From 427f197bb32dd427df9ec20a2497e07a715bc138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 05:12:58 +0100 Subject: [PATCH] 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) --- .../Sources/IMAPClient/IMAPClient.swift | 170 ++++++++++++++++++ .../IMAPClient/IMAPClientProtocol.swift | 10 ++ .../IMAPClient/IMAPCommandRunner.swift | 18 ++ .../Sources/IMAPClient/IMAPConnection.swift | 13 ++ .../Sources/SyncEngine/SyncCoordinator.swift | 39 ++++ .../SyncEngineTests/MockIMAPClient.swift | 35 ++++ 6 files changed, 285 insertions(+) diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift index 042f3c4..bb2ff31 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift @@ -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? 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(uidValue...uidValue) + let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) + let set = MessageIdentifierSetNonEmpty(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(uidValue...uidValue) + let set = MessageIdentifierSetNonEmpty(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 { + 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 { + var caps: Set = [] + 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 { diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift index 11eee5a..3151423 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift @@ -1,3 +1,5 @@ +import Foundation + public protocol IMAPClientProtocol: Sendable { func connect() async throws func disconnect() async throws @@ -6,4 +8,12 @@ public protocol IMAPClientProtocol: 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?) + + // 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 + func copyMessage(uid: Int, from: String, to: String) async throws + func expunge(mailbox: String) async throws + func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws + func capabilities() async throws -> Set } diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift index 38a6279..44bc3cb 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift @@ -18,4 +18,22 @@ struct IMAPCommandRunner { let tagged = TaggedCommand(tag: tag, command: command) return try await connection.sendCommand(tag, command: .tagged(tagged)) } + + mutating func runAppend(_ parts: [AppendCommand]) async throws -> [Response] { + let tag = nextTag() + var streamParts: [CommandStreamPart] = [] + for (index, part) in parts.enumerated() { + if index == 0 { + // Replace the start command's tag with our generated tag + if case .start(_, let mailbox) = part { + streamParts.append(.append(.start(tag: tag, appendingTo: mailbox))) + } else { + streamParts.append(.append(part)) + } + } else { + streamParts.append(.append(part)) + } + } + return try await connection.sendMultiPartCommand(tag, parts: streamParts) + } } diff --git a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift index 98a5547..0aea533 100644 --- a/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift +++ b/Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift @@ -46,6 +46,19 @@ actor IMAPConnection { } } + /// Send multiple command parts as a single multi-part command (e.g. APPEND). + /// The tag must match the tag embedded in the first part. + func sendMultiPartCommand(_ tag: String, parts: [CommandStreamPart]) async throws -> [Response] { + guard let channel else { throw IMAPError.notConnected } + let handler = responseHandler + return try await withCheckedThrowingContinuation { continuation in + handler.sendCommand(tag: tag, continuation: continuation) + for part in parts { + channel.writeAndFlush(IMAPClientHandler.Message.part(part), promise: nil) + } + } + } + func disconnect() async throws { try await channel?.close() channel = nil diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift index 4fe3743..1831fdd 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift @@ -123,6 +123,12 @@ public final class SyncCoordinator { await prefetchBodies(mailboxId: mailboxId) + // Detect and store mailbox role from LIST attributes + let role = Self.detectMailboxRole(attributes: remoteMailbox.attributes, name: remoteMailbox.name) + if role != nil { + try store.updateMailboxRole(id: mailboxId, role: role) + } + try store.updateMailboxSync( id: mailboxId, uidValidity: status.uidValidity, @@ -186,6 +192,39 @@ public final class SyncCoordinator { return String(data: data, encoding: .utf8) } + // MARK: - Mailbox role detection + + /// Maps LIST attributes to a role string. Falls back to name-based detection + /// when the server does not provide special-use attributes (RFC 6154). + static func detectMailboxRole(attributes: Set, name: String) -> String? { + // Attribute-based detection (case-insensitive comparison) + let lowered = Set(attributes.map { $0.lowercased() }) + if lowered.contains("\\trash") { return "trash" } + if lowered.contains("\\archive") || lowered.contains("\\all") { return "archive" } + if lowered.contains("\\sent") { return "sent" } + if lowered.contains("\\drafts") { return "drafts" } + if lowered.contains("\\junk") { return "junk" } + + // Name-based fallback for servers without special-use attributes + let nameLower = name.lowercased() + switch nameLower { + case "trash", "deleted messages", "deleted items": + return "trash" + case "archive", "all mail", "[gmail]/all mail": + return "archive" + case "sent", "sent messages", "sent items", "[gmail]/sent mail": + return "sent" + case "drafts", "draft", "[gmail]/drafts": + return "drafts" + case "junk", "spam", "bulk mail", "[gmail]/spam": + return "junk" + case "inbox": + return "inbox" + default: + return nil + } + } + // MARK: - Periodic Sync public func startPeriodicSync(interval: Duration = .seconds(300)) { diff --git a/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift b/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift index eaf640e..cade3dc 100644 --- a/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift +++ b/Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift @@ -1,3 +1,4 @@ +import Foundation import IMAPClient import Models @@ -14,6 +15,14 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable { var disconnectCalled = false var selectedMailbox: String? + // v0.3 write operation tracking + var storedFlags: [(uid: Int, mailbox: String, add: [String], remove: [String])] = [] + var movedMessages: [(uid: Int, from: String, to: String)] = [] + var copiedMessages: [(uid: Int, from: String, to: String)] = [] + var expungedMailboxes: [String] = [] + var appendedMessages: [(mailbox: String, message: Data, flags: [String])] = [] + var serverCapabilities: Set = ["IMAP4rev1", "MOVE"] + func connect() async throws { connectCalled = true } @@ -51,6 +60,32 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable { func fetchBody(uid: Int) async throws -> (text: String?, html: String?) { bodies[uid] ?? (nil, nil) } + + // MARK: - v0.3 write operations + + func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws { + storedFlags.append((uid: uid, mailbox: mailbox, add: add, remove: remove)) + } + + func moveMessage(uid: Int, from: String, to: String) async throws { + movedMessages.append((uid: uid, from: from, to: to)) + } + + func copyMessage(uid: Int, from: String, to: String) async throws { + copiedMessages.append((uid: uid, from: from, to: to)) + } + + func expunge(mailbox: String) async throws { + expungedMailboxes.append(mailbox) + } + + func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws { + appendedMessages.append((mailbox: mailbox, message: message, flags: flags)) + } + + func capabilities() async throws -> Set { + serverCapabilities + } } enum MockIMAPError: Error {