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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import Models
|
||||
import NIO
|
||||
import NIOIMAPCore
|
||||
@@ -8,6 +9,7 @@ public actor IMAPClient: IMAPClientProtocol {
|
||||
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
|
||||
@@ -105,6 +107,157 @@ public actor IMAPClient: IMAPClientProtocol {
|
||||
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 {
|
||||
@@ -116,6 +269,23 @@ public actor IMAPClient: IMAPClientProtocol {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user