diff --git a/MikeMail/IMAPClient.swift b/MikeMail/IMAPClient.swift index 58d1e50..58e77f6 100644 --- a/MikeMail/IMAPClient.swift +++ b/MikeMail/IMAPClient.swift @@ -178,24 +178,58 @@ final class IMAPClient { return result } - func inventory(folder: MailFolder, writable: Bool = false) async throws -> MailboxInventory { - let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))") + static func highestModSequence(from lines: [String]) -> UInt64? { + for line in lines { + if line.uppercased().contains("[NOMODSEQ]") { return nil } + } + for line in lines { + guard let start = line.range(of: "[HIGHESTMODSEQ ", options: .caseInsensitive), + let end = line[start.upperBound...].firstIndex(of: "]"), + let value = UInt64(line[start.upperBound.. 0 else { continue } + return value + } + return nil + } + + func inventory(folder: MailFolder, writable: Bool = false, + previous: MailboxInventory? = nil, supportsCONDSTORE: Bool = false) async throws -> MailboxInventory { + try await Self.fetchInventory(folder: folder, writable: writable, previous: previous, + supportsCONDSTORE: supportsCONDSTORE, command: command) + } + + static func fetchInventory(folder: MailFolder, writable: Bool = false, + previous: MailboxInventory? = nil, supportsCONDSTORE: Bool = false, + command: (String) async throws -> Response) async throws -> MailboxInventory { + let option = supportsCONDSTORE ? " (CONDSTORE)" : "" + let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))\(option)") let validity = try Self.uidValidity(from: examined.lines) + // Keep the watermark from BEFORE fetching changes; later changes must be replayed next time. + let modSequence = supportsCONDSTORE ? Self.highestModSequence(from: examined.lines) : nil let search = try await command("UID SEARCH ALL") guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else { throw MailError.message("Unvollständige IMAP-Bestandsliste.") } - let ids = line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) } - var flags: [UInt64: [String]] = [:] - for start in stride(from: 0, to: ids.count, by: 200) { - let batch = ids[start.. Data { @@ -275,17 +309,48 @@ final class IMAPClient { /// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail. func remove(uids: [UInt64]) async throws { + try await Self.remove(uids: uids, command: command, onRemoved: { _ in }) + } + + private static func remove(uids: [UInt64], command: (String) async throws -> Response, + onRemoved: ([UInt64]) throws -> Void) async throws { guard !uids.isEmpty else { return } - guard try await capabilities().contains("UIDPLUS") else { + let response = try await command("CAPABILITY") + let capabilities = Set(response.lines.filter { $0.uppercased().hasPrefix("* CAPABILITY ") } + .flatMap { $0.uppercased().split(separator: " ").dropFirst(2).map(String.init) }) + guard capabilities.contains("UIDPLUS") else { throw MailError.message("Der Server unterstützt kein gezieltes Löschen (UIDPLUS). Es wurden keine Mails gelöscht.") } for start in stride(from: 0, to: uids.count, by: 200) { let batch = uids[start.. Void) async throws -> String { + try await Self.emptyTrash(folder: folder, command: command, onRemoved: onRemoved) + } + + /// Only enumerate the selected trash folder; bodies and flags are unnecessary. + static func emptyTrash(folder: MailFolder, command: (String) async throws -> Response, + onRemoved: (String, [UInt64]) throws -> Void) async throws -> String { + guard folder.isTrash else { throw MailError.message("Bitte einen Papierkorb auswählen.") } + let selected = try await command("SELECT \(Self.quote(folder.path))") + let validity = try Self.uidValidity(from: selected.lines) + let search = try await command("UID SEARCH ALL") + guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else { + throw MailError.message("Unvollständige IMAP-Bestandsliste.") + } + let uids = line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) }.sorted() + try await remove(uids: uids, command: command) { removed in + try onRemoved(validity, removed) + } + return validity + } + @discardableResult func append(_ message: Data, path: String, flags: [String]) async throws -> AssignedUID? { let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"] @@ -333,11 +398,64 @@ final class IMAPClient { return ["EXISTS", "EXPUNGE", "FETCH"].contains(String(parts[2])) } + enum IdleChange: Equatable { + case refresh + case removed(validity: String, uid: UInt64) + case flags(validity: String, uid: UInt64, flags: [String]) + } + + /// EXPUNGE uses sequence numbers, which shift after every removal. + struct IdleMailbox { + let validity: String + var uids: [UInt64?] + + mutating func receive(_ line: String) -> IdleChange? { + let parts = line.uppercased().split(separator: " ") + guard parts.count >= 3, parts[0] == "*", let number = Int(parts[1]), number >= 0 else { return nil } + switch parts[2] { + case "EXISTS": + guard number != uids.count else { return nil } + if number > uids.count { + uids.append(contentsOf: repeatElement(nil, count: number - uids.count)) + } else { + uids = Array(repeating: nil, count: number) + } + return .refresh + case "EXPUNGE": + guard number > 0, number <= uids.count else { return .refresh } + guard let uid = uids.remove(at: number - 1) else { return .refresh } + return .removed(validity: validity, uid: uid) + case "FETCH": + guard number > 0, number <= uids.count else { return .refresh } + // Servers may omit UID in unsolicited FETCH responses. + let flags = try? IMAPClient.parseFlags([line + " UID " + String(uids[number - 1] ?? 0)]) + guard let (uid, values) = flags?.first, uid > 0 else { return .refresh } + uids[number - 1] = uid + return .flags(validity: validity, uid: uid, flags: values) + default: + return nil + } + } + } + /// This connection is reserved for IDLE; normal commands use separate sessions. /// Renew before the RFC 2177 inactivity limit, with a bounded DONE response wait. func idleInbox(renewAfterSeconds: Int = 25 * 60, - onReady: () -> Void, onChange: () -> Void) async throws { - _ = try await command("EXAMINE \"INBOX\"") + onReady: () -> Void, onChange: (IdleChange) -> Void) async throws { + let examined = try await command("EXAMINE \"INBOX\"") + let validity = try Self.uidValidity(from: examined.lines) + let search = try await command("UID SEARCH ALL") + guard let listing = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else { + throw MailError.message("Unvollständige IMAP-Bestandsliste.") + } + var mailbox = IdleMailbox(validity: validity, + uids: listing.split(separator: " ").dropFirst(2).compactMap { UInt64($0) }.sorted().map { Optional($0) }) + // SEARCH is the sequence baseline; subsequent unsolicited responses shift it. + if let index = search.lines.firstIndex(of: listing) { + for line in search.lines.dropFirst(index + 1) { + if let change = mailbox.receive(line) { onChange(change) } + } + } sequence += 1 let tag = "M\(sequence)" try await connection.line("\(tag) IDLE") @@ -347,6 +465,7 @@ final class IMAPClient { guard line.hasPrefix("*"), !line.uppercased().hasPrefix("* BYE") else { throw MailError.message("IMAP IDLE wurde nicht akzeptiert: \(line)") } + if let change = mailbox.receive(line) { onChange(change) } } onReady() let renewal = Task { @@ -373,7 +492,7 @@ final class IMAPClient { if line.uppercased().hasPrefix("* BYE") { throw MailError.message("IMAP IDLE: Verbindung beendet.") } - if Self.isMailboxChange(line) { onChange() } + if let change = mailbox.receive(line) { onChange(change) } } } } diff --git a/MikeMail/MailArchive.swift b/MikeMail/MailArchive.swift index fe2cb33..9800dfd 100644 --- a/MikeMail/MailArchive.swift +++ b/MikeMail/MailArchive.swift @@ -43,9 +43,16 @@ struct ArchiveConfiguration: Codable { var recipients: [String] } -struct MailboxInventory { +struct MailboxInventory: Codable, Equatable { let validity: String let flags: [UInt64: [String]] + var highestModSequence: UInt64? = nil + + func canReuseFlags(validity: String, highestModSequence: UInt64?) -> Bool { + guard self.validity == validity, let previous = self.highestModSequence, + let current = highestModSequence else { return false } + return previous > 0 && current >= previous + } } struct ArchiveMailRecord: Codable { @@ -160,15 +167,60 @@ final class MailArchive { return failures } + /// The server snapshot stays separate from local mutations and survives relaunches. + func syncInventory(folderID: String) throws -> MailboxInventory? { + let key = "inventory:" + folderID + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + guard let state = try context.fetch(descriptor).first else { return nil } + // A missing/obsolete cache is recoverable through a full metadata fetch. + return try? JSONDecoder().decode(MailboxInventory.self, from: state.payload) + } + + private func cacheInventory(folderID: String, inventory: MailboxInventory) throws { + let key = "inventory:" + folderID + let payload = try JSONEncoder().encode(inventory) + if let state = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.key == key })).first { + if state.payload != payload { state.payload = payload } + } else { + context.insert(MailArchiveSchema.ArchiveState(key: key, payload: payload)) + } + } + /// Reconcile only after a complete server inventory. Keep removed objects for future backups. - func reconcile(folderID: String, inventory: MailboxInventory) throws { + @discardableResult + func reconcile(folderID: String, inventory: MailboxInventory) throws -> Bool { do { + var changed = false for row in try rows(folderID: folderID) { let flags = UInt64(row.uid).flatMap { inventory.flags[$0] } - row.present = row.validity == inventory.validity && flags != nil - if let flags, row.present { row.flags = flags } + let present = row.validity == inventory.validity && flags != nil + if row.present != present { + row.present = present + changed = true + } + if let flags, present, Set(row.flags) != Set(flags) { + row.flags = flags + changed = true + } } - try context.save() + if try syncInventory(folderID: folderID) != inventory { + try cacheInventory(folderID: folderID, inventory: inventory) + } + if context.hasChanges { try context.save() } + return changed + } catch { context.rollback(); throw error } + } + + /// Commit each confirmed deletion batch, even if a later server request fails. + func applyRemovals(folderID: String, validity: String, uids: [UInt64]) throws { + let removed = Set(uids.map(String.init)) + do { + for row in try rows(folderID: folderID) + where row.validity == validity && removed.contains(row.uid) && row.present { + row.present = false + } + if context.hasChanges { try context.save() } } catch { context.rollback(); throw error } } diff --git a/MikeMail/MailIdleMonitor.swift b/MikeMail/MailIdleMonitor.swift index 01a3586..01d82b3 100644 --- a/MikeMail/MailIdleMonitor.swift +++ b/MikeMail/MailIdleMonitor.swift @@ -8,12 +8,12 @@ final class MailIdleMonitor { private var accounts: [UUID: MailAccount] = [:] private var pathMonitor: NWPathMonitor? private var wakeTask: Task? - private var changed: ((UUID) -> Void)? + private var changed: ((UUID, IMAPClient.IdleChange) -> Void)? private var authenticationFailed: ((UUID) -> Void)? private var report: ((UUID, String) -> Void)? func configure(accounts values: [MailAccount], enabled: Bool, - changed: @escaping (UUID) -> Void, report: @escaping (UUID, String) -> Void, + changed: @escaping (UUID, IMAPClient.IdleChange) -> Void, report: @escaping (UUID, String) -> Void, authenticationFailed: @escaping (UUID) -> Void) { self.authenticationFailed = authenticationFailed self.changed = changed @@ -76,9 +76,9 @@ final class MailIdleMonitor { retrySeconds = 2 report?(account.id, "Sofortempfang aktiv") // Reconcile the gap before the listener became ready, also after reconnects. - changed?(account.id) - } onChange: { - changed?(account.id) + changed?(account.id, .refresh) + } onChange: { change in + changed?(account.id, change) } } } onCancel: { diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift index 03c05b1..2cec2e3 100644 --- a/MikeMail/MailStore.swift +++ b/MikeMail/MailStore.swift @@ -24,6 +24,7 @@ final class MailStore { private var didStart = false private let idleMonitor = MailIdleMonitor() private var pendingIdleAccounts: Set = [] + private var pendingIdleChanges: [(UUID, IMAPClient.IdleChange)] = [] private var idleRefreshGeneration = UUID() private var idleRefreshTask: Task? var idleStatuses: [UUID: String] = [:] @@ -155,6 +156,7 @@ final class MailStore { defer { isBusy = false } var failures: [String] = [] var newInboxMessages: [NewMailNotification] = [] + var changedFolderIDs: Set = [] do { let archive = try storage() for account in accounts where accountID == nil || account.id == accountID { @@ -163,6 +165,7 @@ final class MailStore { defer { client.close() } status = "\(account.email): Verbindung wird aufgebaut …" try await client.login(account: account, password: PasswordStore.password(for: account.id)) + let supportsCONDSTORE = try await client.capabilities().contains("CONDSTORE") var discovered = inboxOnly ? (folders[account.id] ?? []) : try await client.folders(accountID: account.id) let pending = inboxOnly ? [] : try archive.pendingSent(accountID: account.id) if !pending.isEmpty { @@ -192,13 +195,17 @@ final class MailStore { try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory)) let discoveredIDs = Set(allFolders(discovered).map(\.id)) for removed in allFolders(folders[account.id] ?? []) where !discoveredIDs.contains(removed.id) { - try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:])) + if try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:])) { + changedFolderIDs.insert(removed.id) + } } folders = updated for folder in allFolders(discovered) where folder.selectable && (!inboxOnly || folder.isInbox) { do { status = "\(folder.name): Änderungen prüfen …" - let inventory = try await client.inventory(folder: folder) + let previous = try archive.syncInventory(folderID: folder.id) + let inventory = try await client.inventory(folder: folder, previous: previous, + supportsCONDSTORE: supportsCONDSTORE) let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity) let missing = Set(inventory.flags.keys).subtracting(known).sorted(by: >) for (index, uid) in missing.enumerated() { @@ -215,8 +222,8 @@ final class MailStore { try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity, uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false) } - try archive.reconcile(folderID: folder.id, inventory: inventory) - if selectedFolderID == folder.id { try display(folderID: folder.id) } + let changed = try archive.reconcile(folderID: folder.id, inventory: inventory) + if changed || !missing.isEmpty { changedFolderIDs.insert(folder.id) } } catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") } } } catch { @@ -225,9 +232,14 @@ final class MailStore { } } unreadCounts = try archive.unreadCounts() - archiveRevision += 1 + if !changedFolderIDs.isEmpty { archiveRevision += 1 } + let previousSelection = selectedFolderID if let resolvedFolderID = mailboxID(for: folderID) { selectedFolderID = resolvedFolderID } - if let selectedFolderID { try display(folderID: selectedFolderID) } + if let selectedFolderID, + previousSelection != selectedFolderID || changedFolderIDs.contains(selectedFolderID) + || (selectedFolderID.hasPrefix("unified:") && !changedFolderIDs.isEmpty) { + try display(folderID: selectedFolderID) + } await MailNotifications.postNewMail( newInboxMessages, badgeCount: totalUnreadCount @@ -612,12 +624,16 @@ final class MailStore { let targets = folderID == "unified:trash" ? accounts.flatMap { allFolders(folders[$0.id] ?? []) }.filter(\.isTrash) : [folder(id: folderID)].compactMap { $0 }.filter(\.isTrash) - await mutate { + await mutate(refreshAfter: false) { for folder in targets { let client = try await self.client(for: folder.accountID) defer { client.close() } - let inventory = try await client.inventory(folder: folder, writable: true) - try await client.remove(uids: Array(inventory.flags.keys)) + let validity = try await client.emptyTrash(folder: folder) { validity, removed in + try self.storage().applyRemovals(folderID: folder.id, validity: validity, uids: removed) + } + // Also retire stale local entries that were already absent on the server. + try self.storage().reconcile(folderID: folder.id, + inventory: MailboxInventory(validity: validity, flags: [:])) } } } @@ -711,8 +727,8 @@ final class MailStore { func configureIdle() { idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } } - idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id in - self?.scheduleIdleRefresh(accountID: id) + idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id, change in + self?.scheduleIdleRefresh(accountID: id, change: change) } report: { [weak self] id, status in self?.idleStatuses[id] = status } authenticationFailed: { [weak self] id in @@ -720,9 +736,10 @@ final class MailStore { } } - private func scheduleIdleRefresh(accountID: UUID) { + private func scheduleIdleRefresh(accountID: UUID, change: IMAPClient.IdleChange) { guard accounts.contains(where: { $0.id == accountID }) else { return } - pendingIdleAccounts.insert(accountID) + if change == .refresh { pendingIdleAccounts.insert(accountID) } + else { pendingIdleChanges.append((accountID, change)) } guard idleRefreshTask == nil else { return } let generation = UUID() idleRefreshGeneration = generation @@ -731,12 +748,18 @@ final class MailStore { defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } } // Coalesce bursts and retain notifications arriving during another synchronization. try? await Task.sleep(for: .milliseconds(400)) - while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty { + while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty || !self.pendingIdleChanges.isEmpty { if self.isBusy { do { try await Task.sleep(for: .milliseconds(200)) } catch { return } continue } - guard let id = self.pendingIdleAccounts.first else { return } + let changes = self.pendingIdleChanges + self.pendingIdleChanges.removeAll() + for (id, change) in changes { + do { try self.applyIdleChange(accountID: id, change: change) } + catch { self.pendingIdleAccounts.insert(id) } + } + guard let id = self.pendingIdleAccounts.first else { continue } self.pendingIdleAccounts.remove(id) guard self.accounts.contains(where: { $0.id == id }) else { continue } await self.refresh(folderID: self.selectedFolderID, accountID: id, inboxOnly: true) @@ -744,6 +767,28 @@ final class MailStore { } } + /// Apply IDLE echoes after in-flight mutations have committed their local changes. + func applyIdleChange(accountID: UUID, change: IMAPClient.IdleChange) throws { + guard let inbox = allFolders(folders[accountID] ?? []).first(where: \.isInbox) else { return } + let validity: String + let uid: UInt64 + let flags: [String]? + switch change { + case .refresh: + return + case .removed(let value, let identifier): + (validity, uid, flags) = (value, identifier, nil) + case .flags(let value, let identifier, let values): + (validity, uid, flags) = (value, identifier, values) + } + let id = inbox.id + ":" + validity + ":" + String(uid) + // Local moves/deletions already removed the source. Do not revive it. + guard let record = try? storage().record(id: id) else { return } + if let flags, Set(record.flags) == Set(flags) { return } + try storage().applyMessageChange(id: id, flags: flags, removed: flags == nil) + try updateLocalPresentation() + } + func requestPassword(accountID: UUID) { guard accounts.contains(where: { $0.id == accountID }) else { return } if authenticationAccountID == nil { authenticationAccountID = accountID } diff --git a/MikeMailTests/MailActionTests.swift b/MikeMailTests/MailActionTests.swift index c24050c..5a753a3 100644 --- a/MikeMailTests/MailActionTests.swift +++ b/MikeMailTests/MailActionTests.swift @@ -4,6 +4,78 @@ import Testing @MainActor struct MailActionTests { + @Test func emptyTrashOnlyListsUIDsAndDeletesConfirmedBatches() async throws { + let trash = MailFolder(accountID: UUID(), path: "Trash", name: "Trash") + var commands: [String] = [] + var removed: [UInt64] = [] + let validity = try await IMAPClient.emptyTrash(folder: trash) { command in + commands.append(command) + switch command { + case "SELECT \"Trash\"": return .init(lines: ["* OK [UIDVALIDITY 7]"]) + case "UID SEARCH ALL": return .init(lines: ["* SEARCH 10 20"]) + case "CAPABILITY": return .init(lines: ["* CAPABILITY IMAP4rev1 UIDPLUS"]) + case "UID STORE 10,20 +FLAGS.SILENT (\\Deleted)", "UID EXPUNGE 10,20": + return .init() + default: + Issue.record("Unexpected command: \(command)") + throw MailError.message("Unexpected command") + } + } onRemoved: { validity, uids in + #expect(validity == "7") + #expect(commands.last == "UID EXPUNGE 10,20") + removed += uids + } + #expect(validity == "7") + #expect(removed == [10, 20]) + #expect(commands.count == 5) + } + + @Test func partialTrashFailureKeepsConfirmedDeletionsAndOtherFolders() async throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let archive = try MailArchive(root: root) + let account = UUID() + let trash = MailFolder(accountID: account, path: "Trash", name: "Trash") + let inbox = MailFolder(accountID: account, path: "INBOX", name: "Inbox") + let raw = Data("Subject: Fixture\r\n\r\nBody".utf8) + // Only a subset is cached locally; absent local records must not stop server deletion. + for uid in ["1", "201"] { + try archive.store(raw: raw, folderID: trash.id, validity: "7", uid: uid, flags: []) + } + try archive.store(raw: raw, folderID: inbox.id, validity: "7", uid: "1", flags: []) + try archive.store(raw: raw, folderID: trash.id, validity: "8", uid: "1", flags: []) + await #expect(throws: (any Error).self) { + _ = try await IMAPClient.emptyTrash(folder: trash) { command in + if command.hasPrefix("SELECT") { return .init(lines: ["* OK [UIDVALIDITY 7]"]) } + if command == "UID SEARCH ALL" { + return .init(lines: ["* SEARCH " + (1...201).map(String.init).joined(separator: " ")]) + } + if command == "CAPABILITY" { return .init(lines: ["* CAPABILITY UIDPLUS"]) } + if command == "UID EXPUNGE 201" { throw MailError.message("Connection lost") } + return .init() + } onRemoved: { validity, uids in + try archive.applyRemovals(folderID: trash.id, validity: validity, uids: uids) + } + } + #expect(Set(try archive.messages(folderID: trash.id).map(\.id)) == + [trash.id + ":7:201", trash.id + ":8:1"]) + #expect(try archive.messages(folderID: inbox.id).count == 1) + } + + @Test func emptyTrashRequiresUIDPLUSBeforeDeleting() async throws { + let trash = MailFolder(accountID: UUID(), path: "Trash", name: "Trash") + var commands: [String] = [] + await #expect(throws: (any Error).self) { + _ = try await IMAPClient.emptyTrash(folder: trash) { command in + commands.append(command) + if command.hasPrefix("SELECT") { return .init(lines: ["* OK [UIDVALIDITY 7]"]) } + if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1"]) } + return .init(lines: ["* CAPABILITY IMAP4rev1"]) + } onRemoved: { _, _ in Issue.record("No deletion was confirmed") } + } + #expect(commands == ["SELECT \"Trash\"", "UID SEARCH ALL", "CAPABILITY"]) + } + @Test func ccAndBccStaySeparateFromEnvelope() throws { let raw = try MIME.compose(from: "sender@example.com", to: ["to@example.com"], cc: ["copy@example.com"], bcc: ["hidden@example.com"], subject: "CC BCC", body: "Hello", attachments: []) diff --git a/MikeMailTests/MailArchiveTests.swift b/MikeMailTests/MailArchiveTests.swift index 8540329..f4ca001 100644 --- a/MikeMailTests/MailArchiveTests.swift +++ b/MikeMailTests/MailArchiveTests.swift @@ -140,6 +140,122 @@ struct MailArchiveTests { #expect(try reopened.messages(folderID: inboxA.id).count == 1) } + @Test func unchangedLargeMailboxNeedsOnlyTwoCommands() async throws { + let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox") + let flags = Dictionary(uniqueKeysWithValues: (1...10_000).map { (UInt64($0), ["\\Seen"]) }) + let previous = MailboxInventory(validity: "7", flags: flags, highestModSequence: 100) + var commands: [String] = [] + let result = try await IMAPClient.fetchInventory(folder: folder, previous: previous, + supportsCONDSTORE: true) { command in + commands.append(command) + if command == "EXAMINE \"INBOX\" (CONDSTORE)" { + return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 100]"]) + } + #expect(command == "UID SEARCH ALL") + return .init(lines: ["* SEARCH " + (1...10_000).map(String.init).joined(separator: " ")]) + } + #expect(result == previous) + #expect(commands.count == 2) // Previously: EXAMINE + SEARCH + 50 flag batches. + } + + @Test func incrementalInventoryMergesFlagsArrivalsAndDeletions() async throws { + let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox") + let previous = MailboxInventory(validity: "7", flags: [1: [], 2: [], 3: ["\\Flagged"]], + highestModSequence: 100) + var commands: [String] = [] + let result = try await IMAPClient.fetchInventory(folder: folder, previous: previous, + supportsCONDSTORE: true) { command in + commands.append(command) + if command.hasPrefix("EXAMINE") { + return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 110]"]) + } + if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1 3 4"]) } + #expect(command == "UID FETCH 1:* (UID FLAGS) (CHANGEDSINCE 100)") + return .init(lines: ["* 1 FETCH (UID 1 FLAGS (\\Seen) MODSEQ (105))", + "* 3 FETCH (UID 4 FLAGS () MODSEQ (110))"]) + } + #expect(result.flags == [1: ["\\Seen"], 3: ["\\Flagged"], 4: []]) + #expect(result.highestModSequence == 110) + #expect(commands.count == 3) + } + + @Test(arguments: ["unsupported", "nomodseq", "validity", "rollback", "firstSync"]) + func inventoryFallsBackToFullFlags(reason: String) async throws { + let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox") + let previous = MailboxInventory(validity: "7", flags: [1: ["\\Seen"]], highestModSequence: 100) + let validity = reason == "validity" ? "8" : "7" + var commands: [String] = [] + let result = try await IMAPClient.fetchInventory(folder: folder, + previous: reason == "firstSync" ? nil : previous, supportsCONDSTORE: reason != "unsupported") { command in + commands.append(command) + if command.hasPrefix("EXAMINE") { + let sequence = reason == "nomodseq" ? "* OK [NOMODSEQ]" : + "* OK [HIGHESTMODSEQ \(reason == "rollback" ? 90 : 110)]" + return .init(lines: ["* OK [UIDVALIDITY \(validity)]", sequence]) + } + if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1"]) } + #expect(command == "UID FETCH 1:* (UID FLAGS)") + return .init(lines: ["* 1 FETCH (UID 1 FLAGS ())"]) + } + #expect(result.validity == validity) + #expect(result.flags == [1: []]) + #expect(commands.count == 3) + if reason == "unsupported" || reason == "nomodseq" { + #expect(result.highestModSequence == nil) + } + } + + @Test func unchangedModSequenceStillDetectsDeletions() async throws { + let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox") + let previous = MailboxInventory(validity: "7", flags: [1: [], 2: []], highestModSequence: 100) + let result = try await IMAPClient.fetchInventory(folder: folder, previous: previous, + supportsCONDSTORE: true) { command in + if command.hasPrefix("EXAMINE") { + return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 100]"]) + } + #expect(command == "UID SEARCH ALL") + return .init(lines: ["* SEARCH 2"]) + } + #expect(result.flags == [2: []]) + } + + @Test func incompleteDeltaIsRejected() async throws { + let folder = MailFolder(accountID: UUID(), path: "INBOX", name: "Inbox") + let previous = MailboxInventory(validity: "7", flags: [1: []], highestModSequence: 100) + await #expect(throws: (any Error).self) { + _ = try await IMAPClient.fetchInventory(folder: folder, previous: previous, + supportsCONDSTORE: true) { command in + if command.hasPrefix("EXAMINE") { + return .init(lines: ["* OK [UIDVALIDITY 7]", "* OK [HIGHESTMODSEQ 110]"]) + } + if command == "UID SEARCH ALL" { return .init(lines: ["* SEARCH 1 2"]) } + return .init(lines: []) + } + } + } + + @Test func syncSnapshotSurvivesRelaunchAndLocalResetStillDownloadsBodies() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let account = UUID() + let folder = MailFolder(accountID: account, path: "INBOX", name: "Inbox") + let inventory = MailboxInventory(validity: "7", flags: [1: []], highestModSequence: 100) + do { + let archive = try MailArchive(root: root) + try archive.store(raw: Data("Subject: Cached\r\n\r\nBody".utf8), + folderID: folder.id, validity: "7", uid: "1", flags: [], visible: false) + #expect(try archive.reconcile(folderID: folder.id, inventory: inventory)) + #expect(try !archive.reconcile(folderID: folder.id, inventory: inventory)) + } + let reopened = try MailArchive(root: root) + #expect(try reopened.syncInventory(folderID: folder.id) == inventory) + #expect(try reopened.knownUIDs(folderID: folder.id, validity: "7") == [1]) + _ = try reopened.clearLocalMessages(accountID: account) + let snapshot = try #require(try reopened.syncInventory(folderID: folder.id)) + let known = try reopened.knownUIDs(folderID: folder.id, validity: snapshot.validity) + #expect(Set(snapshot.flags.keys).subtracting(known) == [1]) + } + @Test func imapInventoryParsing() throws { #expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123") #expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) } diff --git a/MikeMailTests/MailIdleTests.swift b/MikeMailTests/MailIdleTests.swift index 07e543e..3a5cd13 100644 --- a/MikeMailTests/MailIdleTests.swift +++ b/MikeMailTests/MailIdleTests.swift @@ -16,6 +16,54 @@ struct MailIdleTests { #expect(!IMAPClient.isAuthenticationFailure("* BYE Connection closed")) } + @Test func idleChangesTrackSequenceNumbersWithoutRefreshing() { + var mailbox = IMAPClient.IdleMailbox(validity: "7", uids: [10, 20, 30]) + #expect(mailbox.receive("* 2 FETCH (FLAGS (\\Seen))") == + .flags(validity: "7", uid: 20, flags: ["\\Seen"])) + #expect(mailbox.receive("* 2 EXPUNGE") == .removed(validity: "7", uid: 20)) + #expect(mailbox.receive("* 2 EXPUNGE") == .removed(validity: "7", uid: 30)) + #expect(mailbox.receive("* 1 EXISTS") == nil) + #expect(mailbox.receive("* 2 EXISTS") == .refresh) + #expect(mailbox.receive("* 2 FETCH (UID 40 FLAGS ())") == + .flags(validity: "7", uid: 40, flags: [])) + #expect(mailbox.receive("* 2 EXPUNGE") == .removed(validity: "7", uid: 40)) + #expect(mailbox.receive("* 9 EXPUNGE") == .refresh) + #expect(mailbox.receive("* OK Still here") == nil) + } + + @Test func idleEchoAfterMoveOrDeletionDoesNotReloadOrReviveSource() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let archive = try MailArchive(root: root) + let account = MailAccount(name: "A") + let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Inbox") + let trash = MailFolder(accountID: account.id, path: "Trash", name: "Trash") + try archive.saveConfiguration(ArchiveConfiguration(accounts: [account], + folders: [account.id: [inbox, trash]], recipients: [])) + let raw = Data("Subject: Test\r\n\r\nBody".utf8) + try archive.store(raw: raw, folderID: inbox.id, validity: "7", uid: "10", flags: []) + let store = MailStore(archive: archive) + let id = inbox.id + ":7:10" + try archive.applyMessageChange(id: id, destination: trash.id, + assigned: .init(validity: "8", uid: "50")) + try store.applyIdleChange(accountID: account.id, + change: .flags(validity: "7", uid: 10, flags: ["\\Deleted"])) + try store.applyIdleChange(accountID: account.id, change: .removed(validity: "7", uid: 10)) + #expect(store.archiveRevision == 0) + #expect(try archive.messages(folderID: inbox.id).isEmpty) + #expect(try archive.messages(folderID: trash.id).count == 1) + #expect(!store.isBusy) + + // Changes from another client still update the archive without a network refresh. + try archive.store(raw: raw, folderID: inbox.id, validity: "7", uid: "20", flags: []) + try store.applyIdleChange(accountID: account.id, + change: .flags(validity: "7", uid: 20, flags: ["\\Seen"])) + #expect(try archive.messages(folderID: inbox.id).first?.isRead == true) + try store.applyIdleChange(accountID: account.id, change: .removed(validity: "7", uid: 20)) + #expect(try archive.messages(folderID: inbox.id).isEmpty) + #expect(store.archiveRevision == 2) + } + @Test func queuesPasswordPromptsPerAccount() throws { let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) }