add real IMAPClient actor with NIO connection, command pipeline, envelope parsing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
389
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift
Normal file
389
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift
Normal file
@@ -0,0 +1,389 @@
|
||||
import Models
|
||||
import NIO
|
||||
import NIOIMAPCore
|
||||
|
||||
public actor IMAPClient: IMAPClientProtocol {
|
||||
private let host: String
|
||||
private let port: Int
|
||||
private let credentials: Credentials
|
||||
private var connection: IMAPConnection?
|
||||
private var runner: IMAPCommandRunner?
|
||||
|
||||
public init(host: String, port: Int, credentials: Credentials) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
// MARK: - Connection lifecycle
|
||||
|
||||
public func connect() async throws {
|
||||
let conn = IMAPConnection(host: host, port: port)
|
||||
try await conn.connect()
|
||||
connection = conn
|
||||
var newRunner = IMAPCommandRunner(connection: conn)
|
||||
let responses = try await newRunner.run(.login(username: credentials.username, password: credentials.password))
|
||||
guard responses.contains(where: { isOKTagged($0) }) else {
|
||||
throw IMAPError.authenticationFailed
|
||||
}
|
||||
runner = newRunner
|
||||
}
|
||||
|
||||
public func disconnect() async throws {
|
||||
if var r = runner {
|
||||
_ = try? await r.run(.logout)
|
||||
runner = r
|
||||
}
|
||||
try await connection?.disconnect()
|
||||
try await connection?.shutdown()
|
||||
connection = nil
|
||||
runner = nil
|
||||
}
|
||||
|
||||
// MARK: - Mailbox operations
|
||||
|
||||
public func listMailboxes() async throws -> [IMAPMailboxInfo] {
|
||||
guard var runner else { throw IMAPError.notConnected }
|
||||
let responses = try await runner.run(
|
||||
.list(nil, reference: MailboxName(ByteBuffer(string: "")), .mailbox(ByteBuffer(string: "*")))
|
||||
)
|
||||
self.runner = runner
|
||||
return parseListResponses(responses)
|
||||
}
|
||||
|
||||
public func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
|
||||
guard var runner else { throw IMAPError.notConnected }
|
||||
let responses = try await runner.run(.select(MailboxName(ByteBuffer(string: name))))
|
||||
self.runner = runner
|
||||
return parseSelectResponses(responses, name: name)
|
||||
}
|
||||
|
||||
// MARK: - Fetch operations
|
||||
|
||||
public func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
|
||||
guard var runner else { throw IMAPError.notConnected }
|
||||
let lower = UID(rawValue: UInt32(uid + 1))
|
||||
let range = MessageIdentifierRange<UID>(lower...UID.max)
|
||||
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
|
||||
let responses = try await runner.run(.uidFetch(
|
||||
.set(set),
|
||||
[
|
||||
.envelope,
|
||||
.flags,
|
||||
.uid,
|
||||
.rfc822Size,
|
||||
.bodySection(peek: true, SectionSpecifier(kind: .text), nil),
|
||||
],
|
||||
[]
|
||||
))
|
||||
self.runner = runner
|
||||
return parseFetchResponses(responses)
|
||||
}
|
||||
|
||||
public func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
|
||||
guard var runner else { throw IMAPError.notConnected }
|
||||
let lower = UID(rawValue: UInt32(uids.lowerBound))
|
||||
let upper = UID(rawValue: UInt32(uids.upperBound))
|
||||
let range = MessageIdentifierRange<UID>(lower...upper)
|
||||
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
|
||||
let responses = try await runner.run(.uidFetch(.set(set), [.uid, .flags], []))
|
||||
self.runner = runner
|
||||
return parseFlagResponses(responses)
|
||||
}
|
||||
|
||||
public func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
|
||||
guard var runner else { throw IMAPError.notConnected }
|
||||
let uidValue = UID(rawValue: UInt32(uid))
|
||||
let range = MessageIdentifierRange<UID>(uidValue...uidValue)
|
||||
let set = MessageIdentifierSetNonEmpty<UID>(range: range)
|
||||
let responses = try await runner.run(.uidFetch(
|
||||
.set(set),
|
||||
[.bodySection(peek: true, SectionSpecifier(kind: .text), nil)],
|
||||
[]
|
||||
))
|
||||
self.runner = runner
|
||||
return parseBodyResponse(responses)
|
||||
}
|
||||
|
||||
// MARK: - Response parsing helpers
|
||||
|
||||
private func isOKTagged(_ response: Response) -> Bool {
|
||||
if case .tagged(let tagged) = response {
|
||||
if case .ok = tagged.state {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func parseListResponses(_ responses: [Response]) -> [IMAPMailboxInfo] {
|
||||
var results: [IMAPMailboxInfo] = []
|
||||
for response in responses {
|
||||
guard case .untagged(let payload) = response,
|
||||
case .mailboxData(let mailboxData) = payload,
|
||||
case .list(let info) = mailboxData
|
||||
else { continue }
|
||||
let name = String(decoding: info.path.name.bytes, as: UTF8.self)
|
||||
let attributes = Set(info.attributes.map { String($0) })
|
||||
results.append(IMAPMailboxInfo(name: name, attributes: attributes))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private func parseSelectResponses(_ responses: [Response], name: String) -> IMAPMailboxStatus {
|
||||
var uidValidity = 0
|
||||
var uidNext = 0
|
||||
var messageCount = 0
|
||||
var recentCount = 0
|
||||
|
||||
for response in responses {
|
||||
switch response {
|
||||
case .untagged(let payload):
|
||||
switch payload {
|
||||
case .mailboxData(let data):
|
||||
switch data {
|
||||
case .exists(let count):
|
||||
messageCount = count
|
||||
case .recent(let count):
|
||||
recentCount = count
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .conditionalState(let status):
|
||||
switch status {
|
||||
case .ok(let text):
|
||||
if let code = text.code {
|
||||
switch code {
|
||||
case .uidValidity(let val):
|
||||
uidValidity = Int(val)
|
||||
case .uidNext(let val):
|
||||
uidNext = Int(val.rawValue)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return IMAPMailboxStatus(
|
||||
name: name,
|
||||
uidValidity: uidValidity,
|
||||
uidNext: uidNext,
|
||||
messageCount: messageCount,
|
||||
recentCount: recentCount
|
||||
)
|
||||
}
|
||||
|
||||
private func parseFetchResponses(_ responses: [Response]) -> [FetchedEnvelope] {
|
||||
var envelopes: [FetchedEnvelope] = []
|
||||
var currentUID: Int?
|
||||
var currentEnvelope: Envelope?
|
||||
var currentFlags: [Flag] = []
|
||||
var currentSize: Int = 0
|
||||
var bodyBuffer = ByteBuffer()
|
||||
|
||||
for response in responses {
|
||||
switch response {
|
||||
case .fetch(let fetchResponse):
|
||||
switch fetchResponse {
|
||||
case .start:
|
||||
currentUID = nil
|
||||
currentEnvelope = nil
|
||||
currentFlags = []
|
||||
currentSize = 0
|
||||
bodyBuffer = ByteBuffer()
|
||||
case .startUID:
|
||||
currentUID = nil
|
||||
currentEnvelope = nil
|
||||
currentFlags = []
|
||||
currentSize = 0
|
||||
bodyBuffer = ByteBuffer()
|
||||
case .simpleAttribute(let attr):
|
||||
switch attr {
|
||||
case .uid(let uid):
|
||||
currentUID = Int(uid.rawValue)
|
||||
case .envelope(let env):
|
||||
currentEnvelope = env
|
||||
case .flags(let flags):
|
||||
currentFlags = flags
|
||||
case .rfc822Size(let size):
|
||||
currentSize = size
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .streamingBegin:
|
||||
break
|
||||
case .streamingBytes(let bytes):
|
||||
var mutableBytes = bytes
|
||||
bodyBuffer.writeBuffer(&mutableBytes)
|
||||
case .streamingEnd:
|
||||
break
|
||||
case .finish:
|
||||
if let uid = currentUID {
|
||||
let envelope = buildFetchedEnvelope(
|
||||
uid: uid,
|
||||
envelope: currentEnvelope,
|
||||
flags: currentFlags,
|
||||
size: currentSize,
|
||||
bodyBuffer: bodyBuffer
|
||||
)
|
||||
envelopes.append(envelope)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
private func parseFlagResponses(_ responses: [Response]) -> [UIDFlagsPair] {
|
||||
var results: [UIDFlagsPair] = []
|
||||
var currentUID: Int?
|
||||
var currentFlags: [Flag] = []
|
||||
|
||||
for response in responses {
|
||||
switch response {
|
||||
case .fetch(let fetchResponse):
|
||||
switch fetchResponse {
|
||||
case .start, .startUID:
|
||||
currentUID = nil
|
||||
currentFlags = []
|
||||
case .simpleAttribute(let attr):
|
||||
switch attr {
|
||||
case .uid(let uid):
|
||||
currentUID = Int(uid.rawValue)
|
||||
case .flags(let flags):
|
||||
currentFlags = flags
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .finish:
|
||||
if let uid = currentUID {
|
||||
results.append(UIDFlagsPair(
|
||||
uid: uid,
|
||||
isRead: currentFlags.contains(.seen),
|
||||
isFlagged: currentFlags.contains(.flagged)
|
||||
))
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private func parseBodyResponse(_ responses: [Response]) -> (text: String?, html: String?) {
|
||||
var bodyBuffer = ByteBuffer()
|
||||
|
||||
for response in responses {
|
||||
if case .fetch(let fetchResponse) = response {
|
||||
switch fetchResponse {
|
||||
case .streamingBytes(let bytes):
|
||||
var mutableBytes = bytes
|
||||
bodyBuffer.writeBuffer(&mutableBytes)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard bodyBuffer.readableBytes > 0 else {
|
||||
return (text: nil, html: nil)
|
||||
}
|
||||
|
||||
let bodyString = String(buffer: bodyBuffer)
|
||||
|
||||
// Simple heuristic: if the body contains HTML tags, treat as HTML
|
||||
let lowered = bodyString.lowercased()
|
||||
if lowered.contains("<html") || lowered.contains("<body") || lowered.contains("<div") {
|
||||
return (text: nil, html: bodyString)
|
||||
}
|
||||
return (text: bodyString, html: nil)
|
||||
}
|
||||
|
||||
// MARK: - Envelope conversion
|
||||
|
||||
private func buildFetchedEnvelope(
|
||||
uid: Int,
|
||||
envelope: Envelope?,
|
||||
flags: [Flag],
|
||||
size: Int,
|
||||
bodyBuffer: ByteBuffer
|
||||
) -> FetchedEnvelope {
|
||||
let subject = envelope?.subject.flatMap { String(buffer: $0) }
|
||||
let date = envelope?.date.map { String($0) } ?? ""
|
||||
let messageId = envelope?.messageID.map { String($0) }
|
||||
let inReplyTo = envelope?.inReplyTo.map { String($0) }
|
||||
let from = envelope?.from.compactMap { extractEmailAddress($0) }.first
|
||||
let to = envelope?.to.compactMap { extractEmailAddress($0) } ?? []
|
||||
let cc = envelope?.cc.compactMap { extractEmailAddress($0) } ?? []
|
||||
|
||||
let bodyText: String?
|
||||
let bodyHtml: String?
|
||||
if bodyBuffer.readableBytes > 0 {
|
||||
let bodyString = String(buffer: bodyBuffer)
|
||||
let lowered = bodyString.lowercased()
|
||||
if lowered.contains("<html") || lowered.contains("<body") || lowered.contains("<div") {
|
||||
bodyText = nil
|
||||
bodyHtml = bodyString
|
||||
} else {
|
||||
bodyText = bodyString
|
||||
bodyHtml = nil
|
||||
}
|
||||
} else {
|
||||
bodyText = nil
|
||||
bodyHtml = nil
|
||||
}
|
||||
|
||||
let snippet: String? = bodyText.map { text in
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return String(trimmed.prefix(200))
|
||||
}
|
||||
|
||||
return FetchedEnvelope(
|
||||
uid: uid,
|
||||
messageId: messageId,
|
||||
inReplyTo: inReplyTo,
|
||||
references: nil,
|
||||
subject: subject,
|
||||
from: from,
|
||||
to: to,
|
||||
cc: cc,
|
||||
date: date,
|
||||
snippet: snippet,
|
||||
bodyText: bodyText,
|
||||
bodyHtml: bodyHtml,
|
||||
isRead: flags.contains(.seen),
|
||||
isFlagged: flags.contains(.flagged),
|
||||
size: size
|
||||
)
|
||||
}
|
||||
|
||||
private func extractEmailAddress(_ element: EmailAddressListElement) -> Models.EmailAddress? {
|
||||
switch element {
|
||||
case .singleAddress(let addr):
|
||||
let mailbox = addr.mailbox.flatMap { String(buffer: $0) }
|
||||
let host = addr.host.flatMap { String(buffer: $0) }
|
||||
let personName = addr.personName.flatMap { String(buffer: $0) }
|
||||
guard let mailbox, let host else { return nil }
|
||||
return Models.EmailAddress(name: personName, address: "\(mailbox)@\(host)")
|
||||
case .group(let group):
|
||||
// Return the first child address from the group
|
||||
return group.children.compactMap { extractEmailAddress($0) }.first
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Testing
|
||||
@testable import IMAPClient
|
||||
import NIOIMAPCore
|
||||
|
||||
@Suite("IMAP Response Parsing")
|
||||
struct IMAPResponseParsingTests {
|
||||
@Test("IMAPClient can be instantiated and conforms to protocol")
|
||||
func instantiation() {
|
||||
let client = IMAPClient(
|
||||
host: "imap.example.com",
|
||||
port: 993,
|
||||
credentials: .init(username: "user", password: "pass")
|
||||
)
|
||||
let _: any IMAPClientProtocol = client
|
||||
}
|
||||
|
||||
@Test("tag counter increments across commands")
|
||||
func tagCounterIncrements() async {
|
||||
let connection = IMAPConnection(host: "localhost", port: 993)
|
||||
var runner = IMAPCommandRunner(connection: connection)
|
||||
#expect(runner.nextTag() == "A1")
|
||||
#expect(runner.nextTag() == "A2")
|
||||
#expect(runner.nextTag() == "A3")
|
||||
}
|
||||
|
||||
@Test("IMAPError cases are Sendable")
|
||||
func errorSendability() {
|
||||
let error: any Error & Sendable = IMAPError.notConnected
|
||||
#expect(error is IMAPError)
|
||||
}
|
||||
|
||||
@Test("IMAPError has expected cases")
|
||||
func errorCases() {
|
||||
let errors: [any Error] = [
|
||||
IMAPError.notConnected,
|
||||
IMAPError.authenticationFailed,
|
||||
IMAPError.serverError("test"),
|
||||
IMAPError.unexpectedResponse("bad data"),
|
||||
]
|
||||
|
||||
for error in errors {
|
||||
#expect(error is IMAPError)
|
||||
}
|
||||
#expect(errors.count == 4)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import Testing
|
||||
Reference in New Issue
Block a user