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 Models
|
||||||
import NIO
|
import NIO
|
||||||
import NIOIMAPCore
|
import NIOIMAPCore
|
||||||
@@ -8,6 +9,7 @@ public actor IMAPClient: IMAPClientProtocol {
|
|||||||
private let credentials: Credentials
|
private let credentials: Credentials
|
||||||
private var connection: IMAPConnection?
|
private var connection: IMAPConnection?
|
||||||
private var runner: IMAPCommandRunner?
|
private var runner: IMAPCommandRunner?
|
||||||
|
private var cachedCapabilities: Set<String>?
|
||||||
|
|
||||||
public init(host: String, port: Int, credentials: Credentials) {
|
public init(host: String, port: Int, credentials: Credentials) {
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -105,6 +107,157 @@ public actor IMAPClient: IMAPClientProtocol {
|
|||||||
return parseBodyResponse(responses)
|
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
|
// MARK: - Response parsing helpers
|
||||||
|
|
||||||
private func isOKTagged(_ response: Response) -> Bool {
|
private func isOKTagged(_ response: Response) -> Bool {
|
||||||
@@ -116,6 +269,23 @@ public actor IMAPClient: IMAPClientProtocol {
|
|||||||
return false
|
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] {
|
private func parseListResponses(_ responses: [Response]) -> [IMAPMailboxInfo] {
|
||||||
var results: [IMAPMailboxInfo] = []
|
var results: [IMAPMailboxInfo] = []
|
||||||
for response in responses {
|
for response in responses {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
public protocol IMAPClientProtocol: Sendable {
|
public protocol IMAPClientProtocol: Sendable {
|
||||||
func connect() async throws
|
func connect() async throws
|
||||||
func disconnect() async throws
|
func disconnect() async throws
|
||||||
@@ -6,4 +8,12 @@ public protocol IMAPClientProtocol: Sendable {
|
|||||||
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope]
|
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope]
|
||||||
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair]
|
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair]
|
||||||
func fetchBody(uid: Int) async throws -> (text: String?, html: String?)
|
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<String>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,22 @@ struct IMAPCommandRunner {
|
|||||||
let tagged = TaggedCommand(tag: tag, command: command)
|
let tagged = TaggedCommand(tag: tag, command: command)
|
||||||
return try await connection.sendCommand(tag, command: .tagged(tagged))
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func disconnect() async throws {
|
||||||
try await channel?.close()
|
try await channel?.close()
|
||||||
channel = nil
|
channel = nil
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ public final class SyncCoordinator {
|
|||||||
|
|
||||||
await prefetchBodies(mailboxId: mailboxId)
|
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(
|
try store.updateMailboxSync(
|
||||||
id: mailboxId,
|
id: mailboxId,
|
||||||
uidValidity: status.uidValidity,
|
uidValidity: status.uidValidity,
|
||||||
@@ -186,6 +192,39 @@ public final class SyncCoordinator {
|
|||||||
return String(data: data, encoding: .utf8)
|
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<String>, 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
|
// MARK: - Periodic Sync
|
||||||
|
|
||||||
public func startPeriodicSync(interval: Duration = .seconds(300)) {
|
public func startPeriodicSync(interval: Duration = .seconds(300)) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import IMAPClient
|
import IMAPClient
|
||||||
import Models
|
import Models
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
|
|||||||
var disconnectCalled = false
|
var disconnectCalled = false
|
||||||
var selectedMailbox: String?
|
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<String> = ["IMAP4rev1", "MOVE"]
|
||||||
|
|
||||||
func connect() async throws {
|
func connect() async throws {
|
||||||
connectCalled = true
|
connectCalled = true
|
||||||
}
|
}
|
||||||
@@ -51,6 +60,32 @@ final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
|
|||||||
func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
|
func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
|
||||||
bodies[uid] ?? (nil, nil)
|
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<String> {
|
||||||
|
serverCapabilities
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MockIMAPError: Error {
|
enum MockIMAPError: Error {
|
||||||
|
|||||||
Reference in New Issue
Block a user