├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ └── test.yml
├── assets
└── SMTPKitten.png
├── .gitignore
├── .swiftpm
├── xcode
│ └── package.xcworkspace
│ │ └── contents.xcworkspacedata
└── configuration
│ └── Package.resolved
├── Sources
└── SMTPKitten
│ ├── Connection
│ ├── Errors.swift
│ ├── Request+Reply.swift
│ ├── Helpers.swift
│ ├── ReplyDecoder.swift
│ ├── SMTPSSLMode.swift
│ ├── SMTPCommandsHelper.swift
│ └── SMTPConnection.swift
│ ├── Types
│ ├── Mail
│ │ ├── +Disposition.swift
│ │ ├── MailBodyBuilder.swift
│ │ ├── +build.swift
│ │ ├── +Attachment.swift
│ │ ├── Mail.swift
│ │ ├── +Content.swift
│ │ └── +serialization.swift
│ └── MailUser.swift
│ ├── SMTPClient+Commands
│ ├── +sendMail.swift
│ ├── +login.swift
│ └── +handshake.swift
│ └── ConnectionPool
│ └── SMTPClient.swift
├── README.md
├── Tests
└── SMTPKittenTests
│ └── SMTPKittenTests.swift
├── Package.swift
└── Package.resolved
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [joannis]
2 |
--------------------------------------------------------------------------------
/assets/SMTPKitten.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Joannis/SMTPKitten/HEAD/assets/SMTPKitten.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | .swiftpm/xcode/xcshareddata
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/Errors.swift:
--------------------------------------------------------------------------------
1 | enum SMTPConnectionError: Error {
2 | case endOfStream
3 | case protocolError
4 | case startTLSFailure
5 | case commandFailed(code: Int)
6 | case loginFailed
7 | }
8 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 | allow:
8 | - dependency-type: all
9 | groups:
10 | dependencies:
11 | patterns:
12 | - "*"
13 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/+Disposition.swift:
--------------------------------------------------------------------------------
1 | extension Mail {
2 | public struct Disposition: Sendable {
3 | enum _Disposition: String, Sendable {
4 | case inline
5 | case attachment
6 | }
7 |
8 | let disposition: _Disposition
9 |
10 | public static let `inline` = Disposition(disposition: .inline)
11 | public static let attachment = Disposition(disposition: .attachment)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/MailBodyBuilder.swift:
--------------------------------------------------------------------------------
1 | @resultBuilder
2 | public struct MailBodyBuilder {
3 | /// Creates the email contents body.
4 | public static func buildBlock(_ components: Mail.Content...) -> Mail.Content {
5 | let blocks = components.flatMap(\.content.blocks)
6 |
7 | if blocks.count == 1 {
8 | return .single(blocks[0])
9 | } else {
10 | return .multipart(blocks)
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/+build.swift:
--------------------------------------------------------------------------------
1 | extension Mail {
2 | public static func build(
3 | from: MailUser,
4 | to: Set,
5 | cc: Set = [],
6 | subject: String,
7 | @MailBodyBuilder content: () throws -> Content
8 | ) rethrows -> Mail {
9 | try Mail(
10 | from: from,
11 | to: to,
12 | cc: cc,
13 | subject: subject,
14 | content: content()
15 | )
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/MailUser.swift:
--------------------------------------------------------------------------------
1 | /// A user that can be used in an email. This can be either the sender or a recipient.
2 | public struct MailUser: Hashable, Sendable {
3 | /// The user's name that is displayed in an email. Optional.
4 | public let name: String?
5 |
6 | /// The user's email address.
7 | public let email: String
8 |
9 | /// A new mail user with an optional name.
10 | public init(name: String? = nil, email: String) {
11 | self.name = name
12 | self.email = email
13 | }
14 |
15 | /// Generates the SMTP formatted string of the user.
16 | var smtpFormatted: String {
17 | if let name = name {
18 | return "\(name) <\(email)>"
19 | } else {
20 | return "<\(email)>"
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/Request+Reply.swift:
--------------------------------------------------------------------------------
1 | import NIOCore
2 |
3 | struct SMTPRequest: Sendable {
4 | let buffer: ByteBuffer
5 | internal let continuation: CheckedContinuation
6 | }
7 |
8 | struct SMTPReply: Sendable {
9 | let code: Int
10 | var isSuccessful: Bool {
11 | code < 400
12 | }
13 | var isFailed: Bool {
14 | code >= 400
15 | }
16 | let lines: [ByteBuffer]
17 | }
18 |
19 | /// The response codes that can be received from the SMTP server.
20 | public enum SMTPCode: Int {
21 | case serviceReady = 220
22 | case connectionClosing = 221
23 | case authSucceeded = 235
24 | case commandOK = 250
25 | case willForward = 251
26 | case containingChallenge = 334
27 | case startMailInput = 354
28 | case commandNotRecognized = 502
29 | }
30 |
31 | struct SMTPReplyLine: Sendable {
32 | let code: Int
33 | let contents: ByteBuffer
34 | let isLast: Bool
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/SMTPClient+Commands/+sendMail.swift:
--------------------------------------------------------------------------------
1 | extension SMTPConnection.Handle {
2 | public func sendMail(_ mail: Mail) async throws {
3 | var recipients = [MailUser]()
4 |
5 | for user in mail.to {
6 | recipients.append(user)
7 | }
8 |
9 | for user in mail.cc {
10 | recipients.append(user)
11 | }
12 |
13 | for user in mail.bcc {
14 | recipients.append(user)
15 | }
16 |
17 | try await send(.startMail(mail))
18 | .status(.commandOK)
19 |
20 | for address in recipients {
21 | try await send(.mailRecipient(address.email))
22 | .status(.commandOK, .willForward)
23 | }
24 |
25 | try await send(.startMailData)
26 | .status(.startMailInput)
27 |
28 | try await send(.mailData(mail))
29 | .status(.commandOK)
30 | }
31 | }
32 |
33 |
34 | func testIfThisWorks() {
35 | protocol MyProtocol: ~Copyable {
36 | consuming func consume()
37 | }
38 |
39 | struct MyType: MyProtocol {
40 | consuming func consume() {}
41 | }
42 |
43 | let instance: any MyProtocol = MyType()
44 | instance.consume()
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/SMTPClient+Commands/+login.swift:
--------------------------------------------------------------------------------
1 | extension SMTPConnection.Handle {
2 | internal func selectAuthMethod() -> SMTPAuthMethod {
3 | if handshake.capabilities.contains(.loginPlain) {
4 | return .plain
5 | } else {
6 | return .login
7 | }
8 | }
9 |
10 | public func login(
11 | user: String,
12 | password: String,
13 | method: SMTPAuthMethod? = nil
14 | ) async throws {
15 | let method = method ?? selectAuthMethod()
16 |
17 | switch method.method {
18 | case .login:
19 | try await send(.authenticateLogin)
20 | .status(.containingChallenge, or: SMTPConnectionError.loginFailed)
21 |
22 | try await send(.authenticateUser(user))
23 | .status(.containingChallenge, or: SMTPConnectionError.loginFailed)
24 |
25 | try await self.send(.authenticatePassword(password))
26 | .status(.authSucceeded, or: SMTPConnectionError.loginFailed)
27 | case .plain:
28 | try await send(.authenticatePlain(
29 | credentials: .init(user: user, password: password))
30 | )
31 | .status(.authSucceeded, or: SMTPConnectionError.loginFailed)
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/Helpers.swift:
--------------------------------------------------------------------------------
1 | import NIO
2 | import Foundation
3 |
4 | let cr: UInt8 = 0x0d
5 | let lf: UInt8 = 0x0a
6 | fileprivate let smtpDateFormatter: DateFormatter = {
7 | let formatter = DateFormatter()
8 | formatter.dateFormat = "EEE, d MMM yyyy HH:mm:ss ZZZ"
9 | formatter.locale = Locale(identifier: "en_US_POSIX")
10 | return formatter
11 | }()
12 |
13 | extension String {
14 | var base64Encoded: String {
15 | Data(utf8).base64EncodedString()
16 | }
17 | }
18 |
19 | extension Date {
20 | var smtpFormatted: String {
21 | return smtpDateFormatter.string(from: self)
22 | }
23 | }
24 |
25 | extension SMTPReply {
26 | func status(_ status: SMTPCode..., or error: Error? = nil) throws {
27 | let error = error ?? SMTPConnectionError.commandFailed(code: code)
28 |
29 | guard let currentStatus = SMTPCode(rawValue: code) else {
30 | throw error
31 | }
32 |
33 | for neededStatus in status {
34 | if currentStatus == neededStatus {
35 | return
36 | }
37 | }
38 |
39 | throw error
40 | }
41 |
42 | func isSuccessful(or error: Error? = nil) throws {
43 | guard self.isSuccessful else {
44 | throw error ?? SMTPConnectionError.commandFailed(code: code)
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/+Attachment.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import NIOFoundationCompat
3 | import NIOCore
4 |
5 | extension Mail {
6 | public struct Attachment: Sendable {
7 | let mime: String
8 | let base64: String
9 | let filename: String?
10 | let contentDisposition: Disposition
11 |
12 | public var content: Content { .single(.attachment(self)) }
13 |
14 | public init(
15 | _ buffer: Data,
16 | mimeType mime: String,
17 | filename: String? = nil,
18 | contentDisposition: Disposition = .inline
19 | ) {
20 | self.mime = mime
21 | self.base64 = buffer.base64EncodedString(options: .lineLength76Characters)
22 | self.filename = filename
23 | self.contentDisposition = contentDisposition
24 | }
25 |
26 | public init(
27 | _ buffer: ByteBuffer,
28 | mimeType mime: String,
29 | filename: String? = nil,
30 | contentDisposition: Disposition = .inline
31 | ) {
32 | self.init(
33 | buffer.getData(at: 0, length: buffer.readableBytes)!,
34 | mimeType: mime,
35 | filename: filename,
36 | contentDisposition: contentDisposition
37 | )
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | concurrency:
3 | group: ${{ github.workflow }}-${{ github.ref }}
4 | cancel-in-progress: true
5 | on:
6 | pull_request: { types: [opened, reopened, synchronize, ready_for_review] }
7 | push: { branches: [main] }
8 |
9 | env:
10 | LOG_LEVEL: info
11 | SWIFT_DETERMINISTIC_HASHING: 1
12 | jobs:
13 | test:
14 | services:
15 | mailpit:
16 | image: axllent/mailpit:latest
17 | ports:
18 | # web UI - we don't need in CI
19 | # - 8025:8025
20 | - 1025:1025
21 | env:
22 | MP_MAX_MESSAGES: 100
23 | MP_SMTP_AUTH_ACCEPT_ANY: 1
24 | MP_SMTP_AUTH_ALLOW_INSECURE: 1
25 | options: >-
26 | --health-interval 10s
27 | --health-timeout 5s
28 | --health-retries 5
29 | runs-on: ubuntu-latest
30 | container: swift:5.10-jammy
31 | steps:
32 | - uses: actions/checkout@v4
33 | - name: Resolve
34 | run: swift package resolve
35 | - name: Run tests
36 | run: swift test
37 | env:
38 | SWIFT_DETERMINISTIC_HASHING: 1
39 | SMTP_HOSTNAME: mailpit
40 | SMTP_PORT: 1025
41 | SMTP_USER: 00000
42 | SMTP_PASSWORD: 00000
43 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/ReplyDecoder.swift:
--------------------------------------------------------------------------------
1 | import NIOCore
2 |
3 | enum SMTPReplyDecodingError: Error {
4 | case invalidReplyFormat
5 | case invalidReplyCode(String)
6 | }
7 |
8 | struct SMTPReplyDecoder: ByteToMessageDecoder {
9 | typealias InboundOut = SMTPReplyLine
10 |
11 | mutating func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
12 | guard
13 | buffer.readableBytes >= 3,
14 | let codeString = buffer.readString(length: 3)
15 | else {
16 | throw SMTPReplyDecodingError.invalidReplyFormat
17 | }
18 |
19 | guard
20 | let code = Int(codeString),
21 | code >= 200, code < 600
22 | else {
23 | throw SMTPReplyDecodingError.invalidReplyCode(codeString)
24 | }
25 |
26 | switch buffer.readInteger() as UInt8? {
27 | case 0x2d: // - (hyphen, minus)
28 | let buffer = buffer.readSlice(length: buffer.readableBytes)!
29 | let line = SMTPReplyLine(code: code, contents: buffer, isLast: false)
30 | context.fireChannelRead(wrapInboundOut(line))
31 | return .continue
32 | case 0x20: // Space
33 | let buffer = buffer.readSlice(length: buffer.readableBytes)!
34 | let line = SMTPReplyLine(code: code, contents: buffer, isLast: true)
35 | context.fireChannelRead(wrapInboundOut(line))
36 | return .continue
37 | default:
38 | throw SMTPReplyDecodingError.invalidReplyFormat
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | To get started, add the SMTPKitten dependency:
4 |
5 | ```swift
6 | .package(url: "https://github.com/joannis/SMTPKitten.git", from: "1.0.0"),
7 | ```
8 |
9 | And add it as a dependency of your target:
10 |
11 | ```swift
12 | .product(name: "SMTPKitten", package: "SMTPKitten"),
13 | ```
14 |
15 | ### Create a connection
16 |
17 | ```swift
18 | try await SMTPConnection.withConnection(
19 | to: "localhost",
20 | port: 1025,
21 | ssl: .insecure
22 | ) { client in
23 | // 1. Authenticate
24 | try await client.login(
25 | user: "xxxxxx",
26 | password: "hunter2"
27 | )
28 |
29 | // 2. Send emails
30 | }
31 | ```
32 |
33 | ### Sending Emails
34 |
35 | Before sending an email, first contruct a `Mail` object. Then, call `sendMail` on the client.
36 |
37 | ```swift
38 | let mail = Mail(
39 | from: MailUser(name: "My Mailer", email: "noreply@example.com"),
40 | to: [MailUser(name: "John Doe", email: "john.doe@example.com")],
41 | subject: "Welcome to our app!",
42 | content: .plain("Welcome to our app, you're all set up & stuff.")
43 | )
44 |
45 | try await client.sendMail(mail)
46 | ```
47 |
48 | The `Mail.Content` type supports various other types of information including HTML, Alternative (HTML with Plaintext fallback) and multipart.
49 |
50 | ### Community
51 |
52 | [Join our Discord](https://discord.gg/H6799jh) for any questions and friendly banter.
53 |
54 | If you need hands-on support on your projects, our team is available at [hello@unbeatable.software](mailto:hello@unbeatable.software).
55 |
--------------------------------------------------------------------------------
/Tests/SMTPKittenTests/SMTPKittenTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import SMTPKitten
3 |
4 | final class SMTPKittenTests: XCTestCase {
5 | var port: Int {
6 | ProcessInfo.processInfo.environment["SMTP_PORT"].flatMap(Int.init) ?? 1025
7 | }
8 |
9 | var hostname: String {
10 | ProcessInfo.processInfo.environment["SMTP_HOSTNAME"] ?? "localhost"
11 | }
12 |
13 | func testBasics() async throws {
14 | try await SMTPConnection.withConnection(
15 | to: hostname,
16 | port: port,
17 | ssl: .insecure
18 | ) { connection in
19 | try await connection.sendMail(
20 | Mail(
21 | from: MailUser(name: "Joannis", email: "joannis@unbeatable.software"),
22 | to: [MailUser(name: "MailHog User", email: "test@mail.hog")],
23 | subject: "Test mail",
24 | content: .plain("Hello world")
25 | )
26 | )
27 | }
28 | }
29 |
30 | func testAlternative() async throws {
31 | let html = "Hello, from Swift!
"
32 |
33 | try await SMTPConnection.withConnection(
34 | to: hostname,
35 | port: port,
36 | ssl: .insecure
37 | ) { connection in
38 | let mail = Mail(
39 | from: MailUser(name: "My Mailer", email: "noreply@example.com"),
40 | to: [MailUser(name: "John Doe", email: "john.doe@example.com")],
41 | subject: "Welcome to our app!",
42 | content: .alternative("Welcome to our app, you're all set up & stuff.", html: html)
43 | )
44 |
45 | try await connection.sendMail(mail)
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/SMTPClient+Commands/+handshake.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | internal struct SMTPHandshake {
4 | enum KnownCapability {
5 | case startTLS
6 | case mime8bit
7 | case pipelining
8 | case pipeconnect
9 | case login
10 | case loginPlain
11 | }
12 |
13 | var capabilities = Set()
14 |
15 | init(_ message: SMTPReply) {
16 | for line in message.lines {
17 | let line = String(buffer: line)
18 |
19 | switch line {
20 | case "STARTTLS":
21 | capabilities.insert(.startTLS)
22 | case "8BITMIME":
23 | capabilities.insert(.mime8bit)
24 | case "PIPELINING":
25 | capabilities.insert(.pipelining)
26 | case "PIPECONNECT":
27 | capabilities.insert(.pipeconnect)
28 | case let auth where auth.hasPrefix("LOGIN"):
29 | for method in auth.split(separator: " ").dropFirst() {
30 | switch method {
31 | case "PLAIN":
32 | capabilities.insert(.loginPlain)
33 | case "LOGIN":
34 | capabilities.insert(.login)
35 | default:
36 | ()
37 | }
38 | }
39 | default:
40 | ()
41 | }
42 | }
43 | }
44 | }
45 |
46 | extension SMTPConnection.Handle {
47 | internal func handshake(hostname: String) async throws -> SMTPHandshake {
48 | var message = try await send(.ehlo(hostname: hostname))
49 | if message.isSuccessful {
50 | return SMTPHandshake(message)
51 | }
52 |
53 | message = try await self.send(.helo(hostname: hostname))
54 | return SMTPHandshake(message)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.10
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SMTPKitten",
8 | platforms: [
9 | .macOS(.v14)
10 | ],
11 | products: [
12 | // Products define the executables and libraries produced by a package, and make them visible to other packages.
13 | .library(
14 | name: "SMTPKitten",
15 | targets: ["SMTPKitten"]
16 | ),
17 | ],
18 | dependencies: [
19 | .package(url: "https://github.com/vapor/multipart-kit.git", from: "4.7.0"),
20 | .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.6.3"),
21 |
22 | // 🚀
23 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
24 | .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.0.0"),
25 |
26 | // 🔑
27 | .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.0.0"),
28 | ],
29 | targets: [
30 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
31 | // Targets can depend on other targets in this package, and on products in packages which this package depends on.
32 | .target(
33 | name: "SMTPKitten",
34 | dependencies: [
35 | .product(name: "MultipartKit", package: "multipart-kit"),
36 | .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
37 | .product(name: "NIOCore", package: "swift-nio"),
38 | .product(name: "NIOPosix", package: "swift-nio"),
39 | .product(name: "NIOFoundationCompat", package: "swift-nio"),
40 | .product(name: "NIOExtras", package: "swift-nio-extras"),
41 | .product(name: "NIOSSL", package: "swift-nio-ssl"),
42 | ]),
43 | .testTarget(
44 | name: "SMTPKittenTests",
45 | dependencies: ["SMTPKitten"]),
46 | ]
47 | )
48 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/Mail.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A mail that can be sent using SMTP. This is the main type that you will be using. It contains all the information that is needed to send an email.
4 | public struct Mail: Sendable {
5 | /// The message ID of the mail. This is automatically generated.
6 | public let messageId: String
7 |
8 | /// The sender of the mail. This is a `MailUser` struct that contains the name and email address of the sender.
9 | public var from: MailUser
10 |
11 | /// The reply-to address of the mail. This is a `MailUser` struct that contains the name and email address that replies should be sent to.
12 | public var replyTo: MailUser?
13 |
14 | /// The recipients of the mail. This is a set of `MailUser` structs that contains the name and email address of the recipients.
15 | public var to: Set
16 |
17 | /// The carbon copy recipients of the mail. This is a set of `MailUser` structs that contain the name and email address of the recipients.
18 | public var cc: Set
19 |
20 | /// The blind carbon copy recipients of the mail. This is a set of `MailUser` structs that contain the name and email address of the recipients.
21 | public var bcc: Set
22 |
23 | /// Adds custom headers and overwrites the implicitly created ones. Use this property to add headers relevant for sending bulk emails, such as `List-Unsubscribe` or `Precedence` headers.
24 | public var customHeaders: [String: String]
25 |
26 | /// The subject of the mail.
27 | public var subject: String
28 |
29 | /// The text of the mail. This can be either plain text or HTML depending on the `contentType` property.
30 | public var content: Content
31 |
32 | /// Creates a new `Mail` instance.
33 | public init(
34 | from: MailUser,
35 | to: Set,
36 | cc: Set = [],
37 | bcc: Set = [],
38 | customHeaders: [String: String] = [:],
39 | subject: String,
40 | content: Content
41 | ) {
42 | self.messageId = UUID().uuidString
43 | self.from = from
44 | self.to = to
45 | self.cc = cc
46 | self.bcc = bcc
47 | self.customHeaders = customHeaders
48 | self.subject = subject
49 | self.content = content
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/SMTPSSLMode.swift:
--------------------------------------------------------------------------------
1 | import NIOSSL
2 |
3 | public struct SMTPSSLConfiguration {
4 | internal let configuration: _Configuration
5 |
6 | public static var `default`: SMTPSSLConfiguration {
7 | return SMTPSSLConfiguration(configuration: .default)
8 | }
9 |
10 | public static func customRoot(path: String) -> SMTPSSLConfiguration {
11 | return SMTPSSLConfiguration(configuration: .customRoot(path: path))
12 | }
13 |
14 | public static func custom(configuration: TLSConfiguration) -> SMTPSSLConfiguration {
15 | return SMTPSSLConfiguration(configuration: .custom(configuration))
16 | }
17 |
18 | internal enum _Configuration {
19 | case `default`
20 | case customRoot(path: String)
21 | case custom(TLSConfiguration)
22 |
23 | internal func makeTlsConfiguration() -> TLSConfiguration {
24 | switch self {
25 | case .default:
26 | return TLSConfiguration.clientDefault
27 | case .customRoot(let path):
28 | var tlsConfig = TLSConfiguration.makeClientConfiguration()
29 | tlsConfig.trustRoots = .file(path)
30 | return tlsConfig
31 | case .custom(let config):
32 | return config
33 | }
34 | }
35 | }
36 | }
37 |
38 | /// The mode that the SMTP client should use for SSL. This can be either `startTLS`, `tls` or `insecure`.
39 | public struct SMTPSSLMode {
40 | internal enum _Mode {
41 | /// The SMTP client should use the `STARTTLS` command to upgrade the connection to SSL.
42 | case startTLS(configuration: SMTPSSLConfiguration)
43 |
44 | /// The SMTP client should use SSL from the start.
45 | case tls(configuration: SMTPSSLConfiguration)
46 |
47 | /// The SMTP client should not use SSL.
48 | case insecure
49 | }
50 |
51 | internal let mode: _Mode
52 |
53 | public static var insecure: SMTPSSLMode {
54 | return SMTPSSLMode(mode: .insecure)
55 | }
56 |
57 | public static func startTLS(configuration: SMTPSSLConfiguration = .default) -> SMTPSSLMode {
58 | return SMTPSSLMode(mode: .startTLS(configuration: configuration))
59 | }
60 |
61 | public static func tls(configuration: SMTPSSLConfiguration = .default) -> SMTPSSLMode {
62 | return SMTPSSLMode(mode: .tls(configuration: configuration))
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/.swiftpm/configuration/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "multipart-kit",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/vapor/multipart-kit.git",
7 | "state" : {
8 | "revision" : "a31236f24bfd2ea2f520a74575881f6731d7ae68",
9 | "version" : "4.7.0"
10 | }
11 | },
12 | {
13 | "identity" : "swift-atomics",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/apple/swift-atomics.git",
16 | "state" : {
17 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985",
18 | "version" : "1.2.0"
19 | }
20 | },
21 | {
22 | "identity" : "swift-collections",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/apple/swift-collections.git",
25 | "state" : {
26 | "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d",
27 | "version" : "1.1.2"
28 | }
29 | },
30 | {
31 | "identity" : "swift-http-types",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/apple/swift-http-types",
34 | "state" : {
35 | "revision" : "ae67c8178eb46944fd85e4dc6dd970e1f3ed6ccd",
36 | "version" : "1.3.0"
37 | }
38 | },
39 | {
40 | "identity" : "swift-nio",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/apple/swift-nio.git",
43 | "state" : {
44 | "revision" : "4c4453b489cf76e6b3b0f300aba663eb78182fad",
45 | "version" : "2.70.0"
46 | }
47 | },
48 | {
49 | "identity" : "swift-nio-extras",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/apple/swift-nio-extras.git",
52 | "state" : {
53 | "revision" : "d1ead62745cc3269e482f1c51f27608057174379",
54 | "version" : "1.24.0"
55 | }
56 | },
57 | {
58 | "identity" : "swift-nio-http2",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/apple/swift-nio-http2.git",
61 | "state" : {
62 | "revision" : "b5f7062b60e4add1e8c343ba4eb8da2e324b3a94",
63 | "version" : "1.34.0"
64 | }
65 | },
66 | {
67 | "identity" : "swift-nio-ssl",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/apple/swift-nio-ssl.git",
70 | "state" : {
71 | "revision" : "a9fa5efd86e7ce2e5c1b6de113262e58035ca251",
72 | "version" : "2.27.1"
73 | }
74 | },
75 | {
76 | "identity" : "swift-system",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/apple/swift-system.git",
79 | "state" : {
80 | "revision" : "d2ba781702a1d8285419c15ee62fd734a9437ff5",
81 | "version" : "1.3.2"
82 | }
83 | }
84 | ],
85 | "version" : 2
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/ConnectionPool/SMTPClient.swift:
--------------------------------------------------------------------------------
1 | import ServiceLifecycle
2 |
3 | enum SMTPClientError: Error {
4 | case notRunning
5 | }
6 |
7 | public actor SMTPClient: Service {
8 | typealias SendMails = AsyncStream<(Mail, CheckedContinuation)>
9 |
10 | let host: String
11 | let port: Int
12 | let ssl: SMTPSSLMode
13 | let onCreateConnection: (inout SMTPConnection.Handle) async throws -> Void
14 | var writeMail: SendMails.Continuation?
15 |
16 | // How long to wait before retrying to connect to the server
17 | private nonisolated let backoff = Duration.seconds(5)
18 |
19 | init(
20 | to host: String,
21 | port: Int = 587,
22 | ssl: SMTPSSLMode,
23 | onCreateConnection: @escaping (inout SMTPConnection.Handle) async throws -> Void
24 | ) {
25 | self.host = host
26 | self.port = port
27 | self.ssl = ssl
28 | self.onCreateConnection = onCreateConnection
29 | }
30 |
31 | public func sendMail(_ mail: Mail) async throws {
32 | guard let writeMail else {
33 | throw SMTPClientError.notRunning
34 | }
35 |
36 | try await withCheckedThrowingContinuation { continuation in
37 | writeMail.yield((mail, continuation))
38 | }
39 | }
40 |
41 | public func run() async throws {
42 | precondition(writeMail == nil, "Cannot run SMTPClient twice in parallel")
43 |
44 | let queries = SendMails.makeStream()
45 | var iterator = queries.stream.makeAsyncIterator()
46 | self.writeMail = queries.continuation
47 | await withTaskCancellationHandler {
48 | while !Task.isCancelled {
49 | do {
50 | try await SMTPConnection.withConnection(
51 | to: self.host,
52 | port: self.port,
53 | ssl: self.ssl
54 | ) { handle in
55 | try await self.onCreateConnection(&handle)
56 | while let (mail, continuation) = await iterator.next() {
57 | do {
58 | try await handle.sendMail(mail)
59 | continuation.resume()
60 | } catch {
61 | continuation.resume(throwing: error)
62 | }
63 | }
64 | }
65 |
66 | try await Task.sleep(for: backoff)
67 | } catch {}
68 | }
69 | self.writeMail = nil
70 | } onCancel: {
71 | queries.continuation.finish()
72 | }
73 |
74 | while let (_, continuation) = await iterator.next() {
75 | continuation.resume(throwing: CancellationError())
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/SMTPCommandsHelper.swift:
--------------------------------------------------------------------------------
1 | import NIOCore
2 |
3 | enum SMTPCredentials {
4 | struct Plain {
5 | let user: String
6 | let password: String
7 |
8 | var text: String {
9 | "\0\(user)\0\(password)"
10 | }
11 | }
12 | }
13 |
14 | /// SMTP Authentication method.
15 | public struct SMTPAuthMethod {
16 | internal enum _Method: String, CaseIterable {
17 | case plain = "PLAIN"
18 | case login = "LOGIN"
19 | }
20 |
21 | let method: _Method
22 |
23 | public static let login = SMTPAuthMethod(method: .login)
24 | public static let plain = SMTPAuthMethod(method: .plain)
25 | }
26 |
27 | enum _SMTPRequest: Sendable {
28 | case helo(hostname: String)
29 | case ehlo(hostname: String)
30 | case starttls
31 | case authenticatePlain(credentials: SMTPCredentials.Plain)
32 | case authenticateLogin
33 | case authenticateCramMd5
34 | case authenticateXOAuth2(credentials: String)
35 | case authenticateUser(String)
36 | case authenticatePassword(String)
37 | case quit
38 |
39 | case startMail(Mail)
40 | case mailRecipient(String)
41 | case startMailData
42 | case mailData(Mail)
43 |
44 | func write(into out: inout ByteBuffer, forHost host: String) throws {
45 | switch self {
46 | case .helo(let hostname):
47 | out.writeString("HELO ")
48 | out.writeString(hostname)
49 | case .ehlo(let hostname):
50 | out.writeString("EHLO ")
51 | out.writeString(hostname)
52 | case .startMail(let mail):
53 | out.writeString("MAIL FROM: <\(mail.from.email)>")
54 | case .mailRecipient(let address):
55 | out.writeString("RCPT TO: <\(address)>")
56 | case .startMailData:
57 | out.writeString("DATA")
58 | case .mailData(let mail):
59 | var headersText = ""
60 | for header in mail.headers(forHost: host) {
61 | headersText += "\(header.key): \(header.value)\r\n"
62 | }
63 | headersText += "Content-Transfer-Encoding: 8bit\r\n"
64 | out.writeString(headersText)
65 | out.writeString("\r\n")
66 | try mail.content.writePayload(into: &out)
67 | out.writeString("\r\n.")
68 | case .starttls:
69 | out.writeString("STARTTLS")
70 | case .authenticatePlain(let credentials):
71 | out.writeString("AUTH PLAIN \(credentials.text.base64Encoded)")
72 | case .authenticateLogin:
73 | out.writeString("AUTH LOGIN")
74 | case .authenticateCramMd5:
75 | out.writeString("AUTH CRAM-MD5")
76 | case .authenticateXOAuth2(let credentials):
77 | out.writeString("AUTH XOAUTH2 ")
78 | out.writeString(credentials)
79 | case .authenticateUser(let user):
80 | out.writeString(user.base64Encoded)
81 | case .authenticatePassword(let password):
82 | out.writeString(password.base64Encoded)
83 | case .quit:
84 | out.writeString("QUIT")
85 | }
86 |
87 | out.writeInteger(cr)
88 | out.writeInteger(lf)
89 | }
90 | }
91 |
92 | extension SMTPConnection.Handle {
93 | func send(_ request: _SMTPRequest) async throws -> SMTPReply {
94 | var buffer = ByteBufferAllocator().buffer(capacity: 4096)
95 | try request.write(into: &buffer, forHost: host)
96 | return try await send(buffer)
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/+Content.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import NIO
3 |
4 | extension Mail {
5 | public struct Content: Sendable {
6 | public struct ID: Hashable, Sendable {
7 | let id: String
8 |
9 | public init(named name: String = UUID().uuidString) {
10 | self.id = name
11 | }
12 | }
13 |
14 | internal enum Block: Sendable {
15 | case plain(String)
16 | case html(String)
17 | case image(Image)
18 | case attachment(Attachment)
19 | case alternative(boundary: String, text: String, html: String)
20 | }
21 |
22 | internal enum _Content: Sendable {
23 | case single(Block)
24 | case multipart(boundary: String, blocks: [Block])
25 | }
26 |
27 | internal let _content: _Content
28 | public var content: Content { self }
29 |
30 | internal var blocks: [Block] {
31 | switch _content {
32 | case .single(let block):
33 | return [block]
34 | case .multipart(_, let blocks):
35 | return blocks
36 | }
37 | }
38 |
39 | internal static func single(_ block: Block) -> Content {
40 | return Content(_content: .single(block))
41 | }
42 |
43 | internal static func multipart(
44 | _ blocks: [Block],
45 | boundary: String = UUID().uuidString
46 | ) -> Content {
47 | return Content(_content: .multipart(boundary: boundary, blocks: blocks))
48 | }
49 |
50 | public static func multipart(
51 | _ content: [Content],
52 | boundary: String = UUID().uuidString
53 | ) -> Content {
54 | let blocks = content.flatMap(\.blocks)
55 | return Content(_content: .multipart(boundary: boundary, blocks: blocks))
56 | }
57 |
58 | public static func plain(_ text: String) -> Content {
59 | return .single(.plain(text))
60 | }
61 |
62 | public static func html(_ html: String) -> Content {
63 | return .single(.html(html))
64 | }
65 |
66 | public static func alternative(_ text: String, html: String) -> Content {
67 | return .single(.alternative(boundary: UUID().uuidString, text: text, html: html))
68 | }
69 |
70 | public static func alternative(_ blocks: [Content]) -> Content {
71 | return .multipart(blocks.flatMap(\.blocks))
72 | }
73 | }
74 | }
75 |
76 | extension Mail.Content {
77 | public struct Image: Sendable {
78 | let mime: String
79 | let base64: String
80 | let filename: String?
81 | let contentDisposition: Mail.Disposition
82 | let contentId: ID
83 |
84 | public var content: Mail.Content { .single(.image(self)) }
85 |
86 | public static func png(
87 | _ buffer: Data,
88 | filename: String? = nil,
89 | contentDisposition: Mail.Disposition = .inline,
90 | contentId: ID = .init()
91 | ) -> Image {
92 | return Image(
93 | mime: "image/png",
94 | base64: buffer.base64EncodedString(options: .lineLength76Characters),
95 | filename: filename,
96 | contentDisposition: contentDisposition,
97 | contentId: contentId
98 | )
99 | }
100 |
101 | public static func jpeg(
102 | _ buffer: Data,
103 | filename: String? = nil,
104 | contentDisposition: Mail.Disposition = .inline,
105 | contentId: ID = .init()
106 | ) -> Image {
107 | return Image(
108 | mime: "image/jpeg",
109 | base64: buffer.base64EncodedString(options: .lineLength76Characters),
110 | filename: filename,
111 | contentDisposition: contentDisposition,
112 | contentId: contentId
113 | )
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "903b62ae30677fd56c2954b97f9910ca4be63d784de96cfaa8f48be16f28759f",
3 | "pins" : [
4 | {
5 | "identity" : "multipart-kit",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/vapor/multipart-kit.git",
8 | "state" : {
9 | "revision" : "3498e60218e6003894ff95192d756e238c01f44e",
10 | "version" : "4.7.1"
11 | }
12 | },
13 | {
14 | "identity" : "swift-algorithms",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/apple/swift-algorithms.git",
17 | "state" : {
18 | "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
19 | "version" : "1.2.1"
20 | }
21 | },
22 | {
23 | "identity" : "swift-async-algorithms",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/apple/swift-async-algorithms.git",
26 | "state" : {
27 | "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97",
28 | "version" : "1.0.3"
29 | }
30 | },
31 | {
32 | "identity" : "swift-atomics",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/apple/swift-atomics.git",
35 | "state" : {
36 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985",
37 | "version" : "1.2.0"
38 | }
39 | },
40 | {
41 | "identity" : "swift-collections",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/apple/swift-collections.git",
44 | "state" : {
45 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
46 | "version" : "1.1.4"
47 | }
48 | },
49 | {
50 | "identity" : "swift-http-structured-headers",
51 | "kind" : "remoteSourceControl",
52 | "location" : "https://github.com/apple/swift-http-structured-headers.git",
53 | "state" : {
54 | "revision" : "d01361d32e14ae9b70ea5bd308a3794a198a2706",
55 | "version" : "1.2.0"
56 | }
57 | },
58 | {
59 | "identity" : "swift-http-types",
60 | "kind" : "remoteSourceControl",
61 | "location" : "https://github.com/apple/swift-http-types.git",
62 | "state" : {
63 | "revision" : "ef18d829e8b92d731ad27bb81583edd2094d1ce3",
64 | "version" : "1.3.1"
65 | }
66 | },
67 | {
68 | "identity" : "swift-log",
69 | "kind" : "remoteSourceControl",
70 | "location" : "https://github.com/apple/swift-log.git",
71 | "state" : {
72 | "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91",
73 | "version" : "1.6.2"
74 | }
75 | },
76 | {
77 | "identity" : "swift-nio",
78 | "kind" : "remoteSourceControl",
79 | "location" : "https://github.com/apple/swift-nio.git",
80 | "state" : {
81 | "revision" : "c51907a839e63ebf0ba2076bba73dd96436bd1b9",
82 | "version" : "2.81.0"
83 | }
84 | },
85 | {
86 | "identity" : "swift-nio-extras",
87 | "kind" : "remoteSourceControl",
88 | "location" : "https://github.com/apple/swift-nio-extras.git",
89 | "state" : {
90 | "revision" : "00f3f72d2f9942d0e2dc96057ab50a37ced150d4",
91 | "version" : "1.25.0"
92 | }
93 | },
94 | {
95 | "identity" : "swift-nio-http2",
96 | "kind" : "remoteSourceControl",
97 | "location" : "https://github.com/apple/swift-nio-http2.git",
98 | "state" : {
99 | "revision" : "170f4ca06b6a9c57b811293cebcb96e81b661310",
100 | "version" : "1.35.0"
101 | }
102 | },
103 | {
104 | "identity" : "swift-nio-ssl",
105 | "kind" : "remoteSourceControl",
106 | "location" : "https://github.com/apple/swift-nio-ssl.git",
107 | "state" : {
108 | "revision" : "0cc3528ff48129d64ab9cab0b1cd621634edfc6b",
109 | "version" : "2.29.3"
110 | }
111 | },
112 | {
113 | "identity" : "swift-numerics",
114 | "kind" : "remoteSourceControl",
115 | "location" : "https://github.com/apple/swift-numerics.git",
116 | "state" : {
117 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
118 | "version" : "1.0.2"
119 | }
120 | },
121 | {
122 | "identity" : "swift-service-lifecycle",
123 | "kind" : "remoteSourceControl",
124 | "location" : "https://github.com/swift-server/swift-service-lifecycle.git",
125 | "state" : {
126 | "revision" : "c2e97cf6f81510f2d6b4a69453861db65d478560",
127 | "version" : "2.6.3"
128 | }
129 | },
130 | {
131 | "identity" : "swift-system",
132 | "kind" : "remoteSourceControl",
133 | "location" : "https://github.com/apple/swift-system.git",
134 | "state" : {
135 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
136 | "version" : "1.4.2"
137 | }
138 | }
139 | ],
140 | "version" : 3
141 | }
142 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Types/Mail/+serialization.swift:
--------------------------------------------------------------------------------
1 | import NIOCore
2 | import Foundation
3 | import MultipartKit
4 |
5 | extension Mail.Content.Block {
6 | var headers: [String: String] {
7 | switch self {
8 | case .plain:
9 | return [
10 | "Content-Type": "text/plain; charset=utf-8",
11 | ]
12 | case .html:
13 | return [
14 | "Content-Type": "text/html; charset=utf-8",
15 | ]
16 | case .alternative(let boundary, _, _):
17 | return [
18 | "Content-Type": "multipart/alternative; boundary=\"\(boundary)\"",
19 | ]
20 | case .image(let image):
21 | var disposition = image.contentDisposition.disposition.rawValue
22 |
23 | if let filename = image.filename {
24 | disposition += "; filename=\"\(filename)\""
25 | }
26 |
27 | return [
28 | "Content-Type": image.mime,
29 | "Content-Disposition": disposition,
30 | "Content-ID": image.contentId.id,
31 | "Content-Transfer-Encoding": "base64",
32 | ]
33 | case .attachment(let attachment):
34 | var disposition = attachment.contentDisposition.disposition.rawValue
35 |
36 | if let filename = attachment.filename {
37 | disposition += "; filename=\"\(filename)\""
38 | }
39 |
40 | return [
41 | "Content-Type": attachment.mime,
42 | "Content-Disposition": disposition,
43 | "Content-Transfer-Encoding": "base64",
44 | ]
45 | }
46 | }
47 |
48 | @discardableResult
49 | internal func writePayload(into buffer: inout ByteBuffer) throws -> Int {
50 | switch self {
51 | case .plain(let text):
52 | return buffer.writeString(text)
53 | case .html(let html):
54 | return buffer.writeString(html)
55 | case .alternative(let boundary, let text, let html):
56 | let writtenBytes = buffer.writerIndex
57 | try MultipartSerializer().serialize(
58 | parts: [
59 | MultipartPart(
60 | headers: [
61 | "Content-Type": "text/plain; charset=utf-8",
62 | ],
63 | body: text
64 | ),
65 | MultipartPart(
66 | headers: [
67 | "Content-Type": "text/html; charset=utf-8",
68 | ],
69 | body: html
70 | )
71 | ],
72 | boundary: boundary,
73 | into: &buffer
74 | )
75 | return buffer.writerIndex - writtenBytes
76 | case .image(let image):
77 | return buffer.writeString(image.base64)
78 | case .attachment(let attachment):
79 | return buffer.writeString(attachment.base64)
80 | }
81 | }
82 | }
83 |
84 | extension Mail.Content {
85 | internal var headers: [String: String] {
86 | switch _content {
87 | case .single(let block):
88 | return block.headers
89 | case .multipart(boundary: let boundary, blocks: _):
90 | return [
91 | "Content-Type": "multipart/mixed; boundary=\(boundary)",
92 | ]
93 | }
94 | }
95 |
96 | @discardableResult
97 | internal func writePayload(into buffer: inout ByteBuffer) throws -> Int {
98 | switch _content {
99 | case .multipart(boundary: let boundary, blocks: let blocks):
100 | var written = 0
101 | for block in blocks {
102 | let headers = block.headers.map { "\($0): \($1)" }.joined(separator: "\r\n")
103 | written += buffer.writeString("""
104 | --\(boundary)\r
105 | \(headers)\r
106 | \r
107 |
108 | """)
109 |
110 | written += try block.writePayload(into: &buffer)
111 | written += buffer.writeString("\n")
112 | }
113 |
114 | return written
115 | case .single(let block):
116 | return try block.writePayload(into: &buffer)
117 | }
118 | }
119 | }
120 |
121 | extension Mail {
122 | // TODO: Attachments
123 |
124 | /// Generates the headers of the mail.
125 | internal func headers(forHost host: String) -> [String: String] {
126 | var headers = content.headers
127 | for (key, value) in customHeaders {
128 | headers[key] = value
129 | }
130 | headers.reserveCapacity(16)
131 |
132 | headers["MIME-Version"] = "1.0"
133 | headers["Message-Id"] = "<\(UUID().uuidString)@\(host)>"
134 | headers["Date"] = Date().smtpFormatted
135 | headers["From"] = from.smtpFormatted
136 | headers["To"] = to.map(\.smtpFormatted)
137 | .joined(separator: ", ")
138 |
139 | if let replyTo {
140 | headers["Reply-To"] = replyTo.smtpFormatted
141 | }
142 |
143 | if !cc.isEmpty {
144 | headers["Cc"] = cc.map { $0.smtpFormatted }
145 | .joined(separator: ", ")
146 | }
147 |
148 | if let data = subject.data(using: .utf8) {
149 | headers["Subject"] = "=?utf-8?B?\(data.base64EncodedString())?="
150 | } else {
151 | headers["Subject"] = subject
152 | }
153 |
154 | return headers
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/Sources/SMTPKitten/Connection/SMTPConnection.swift:
--------------------------------------------------------------------------------
1 | import NIOCore
2 | import NIOPosix
3 | import NIOExtras
4 | import NIOSSL
5 |
6 | public actor SMTPConnection {
7 | public struct Handle: ~Copyable, Sendable {
8 | enum State {
9 | case preparing(AsyncStream.Continuation)
10 | case prepared(AsyncStream.Continuation, handshake: SMTPHandshake)
11 |
12 | var requestWriter: AsyncStream.Continuation {
13 | switch self {
14 | case .preparing(let continuation):
15 | return continuation
16 | case .prepared(let continuation, _):
17 | return continuation
18 | }
19 | }
20 | }
21 |
22 | let host: String
23 | var state: State
24 |
25 | var handshake: SMTPHandshake {
26 | guard case let .prepared(_, handshake) = state else {
27 | preconditionFailure("SMTPConnection didn't set the SMTPHandshake after getting it")
28 | }
29 |
30 | return handshake
31 | }
32 |
33 | internal func send(_ request: ByteBuffer) async throws -> SMTPReply {
34 | try await withCheckedThrowingContinuation { continuation in
35 | let request = SMTPRequest(buffer: request, continuation: continuation)
36 | state.requestWriter.yield(request)
37 | }
38 | }
39 | }
40 |
41 | internal let channel: NIOAsyncChannel
42 | fileprivate let requests: AsyncStream
43 | fileprivate let requestWriter: AsyncStream.Continuation
44 | fileprivate var error: Error?
45 | internal var isOpen = false
46 |
47 | fileprivate init(channel: NIOAsyncChannel) {
48 | self.channel = channel
49 | (requests, requestWriter) = AsyncStream.makeStream(of: SMTPRequest.self, bufferingPolicy: .unbounded)
50 | }
51 |
52 | private func run() async throws -> Never {
53 | try await withTaskCancellationHandler {
54 | do {
55 | defer { isOpen = false }
56 | try await channel.executeThenClose { inbound, outbound in
57 | self.isOpen = true
58 | var inboundIterator = inbound.makeAsyncIterator()
59 |
60 | for await request in requests {
61 | do {
62 | if request.buffer.readableBytes > 0 {
63 | // The first "message" on a connection send by us is empty
64 | // Because we're expecting to read data here, not write
65 | try await outbound.write(request.buffer)
66 | }
67 |
68 | guard var lastLine = try await inboundIterator.next() else {
69 | throw SMTPConnectionError.endOfStream
70 | }
71 |
72 | let code = lastLine.code
73 | var lines = [lastLine]
74 |
75 | while !lastLine.isLast, let nextLine = try await inboundIterator.next() {
76 | guard nextLine.code == code else {
77 | throw SMTPConnectionError.protocolError
78 | }
79 |
80 | lines.append(nextLine)
81 | lastLine = nextLine
82 | }
83 |
84 | request.continuation.resume(
85 | returning: SMTPReply(
86 | code: code,
87 | lines: lines.map(\.contents)
88 | )
89 | )
90 | } catch {
91 | request.continuation.resume(throwing: error)
92 | throw error
93 | }
94 | }
95 | }
96 |
97 | for await request in requests {
98 | request.continuation.resume(throwing: SMTPConnectionError.endOfStream)
99 | }
100 |
101 | throw CancellationError()
102 | } catch {
103 | self.error = error
104 | for await request in requests {
105 | request.continuation.resume(throwing: error)
106 | }
107 | throw error
108 | }
109 | } onCancel: {
110 | requestWriter.finish()
111 | }
112 | }
113 |
114 | public static func withConnection(
115 | to host: String,
116 | port: Int = 587,
117 | ssl: SMTPSSLMode,
118 | perform: @escaping (inout SMTPConnection.Handle) async throws -> T
119 | ) async throws -> T {
120 | let asyncChannel: NIOAsyncChannel = try await ClientBootstrap(
121 | group: MultiThreadedEventLoopGroup.singleton
122 | ).connect(host: host, port: port) { channel in
123 | do {
124 | if case .tls(let tls) = ssl.mode {
125 | let context = try NIOSSLContext(
126 | configuration: tls.configuration.makeTlsConfiguration()
127 | )
128 |
129 | try channel.pipeline.syncOperations.addHandler(
130 | NIOSSLClientHandler(context: context, serverHostname: host)
131 | )
132 | }
133 |
134 | try channel.pipeline.syncOperations.addHandlers(
135 | ByteToMessageHandler(LineBasedFrameDecoder()),
136 | ByteToMessageHandler(SMTPReplyDecoder())
137 | )
138 |
139 | let asyncChannel = try NIOAsyncChannel(
140 | wrappingChannelSynchronously: channel
141 | )
142 | return channel.eventLoop.makeSucceededFuture(asyncChannel)
143 | } catch {
144 | return channel.eventLoop.makeFailedFuture(error)
145 | }
146 | }
147 |
148 | let connection = SMTPConnection(channel: asyncChannel)
149 | return try await withThrowingTaskGroup(of: T.self) { group in
150 | group.addTask {
151 | var handle = Handle(
152 | host: host,
153 | state: .preparing(connection.requestWriter)
154 | )
155 | // An empty buffer is sent, which the networking layer doesn't (need to) write
156 | // This happens because the first message is always sent by the server
157 | // directly after accepting a client
158 | let serverHello = try await handle.send(ByteBuffer())
159 |
160 | guard serverHello.isSuccessful else {
161 | throw SMTPConnectionError.commandFailed(code: serverHello.code)
162 | }
163 |
164 | // After being accepted as a client, SMTP is request-response based
165 | var handshake = try await handle.handshake(hostname: host)
166 |
167 | if case .startTLS(let tls) = ssl.mode, handshake.capabilities.contains(.startTLS) {
168 | try await handle.starttls(
169 | configuration: tls,
170 | hostname: host,
171 | channel: connection.channel.channel
172 | )
173 | handshake = try await handle.handshake(hostname: host)
174 | }
175 |
176 | handle.state = .prepared(connection.requestWriter, handshake: handshake)
177 | return try await perform(&handle)
178 | }
179 |
180 | group.addTask {
181 | try await connection.run()
182 | }
183 | guard let result = try await group.next() else {
184 | throw CancellationError()
185 | }
186 | group.cancelAll()
187 | return result
188 | }
189 | }
190 | }
191 |
192 | extension SMTPConnection.Handle {
193 | fileprivate func starttls(
194 | configuration: SMTPSSLConfiguration,
195 | hostname: String,
196 | channel: Channel
197 | ) async throws {
198 | try await send(.starttls)
199 | .status(.serviceReady, or: SMTPConnectionError.startTLSFailure)
200 |
201 | let sslContext = try NIOSSLContext(configuration: configuration.configuration.makeTlsConfiguration())
202 | let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
203 |
204 | try await channel.pipeline.addHandler(sslHandler, position: .first).get()
205 | }
206 | }
207 |
--------------------------------------------------------------------------------