Misc. Fixes
This commit is contained in:
committed by
Dr. Michael Kübler
parent
49138503dd
commit
986ec72af0
@@ -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: [])
|
||||
|
||||
@@ -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"]) }
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user