add SMTPClient module: connection layer, message formatter, public API, tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
import NIO
|
||||
@preconcurrency import NIOSSL
|
||||
import Models
|
||||
|
||||
actor SMTPConnection {
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let security: SMTPSecurity
|
||||
private let group: EventLoopGroup
|
||||
private var channel: Channel?
|
||||
private let responseHandler: SMTPResponseHandler
|
||||
|
||||
init(host: String, port: Int, security: SMTPSecurity) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.security = security
|
||||
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
self.responseHandler = SMTPResponseHandler()
|
||||
}
|
||||
|
||||
func connect() async throws -> SMTPResponse {
|
||||
let handler = responseHandler
|
||||
let hostname = host
|
||||
|
||||
let bootstrap: ClientBootstrap
|
||||
if security == .ssl {
|
||||
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
|
||||
bootstrap = ClientBootstrap(group: group)
|
||||
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.channelInitializer { channel in
|
||||
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
|
||||
return channel.pipeline.addHandlers([sslHandler, handler])
|
||||
}
|
||||
} else {
|
||||
// STARTTLS: start plain, upgrade later
|
||||
bootstrap = ClientBootstrap(group: group)
|
||||
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
|
||||
.channelInitializer { channel in
|
||||
channel.pipeline.addHandler(handler)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
channel = try await bootstrap.connect(host: host, port: port).get()
|
||||
} catch {
|
||||
throw SMTPError.connectionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return try await handler.waitForGreeting()
|
||||
}
|
||||
|
||||
func sendCommand(_ command: String) async throws -> SMTPResponse {
|
||||
guard let channel else { throw SMTPError.notConnected }
|
||||
let handler = responseHandler
|
||||
|
||||
var buffer = channel.allocator.buffer(capacity: command.utf8.count + 2)
|
||||
buffer.writeString(command + "\r\n")
|
||||
channel.writeAndFlush(buffer, promise: nil)
|
||||
|
||||
return try await handler.waitForResponse()
|
||||
}
|
||||
|
||||
func upgradeToTLS() async throws {
|
||||
guard let channel else { throw SMTPError.notConnected }
|
||||
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
|
||||
let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host)
|
||||
do {
|
||||
try await channel.pipeline.addHandler(sslHandler, position: .first).get()
|
||||
} catch {
|
||||
throw SMTPError.tlsUpgradeFailed
|
||||
}
|
||||
}
|
||||
|
||||
func sendRawBytes(_ data: Data) async throws {
|
||||
guard let channel else { throw SMTPError.notConnected }
|
||||
var buffer = channel.allocator.buffer(capacity: data.count)
|
||||
buffer.writeBytes(data)
|
||||
channel.writeAndFlush(buffer, promise: nil)
|
||||
}
|
||||
|
||||
func disconnect() async throws {
|
||||
try await channel?.close()
|
||||
channel = nil
|
||||
}
|
||||
|
||||
func shutdown() async throws {
|
||||
try await group.shutdownGracefully()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user