import Foundation import Testing @testable import MikeMail @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: []) let headers = MIME.split(raw).0 #expect(headers["cc"] == "copy@example.com") #expect(headers["bcc"] == nil) #expect(!String(decoding: raw, as: UTF8.self).contains("hidden@example.com")) #expect(MIME.parse(raw, id: "1").cc == "copy@example.com") #expect(throws: (any Error).self) { try MIME.compose(from: "sender@example.com", to: [], bcc: ["bad\r\nBcc: evil@example.com"], subject: "No injection", body: "", attachments: []) } let blindOnly = try MIME.compose(from: "sender@example.com", to: [], bcc: ["hidden@example.com"], subject: "Blind only", body: "", attachments: []) #expect(MIME.split(blindOnly).0["bcc"] == nil) } @Test func replyUsesReplyToAndForwardIncludesAttachments() { let attachment = MailAttachment(name: "sample.txt", mimeType: "text/plain", data: Data("abc".utf8)) let mail = MailMessage(id: "id", sender: "Sender ", recipient: "to@example.com", subject: "Question", date: Date(), body: "Original", attachments: [attachment], replyTo: "Reply ") let account = UUID() let reply = ComposeDraft.from(mail, accountID: account, forwarding: false) #expect(reply.recipient == "reply@example.com") #expect(reply.accountID == account) #expect(reply.body.contains("> Original")) #expect(reply.attachments.isEmpty) let forward = ComposeDraft.from(mail, accountID: account, forwarding: true) #expect(forward.recipient.isEmpty) #expect(forward.attachments.first?.data == attachment.data) } @Test func newAccountRequiresValidationAndFlagsDriveCounts() throws { let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let archive = try MailArchive(root: root) try archive.saveConfiguration(ArchiveConfiguration(accounts: [], folders: [:], recipients: [])) let store = MailStore(archive: archive) let account = MailAccount(name: "New", email: "new@example.com", username: "new", host: "example.com") #expect(throws: (any Error).self) { try store.save(account: account, password: "unused") } #expect(store.accounts.isEmpty) let folder = MailFolder(accountID: account.id, path: "INBOX", name: "Inbox") let raw = Data("Subject: Flags\r\nMessage-ID: \r\n\r\nBody".utf8) try archive.store(raw: raw, folderID: folder.id, validity: "1", uid: "1", flags: []) #expect(try archive.unreadCounts()[folder.id] == 1) try archive.reconcile(folderID: folder.id, inventory: MailboxInventory(validity: "1", flags: [1: ["\\Seen"]])) #expect(try archive.unreadCounts()[folder.id] == nil) try archive.store(raw: raw, folderID: account.id.uuidString + ":__local_outgoing__", validity: "local", uid: "outgoing", flags: ["\\Seen", "SentCopyPending"]) #expect(try archive.pendingSent(accountID: account.id).count == 1) try archive.store(raw: raw, folderID: account.id.uuidString + ":__local_outgoing__", validity: "local", uid: "outgoing", flags: ["\\Seen", "SentCopied"], visible: false) #expect(try archive.pendingSent(accountID: account.id).isEmpty) } }