├── .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 | --------------------------------------------------------------------------------