Files
MagnumOpus/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift
T
felixfoertsch 427f197bb3 add IMAP write operations, special folder role detection
Extend IMAPClientProtocol with storeFlags, moveMessage, copyMessage,
expunge, appendMessage, capabilities methods. Implement all six in
IMAPClient actor using NIOIMAPCore typed commands. Add multi-part
command support to IMAPConnection/IMAPCommandRunner for APPEND.
MockIMAPClient tracks all write calls for testing. SyncCoordinator
detects mailbox roles from LIST attributes with name-based fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 05:13:39 +01:00

560 lines
16 KiB
Swift

import Foundation
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?
private var cachedCapabilities: Set<String>?
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: - Write operations
public func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
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)
if !add.isEmpty {
let flags = add.map { Flag($0) }
let storeData = StoreData.flags(.add(silent: true, list: flags))
let responses = try await runner.run(.uidStore(.set(set), [], storeData))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID STORE +FLAGS failed")
}
}
if !remove.isEmpty {
let flags = remove.map { Flag($0) }
let storeData = StoreData.flags(.remove(silent: true, list: flags))
let responses = try await runner.run(.uidStore(.set(set), [], storeData))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID STORE -FLAGS failed")
}
}
self.runner = runner
}
public func moveMessage(uid: Int, from: String, to: String) async throws {
// Check capabilities first (updates self.runner internally)
let caps = try await capabilities()
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 toMailbox = MailboxName(ByteBuffer(string: to))
// Select the source mailbox
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: from))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(from) failed")
}
if caps.contains("MOVE") {
let responses = try await runner.run(.uidMove(.set(set), toMailbox))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID MOVE failed")
}
} else {
// Fallback: COPY + STORE \Deleted + EXPUNGE
let copyResponses = try await runner.run(.uidCopy(.set(set), toMailbox))
self.runner = runner
guard copyResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID COPY failed")
}
let storeData = StoreData.flags(.add(silent: true, list: [.deleted]))
let storeResponses = try await runner.run(.uidStore(.set(set), [], storeData))
self.runner = runner
guard storeResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID STORE +FLAGS \\Deleted failed")
}
let expungeResponses = try await runner.run(.expunge)
self.runner = runner
guard expungeResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("EXPUNGE failed")
}
}
self.runner = runner
}
public func copyMessage(uid: Int, from: String, to: String) async throws {
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 toMailbox = MailboxName(ByteBuffer(string: to))
// Select the source mailbox first
let selectResponses = try await runner.run(.select(MailboxName(ByteBuffer(string: from))))
self.runner = runner
guard selectResponses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("SELECT \(from) failed")
}
let responses = try await runner.run(.uidCopy(.set(set), toMailbox))
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("UID COPY failed")
}
}
public func expunge(mailbox: String) async throws {
guard var runner else { throw IMAPError.notConnected }
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 responses = try await runner.run(.expunge)
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("EXPUNGE failed")
}
}
public func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
guard var runner else { throw IMAPError.notConnected }
let mailboxName = MailboxName(ByteBuffer(string: mailbox))
let flagList = flags.map { Flag($0) }
let options = AppendOptions(flagList: flagList)
let data = AppendData(byteCount: message.count)
let appendMessage = AppendMessage(options: options, data: data)
let parts: [AppendCommand] = [
.start(tag: "", appendingTo: mailboxName),
.beginMessage(message: appendMessage),
.messageBytes(ByteBuffer(bytes: message)),
.endMessage,
.finish,
]
let responses = try await runner.runAppend(parts)
self.runner = runner
guard responses.contains(where: { isOKTagged($0) }) else {
throw IMAPError.unexpectedResponse("APPEND failed")
}
}
public func capabilities() async throws -> Set<String> {
if let cached = cachedCapabilities {
return cached
}
guard var runner else { throw IMAPError.notConnected }
let responses = try await runner.run(.capability)
self.runner = runner
let caps = parseCapabilityResponses(responses)
cachedCapabilities = caps
return caps
}
// 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 parseCapabilityResponses(_ responses: [Response]) -> Set<String> {
var caps: Set<String> = []
for response in responses {
guard case .untagged(let payload) = response,
case .capabilityData(let capabilities) = payload
else { continue }
for cap in capabilities {
if let value = cap.value {
caps.insert("\(cap.name)=\(value)")
} else {
caps.insert(cap.name)
}
}
}
return caps
}
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
}
}
}