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,92 @@
|
||||
import NIO
|
||||
|
||||
/// NIO handler that parses SMTP line-based responses.
|
||||
/// SMTP response format: `<3-digit code><separator><text>\r\n`
|
||||
/// Separator `-` means continuation, space means final line.
|
||||
final class SMTPResponseHandler: ChannelInboundHandler, @unchecked Sendable {
|
||||
typealias InboundIn = ByteBuffer
|
||||
|
||||
private var stringBuffer = ""
|
||||
private var responseLines: [String] = []
|
||||
private var responseCode: Int?
|
||||
private var continuation: CheckedContinuation<SMTPResponse, Error>?
|
||||
private var greetingContinuation: CheckedContinuation<SMTPResponse, Error>?
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
var buffer = unwrapInboundIn(data)
|
||||
guard let received = buffer.readString(length: buffer.readableBytes) else { return }
|
||||
stringBuffer.append(received)
|
||||
|
||||
while let lineEnd = stringBuffer.range(of: "\r\n") {
|
||||
let line = String(stringBuffer[stringBuffer.startIndex..<lineEnd.lowerBound])
|
||||
stringBuffer.removeSubrange(stringBuffer.startIndex..<lineEnd.upperBound)
|
||||
processLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func processLine(_ line: String) {
|
||||
guard line.count >= 3,
|
||||
let code = Int(line.prefix(3))
|
||||
else { return }
|
||||
|
||||
// Extract text after the code + separator character
|
||||
let text: String
|
||||
if line.count > 4 {
|
||||
text = String(line.dropFirst(4))
|
||||
} else {
|
||||
text = ""
|
||||
}
|
||||
|
||||
responseLines.append(text)
|
||||
|
||||
if responseCode == nil {
|
||||
responseCode = code
|
||||
}
|
||||
|
||||
// Separator is at index 3: `-` for continuation, space for final
|
||||
let isFinal: Bool
|
||||
if line.count > 3 {
|
||||
isFinal = line[line.index(line.startIndex, offsetBy: 3)] == " "
|
||||
} else {
|
||||
isFinal = true
|
||||
}
|
||||
|
||||
if isFinal {
|
||||
let response = SMTPResponse(code: code, lines: responseLines)
|
||||
responseLines = []
|
||||
responseCode = nil
|
||||
|
||||
if let cont = greetingContinuation {
|
||||
greetingContinuation = nil
|
||||
cont.resume(returning: response)
|
||||
} else if let cont = continuation {
|
||||
continuation = nil
|
||||
cont.resume(returning: response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
if let cont = continuation {
|
||||
continuation = nil
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
if let cont = greetingContinuation {
|
||||
greetingContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
context.close(promise: nil)
|
||||
}
|
||||
|
||||
func waitForGreeting() async throws -> SMTPResponse {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
greetingContinuation = cont
|
||||
}
|
||||
}
|
||||
|
||||
func waitForResponse() async throws -> SMTPResponse {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
continuation = cont
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user