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
@@ -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()
@@ -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