diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift index bb2ff31..653b271 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift @@ -93,6 +93,65 @@ public actor IMAPClient: IMAPClientProtocol { return parseFlagResponses(responses) } + public func fetchFullMessage(uid: Int) async throws -> 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: .complete), nil)], + [] + )) + self.runner = runner + return parseFullMessageResponse(responses) + } + + public func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data { + guard var runner else { throw IMAPError.notConnected } + + // Select the mailbox first + let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: mailbox)))) + self.runner = runner + guard selectResponses.contains(where: { isOKTagged($0) }) else { + throw IMAPError.unexpectedResponse("SELECT \(mailbox) failed") + } + + let uidValue = UID(rawValue: UInt32(uid)) + let range = MessageIdentifierRange(uidValue...uidValue) + let set = MessageIdentifierSetNonEmpty(range: range) + + // Parse section path into SectionSpecifier.Part + let sectionParts = section.split(separator: ".").compactMap { Int($0) } + let part = SectionSpecifier.Part(sectionParts) + let spec = SectionSpecifier(part: part) + + let responses = try await runner.run(.uidFetch( + .set(set), + [.bodySection(peek: true, spec, nil)], + [] + )) + self.runner = runner + + var bodyBuffer = ByteBuffer() + for response in responses { + if case .fetch(let fetchResponse) = response { + if case .streamingBytes(let bytes) = fetchResponse { + var mutableBytes = bytes + bodyBuffer.writeBuffer(&mutableBytes) + } + } + } + + // The section content may be base64 encoded — decode it + let raw = String(buffer: bodyBuffer) + let cleaned = raw.filter { !$0.isWhitespace } + if let decoded = Data(base64Encoded: cleaned) { + return decoded + } + return Data(bodyBuffer.readableBytesView) + } + public func fetchBody(uid: Int) async throws -> (text: String?, html: String?) { guard var runner else { throw IMAPError.notConnected } let uidValue = UID(rawValue: UInt32(uid)) @@ -456,6 +515,22 @@ public actor IMAPClient: IMAPClientProtocol { return results } + private func parseFullMessageResponse(_ responses: [Response]) -> 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 + } + } + } + return String(buffer: bodyBuffer) + } + private func parseBodyResponse(_ responses: [Response]) -> (text: String?, html: String?) { var bodyBuffer = ByteBuffer() diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift index 3151423..15a29f4 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift @@ -9,6 +9,10 @@ public protocol IMAPClientProtocol: Sendable { func fetchFlags(uids: ClosedRange) async throws -> [UIDFlagsPair] func fetchBody(uid: Int) async throws -> (text: String?, html: String?) + // v0.5 attachment/MIME operations + func fetchFullMessage(uid: Int) async throws -> String + func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data + // v0.3 write operations func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws func moveMessage(uid: Int, from: String, to: String) async throws diff --git a/Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift b/Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift index e251763..dab8b09 100644 --- a/Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift +++ b/Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift @@ -285,6 +285,8 @@ private final class FailingIMAPClient: IMAPClientProtocol, @unchecked Sendable { func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] { [] } func fetchFlags(uids: ClosedRange) async throws -> [UIDFlagsPair] { [] } func fetchBody(uid: Int) async throws -> (text: String?, html: String?) { (nil, nil) } + func fetchFullMessage(uid: Int) async throws -> String { "" } + func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data { Data() } func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws { throw FailingIMAPError.alwaysFails } diff --git a/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift b/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift index cade3dc..17b0920 100644 --- a/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift +++ b/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift @@ -22,6 +22,10 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable { var expungedMailboxes: [String] = [] var appendedMessages: [(mailbox: String, message: Data, flags: [String])] = [] var serverCapabilities: Set = ["IMAP4rev1", "MOVE"] + var fullMessages: [Int: String] = [:] + var sections: [String: Data] = [:] // key: "uid-section" + var fetchFullMessageCalls: [Int] = [] + var fetchSectionCalls: [(uid: Int, mailbox: String, section: String)] = [] func connect() async throws { connectCalled = true @@ -61,6 +65,18 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable { bodies[uid] ?? (nil, nil) } + // MARK: - v0.5 attachment/MIME operations + + func fetchFullMessage(uid: Int) async throws -> String { + fetchFullMessageCalls.append(uid) + return fullMessages[uid] ?? "" + } + + func fetchSection(uid: Int, mailbox: String, section: String) async throws -> Data { + fetchSectionCalls.append((uid: uid, mailbox: mailbox, section: section)) + return sections["\(uid)-\(section)"] ?? Data() + } + // MARK: - v0.3 write operations func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {