add fetchFullMessage, fetchSection to IMAPClient for MIME attachment retrieval

This commit is contained in:
2026-03-14 13:31:38 +01:00
parent 0a564a05fd
commit 968dd91f80
4 changed files with 97 additions and 0 deletions

View File

@@ -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<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(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<UID>(uidValue...uidValue)
let set = MessageIdentifierSetNonEmpty<UID>(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()

View File

@@ -9,6 +9,10 @@ public protocol IMAPClientProtocol: Sendable {
func fetchFlags(uids: ClosedRange<Int>) 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

View File

@@ -285,6 +285,8 @@ private final class FailingIMAPClient: IMAPClientProtocol, @unchecked Sendable {
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] { [] }
func fetchFlags(uids: ClosedRange<Int>) 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
}

View File

@@ -22,6 +22,10 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
var expungedMailboxes: [String] = []
var appendedMessages: [(mailbox: String, message: Data, flags: [String])] = []
var serverCapabilities: Set<String> = ["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 {