From 21227b3c78cb29fb8f5f41be34f2b176303fe516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Fri, 13 Mar 2026 21:12:39 +0100 Subject: [PATCH] add real IMAPClient actor with NIO connection, command pipeline, envelope parsing Co-Authored-By: Claude Opus 4.6 --- .../Sources/IMAPClient/IMAPClient.swift | 389 ++++++++++++++++++ .../IMAPResponseParsingTests.swift | 46 +++ .../Tests/IMAPClientTests/Placeholder.swift | 1 - 3 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift create mode 100644 Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift delete mode 100644 Packages/MagnumOpusCore/Tests/IMAPClientTests/Placeholder.swift diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift new file mode 100644 index 0000000..042f3c4 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift @@ -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(lower...UID.max) + let set = MessageIdentifierSetNonEmpty(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) 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(lower...upper) + let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) + let set = MessageIdentifierSetNonEmpty(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(" 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(" 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 + } + } +} diff --git a/Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift b/Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift new file mode 100644 index 0000000..d3edd38 --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift @@ -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) + } +} diff --git a/Packages/MagnumOpusCore/Tests/IMAPClientTests/Placeholder.swift b/Packages/MagnumOpusCore/Tests/IMAPClientTests/Placeholder.swift deleted file mode 100644 index d196bca..0000000 --- a/Packages/MagnumOpusCore/Tests/IMAPClientTests/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -import Testing