267 lines
16 KiB
Swift
267 lines
16 KiB
Swift
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 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"]) }
|
|
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] == [])
|
|
}
|
|
}
|