import Foundation import Testing @testable import MikeMail @MainActor struct MailArchiveTests { @Test func confirmedMessageChangesPreserveOtherMailAndBackup() throws { let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let archive = try MailArchive(root: root.appending(path: "Archive")) let account = MailAccount(name: "Fixture", email: "fixture@example.com", username: "fixture", host: "example.invalid") let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang") let trash = MailFolder(accountID: account.id, path: "Trash", name: "Papierkorb") try archive.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [inbox, trash]], recipients: [])) let raw = Data("Subject: Move\r\n\r\nBody".utf8) for uid in ["1", "2"] { try archive.store(raw: raw, folderID: inbox.id, validity: "10", uid: uid, flags: []) } let id = inbox.id + ":10:1" try archive.applyMessageChange(id: id, flags: ["\\Seen"], removed: false) #expect(try archive.message(id: id).isRead) try archive.applyMessageChange(id: id, destination: trash.id, assigned: .init(validity: "20", uid: "9"), flags: ["\\Seen"]) #expect(try archive.messages(folderID: inbox.id).map(\.id) == [inbox.id + ":10:2"]) #expect(try archive.message(id: trash.id + ":20:9").body == "Body") #expect(try archive.unreadCounts()[inbox.id] == 1) try archive.applyMessageChange(id: trash.id + ":20:9") #expect(try archive.messages(folderID: trash.id).isEmpty) let backup = root.appending(path: "Backup") try archive.exportSnapshot(to: backup) let restored = try MailArchive.restoreSnapshot(from: backup, to: root.appending(path: "Restored")) #expect(try restored.messages(folderID: trash.id).isEmpty) #expect(try restored.messages(folderID: inbox.id).count == 1) } @Test func singleMessageServerUIDMapping() { #expect(IMAPClient.assignedUID(["M1 OK [COPYUID 123 5 17] Done"], sourceUID: 5) == .init(validity: "123", uid: "17")) #expect(IMAPClient.assignedUID(["* OK [COPYUID 123 5 17] Done"], sourceUID: 6) == nil) #expect(IMAPClient.assignedUID(["M2 OK [APPENDUID 123 18] Done"]) == .init(validity: "123", uid: "18")) #expect(IMAPClient.assignedUID(["M2 OK Done"]) == nil) #expect(IMAPClient.assignedUID(["M2 OK [APPENDUID 0 18] Done"]) == nil) #expect(IMAPClient.assignedUID(["M2 OK [COPYUID 123 5:6 17:18] Done"], sourceUID: 5) == nil) } @Test func automaticReadDelayIsCancellable() async throws { try await AutoReadRequest(ids: ["mail"], immediately: true, seconds: 3600).wait() let delayed = Task { try await AutoReadRequest(ids: ["mail"], immediately: false, seconds: 3600).wait() } delayed.cancel() do { try await delayed.value Issue.record("Cancelled selection must not be marked as read") } catch is CancellationError { } } @Test func diskPersistenceIncrementalReconciliationAndRestore() throws { let base = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: base) } let root = base.appending(path: "Archive") let account = MailAccount(name: "Fixture", email: "fixture@example.com", username: "fixture", host: "example.com") let folder = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang") let bytes = Data((0...255).map(UInt8.init)) let raw = try MIME.compose(from: account.email, to: ["recipient@example.com"], subject: "Offline Büro", body: "Dauerhaft verfügbar", attachments: [MailAttachment(name: "Prüfung.bin", mimeType: "application/octet-stream", data: bytes)]) var archive: MailArchive? = try MailArchive(root: root) try archive?.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [folder]], recipients: ["recipient@example.com"])) try archive?.store(raw: raw, folderID: folder.id, validity: "10", uid: "1", flags: []) try archive?.store(raw: raw, folderID: folder.id, validity: "10", uid: "1", flags: ["\\Seen"]) #expect(try archive?.messages(folderID: folder.id).count == 1) #expect(try archive?.knownUIDs(folderID: folder.id, validity: "10") == [1]) #expect(try archive?.knownUIDs(folderID: folder.id, validity: "11") == []) archive = nil let reopened = try MailArchive(root: root) #expect(try reopened.configuration()?.accounts == [account]) #expect(try reopened.messages(folderID: folder.id).first?.attachments.first?.data == bytes) #expect(try reopened.messages(folderID: folder.id).first?.isRead == true) try reopened.reconcile(folderID: folder.id, inventory: MailboxInventory(validity: "10", flags: [1: []])) #expect(try reopened.messages(folderID: folder.id).first?.isRead == false) // Partial download of a new UID generation is durable but not mixed into the visible old generation. try reopened.store(raw: raw, folderID: folder.id, validity: "11", uid: "2", flags: [], visible: false) #expect(try reopened.messages(folderID: folder.id).count == 1) #expect(try reopened.knownUIDs(folderID: folder.id, validity: "11") == [2]) let backup = base.appending(path: "Backup") try reopened.exportSnapshot(to: backup) let restored = try MailArchive.restoreSnapshot(from: backup, to: base.appending(path: "Restored")) #expect(try restored.messages(folderID: folder.id).first?.body == "Dauerhaft verfügbar") #expect(try restored.messages(folderID: folder.id).first?.attachments.first?.data == bytes) #expect(try restored.configuration()?.recipients == ["recipient@example.com"]) // A server UIDVALIDITY reset must not attach old data to the reused UID. try reopened.reconcile(folderID: folder.id, inventory: MailboxInventory(validity: "11", flags: [1: []])) #expect(try reopened.messages(folderID: folder.id).isEmpty) let retained = base.appending(path: "RetainedBackup") try reopened.exportSnapshot(to: retained) let manifest = try JSONDecoder().decode(MailArchive.Snapshot.self, from: Data(contentsOf: retained.appending(path: "manifest.json"))) #expect(manifest.messages.count == 2) #expect(manifest.messages.allSatisfy { !$0.present }) let object = retained.appending(path: "Objects/\(MailArchive.digest(raw)).eml") try Data("corrupt".utf8).write(to: object) #expect(throws: (any Error).self) { try MailArchive.restoreSnapshot(from: retained, to: base.appending(path: "Rejected")) } #expect(!FileManager.default.fileExists(atPath: base.appending(path: "Rejected").path)) } @Test func localResetIsAccountScopedAndForcesRedownload() 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 a = MailAccount(name: "A", email: "a@example.com", username: "a", host: "example.com") let b = MailAccount(name: "B", email: "b@example.com", username: "b", host: "example.com") let inboxA = MailFolder(accountID: a.id, path: "INBOX", name: "Posteingang") let inboxB = MailFolder(accountID: b.id, path: "INBOX", name: "Posteingang") let hiddenA = MailFolder(accountID: a.id, path: "OldFolder", name: "Alt") let outgoingA = MailFolder(accountID: a.id, path: "__local_outgoing__", name: "Versand") let shared = Data("Subject: Shared\r\n\r\nShared body".utf8) let unique = Data("Subject: Local\r\n\r\nOnly A".utf8) try archive.saveConfiguration(ArchiveConfiguration(accounts: [a, b], folders: [a.id: [inboxA], b.id: [inboxB]], recipients: ["x@example.com"])) try archive.store(raw: shared, folderID: inboxA.id, validity: "1", uid: "1", flags: []) try archive.store(raw: shared, folderID: inboxB.id, validity: "1", uid: "1", flags: []) try archive.store(raw: unique, folderID: hiddenA.id, validity: "old", uid: "2", flags: [], visible: false) try archive.store(raw: unique, folderID: outgoingA.id, validity: "local", uid: "pending", flags: ["Pending"]) let store = MailStore(archive: archive) await store.load(folderID: inboxA.id) #expect(store.messages.count == 1) store.isBusy = true #expect(throws: (any Error).self) { try store.clearLocalMessages(accountID: a.id) } #expect(try archive.knownUIDs(folderID: inboxA.id, validity: "1") == [1]) store.isBusy = false #expect(try store.clearLocalMessages(accountID: a.id) == 0) #expect(store.messages.isEmpty) #expect(store.accounts == [a, b]) #expect(try archive.configuration()?.folders[a.id] == [inboxA]) #expect(try archive.knownUIDs(folderID: inboxA.id, validity: "1").isEmpty) #expect(try archive.knownUIDs(folderID: hiddenA.id, validity: "old").isEmpty) #expect(try archive.messages(folderID: outgoingA.id).isEmpty) #expect(try archive.messages(folderID: inboxB.id).count == 1) #expect(FileManager.default.fileExists(atPath: root.appending(path: "Objects/\(MailArchive.digest(shared)).eml").path)) #expect(!FileManager.default.fileExists(atPath: root.appending(path: "Objects/\(MailArchive.digest(unique)).eml").path)) let reopened = try MailArchive(root: root) #expect(try reopened.knownUIDs(folderID: inboxA.id, validity: "1").isEmpty) try reopened.store(raw: shared, folderID: inboxA.id, validity: "1", uid: "1", flags: []) #expect(try reopened.messages(folderID: inboxA.id).count == 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"]) } let flags = try IMAPClient.parseFlags(["* 1 FETCH (UID 7 FLAGS (\\Seen \\Flagged))", "* 2 FETCH (FLAGS () UID 9)"]) #expect(flags[7] == ["\\Seen", "\\Flagged"]) #expect(flags[9] == []) } }