diff --git a/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift b/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift index 5df4e3d..2cbd5d6 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift @@ -84,6 +84,49 @@ public enum DatabaseSetup { } } + migrator.registerMigration("v2_smtp") { db in + try db.alter(table: "account") { t in + t.add(column: "smtpHost", .text) + t.add(column: "smtpPort", .integer) + t.add(column: "smtpSecurity", .text) + } + } + + migrator.registerMigration("v2_mailboxRole") { db in + try db.alter(table: "mailbox") { t in + t.add(column: "role", .text) + } + } + + migrator.registerMigration("v2_draft") { db in + try db.create(table: "draft") { t in + t.primaryKey("id", .text) + t.belongsTo("account", onDelete: .cascade).notNull() + t.column("inReplyTo", .text) + t.column("forwardOf", .text) + t.column("toAddresses", .text) + t.column("ccAddresses", .text) + t.column("bccAddresses", .text) + t.column("subject", .text) + t.column("bodyText", .text) + t.column("createdAt", .text).notNull() + t.column("updatedAt", .text).notNull() + } + } + + migrator.registerMigration("v2_pendingAction") { db in + try db.create(table: "pendingAction") { t in + t.primaryKey("id", .text) + t.belongsTo("account", onDelete: .cascade).notNull() + t.column("actionType", .text).notNull() + t.column("payload", .text).notNull() + t.column("createdAt", .text).notNull() + t.column("retryCount", .integer).notNull().defaults(to: 0) + t.column("lastError", .text) + } + try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"]) + } + return migrator } diff --git a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift index 46464bb..3bc01a8 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift @@ -184,6 +184,134 @@ public final class MailStore: Sendable { } } + // MARK: - Drafts + + public func insertDraft(_ draft: DraftRecord) throws { + try dbWriter.write { db in + try draft.insert(db) + } + } + + public func updateDraft(_ draft: DraftRecord) throws { + try dbWriter.write { db in + try draft.update(db) + } + } + + public func deleteDraft(id: String) throws { + try dbWriter.write { db in + _ = try DraftRecord.deleteOne(db, key: id) + } + } + + public func draft(id: String) throws -> DraftRecord? { + try dbWriter.read { db in + try DraftRecord.fetchOne(db, key: id) + } + } + + public func drafts(accountId: String) throws -> [DraftRecord] { + try dbWriter.read { db in + try DraftRecord + .filter(Column("accountId") == accountId) + .order(Column("updatedAt").desc) + .fetchAll(db) + } + } + + // MARK: - Pending Actions + + public func insertPendingAction(_ action: PendingActionRecord) throws { + try dbWriter.write { db in + try action.insert(db) + } + } + + public func insertPendingActions(_ actions: [PendingActionRecord]) throws { + try dbWriter.write { db in + for action in actions { + try action.insert(db) + } + } + } + + public func pendingActions(accountId: String) throws -> [PendingActionRecord] { + try dbWriter.read { db in + try PendingActionRecord + .filter(Column("accountId") == accountId) + .order(Column("createdAt").asc) + .fetchAll(db) + } + } + + public func deletePendingAction(id: String) throws { + try dbWriter.write { db in + _ = try PendingActionRecord.deleteOne(db, key: id) + } + } + + public func updatePendingAction(_ action: PendingActionRecord) throws { + try dbWriter.write { db in + try action.update(db) + } + } + + public func pendingActionCount(accountId: String) throws -> Int { + try dbWriter.read { db in + try PendingActionRecord + .filter(Column("accountId") == accountId) + .fetchCount(db) + } + } + + // MARK: - Mailbox Roles + + public func mailboxWithRole(_ role: String, accountId: String) throws -> MailboxRecord? { + try dbWriter.read { db in + try MailboxRecord + .filter(Column("accountId") == accountId) + .filter(Column("role") == role) + .fetchOne(db) + } + } + + public func updateMailboxRole(id: String, role: String?) throws { + try dbWriter.write { db in + try db.execute( + sql: "UPDATE mailbox SET role = ? WHERE id = ?", + arguments: [role, id] + ) + } + } + + // MARK: - Message Mutations + + public func updateMessageMailbox(messageId: String, newMailboxId: String) throws { + try dbWriter.write { db in + try db.execute( + sql: "UPDATE message SET mailboxId = ? WHERE id = ?", + arguments: [newMailboxId, messageId] + ) + } + } + + public func deleteMessage(id: String) throws { + try dbWriter.write { db in + _ = try MessageRecord.deleteOne(db, key: id) + } + } + + public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] { + try dbWriter.read { db in + try MessageRecord.fetchAll(db, sql: """ + SELECT m.* FROM message m + JOIN threadMessage tm ON tm.messageId = m.id + WHERE tm.threadId = ? AND m.mailboxId = ? + ORDER BY m.date ASC + """, arguments: [threadId, mailboxId]) + } + } + /// Access the underlying database writer (for ValueObservation) public var databaseReader: any DatabaseReader { dbWriter diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift index e5b0b9e..9ae28ee 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift @@ -8,12 +8,27 @@ public struct AccountRecord: Codable, FetchableRecord, PersistableRecord, Sendab public var email: String public var imapHost: String public var imapPort: Int + public var smtpHost: String? + public var smtpPort: Int? + public var smtpSecurity: String? - public init(id: String, name: String, email: String, imapHost: String, imapPort: Int) { + public init( + id: String, + name: String, + email: String, + imapHost: String, + imapPort: Int, + smtpHost: String? = nil, + smtpPort: Int? = nil, + smtpSecurity: String? = nil + ) { self.id = id self.name = name self.email = email self.imapHost = imapHost self.imapPort = imapPort + self.smtpHost = smtpHost + self.smtpPort = smtpPort + self.smtpSecurity = smtpSecurity } } diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift new file mode 100644 index 0000000..3d20e13 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift @@ -0,0 +1,44 @@ +import Foundation +import GRDB + +public struct DraftRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "draft" + + public var id: String + public var accountId: String + public var inReplyTo: String? + public var forwardOf: String? + public var toAddresses: String? + public var ccAddresses: String? + public var bccAddresses: String? + public var subject: String? + public var bodyText: String? + public var createdAt: String + public var updatedAt: String + + public init( + id: String, + accountId: String, + inReplyTo: String? = nil, + forwardOf: String? = nil, + toAddresses: String? = nil, + ccAddresses: String? = nil, + bccAddresses: String? = nil, + subject: String? = nil, + bodyText: String? = nil, + createdAt: String, + updatedAt: String + ) { + self.id = id + self.accountId = accountId + self.inReplyTo = inReplyTo + self.forwardOf = forwardOf + self.toAddresses = toAddresses + self.ccAddresses = ccAddresses + self.bccAddresses = bccAddresses + self.subject = subject + self.bodyText = bodyText + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift index dc579dd..fdf7c57 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift @@ -8,12 +8,21 @@ public struct MailboxRecord: Codable, FetchableRecord, PersistableRecord, Sendab public var name: String public var uidValidity: Int public var uidNext: Int + public var role: String? - public init(id: String, accountId: String, name: String, uidValidity: Int, uidNext: Int) { + public init( + id: String, + accountId: String, + name: String, + uidValidity: Int, + uidNext: Int, + role: String? = nil + ) { self.id = id self.accountId = accountId self.name = name self.uidValidity = uidValidity self.uidNext = uidNext + self.role = role } } diff --git a/Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift b/Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift new file mode 100644 index 0000000..8316903 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift @@ -0,0 +1,32 @@ +import Foundation +import GRDB + +public struct PendingActionRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + public static let databaseTableName = "pendingAction" + + public var id: String + public var accountId: String + public var actionType: String + public var payload: String + public var createdAt: String + public var retryCount: Int + public var lastError: String? + + public init( + id: String, + accountId: String, + actionType: String, + payload: String, + createdAt: String, + retryCount: Int = 0, + lastError: String? = nil + ) { + self.id = id + self.accountId = accountId + self.actionType = actionType + self.payload = payload + self.createdAt = createdAt + self.retryCount = retryCount + self.lastError = lastError + } +} diff --git a/Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift b/Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift index 3d141d0..32ec86c 100644 --- a/Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift +++ b/Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift @@ -4,12 +4,27 @@ public struct AccountConfig: Sendable, Codable, Equatable { public var email: String public var imapHost: String public var imapPort: Int + public var smtpHost: String? + public var smtpPort: Int? + public var smtpSecurity: SMTPSecurity? - public init(id: String, name: String, email: String, imapHost: String, imapPort: Int) { + public init( + id: String, + name: String, + email: String, + imapHost: String, + imapPort: Int, + smtpHost: String? = nil, + smtpPort: Int? = nil, + smtpSecurity: SMTPSecurity? = nil + ) { self.id = id self.name = name self.email = email self.imapHost = imapHost self.imapPort = imapPort + self.smtpHost = smtpHost + self.smtpPort = smtpPort + self.smtpSecurity = smtpSecurity } } diff --git a/Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift b/Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift new file mode 100644 index 0000000..12d3386 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift @@ -0,0 +1,33 @@ +public struct OutgoingMessage: Sendable, Codable, Equatable { + public var from: EmailAddress + public var to: [EmailAddress] + public var cc: [EmailAddress] + public var bcc: [EmailAddress] + public var subject: String + public var bodyText: String + public var inReplyTo: String? + public var references: String? + public var messageId: String + + public init( + from: EmailAddress, + to: [EmailAddress], + cc: [EmailAddress] = [], + bcc: [EmailAddress] = [], + subject: String, + bodyText: String, + inReplyTo: String? = nil, + references: String? = nil, + messageId: String + ) { + self.from = from + self.to = to + self.cc = cc + self.bcc = bcc + self.subject = subject + self.bodyText = bodyText + self.inReplyTo = inReplyTo + self.references = references + self.messageId = messageId + } +} diff --git a/Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift b/Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift new file mode 100644 index 0000000..763a86b --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift @@ -0,0 +1,4 @@ +public enum SMTPSecurity: String, Sendable, Codable { + case ssl // implicit TLS, port 465 + case starttls // upgrade after connect, port 587 +} diff --git a/Packages/MagnumOpusCore/Tests/MailStoreTests/MigrationTests.swift b/Packages/MagnumOpusCore/Tests/MailStoreTests/MigrationTests.swift new file mode 100644 index 0000000..86b46a6 --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/MailStoreTests/MigrationTests.swift @@ -0,0 +1,408 @@ +import Testing +import GRDB +@testable import MailStore + +@Suite("V2 Migrations") +struct MigrationTests { + func makeStore() throws -> MailStore { + try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase()) + } + + func makeAccount(_ store: MailStore) throws { + try store.insertAccount(AccountRecord( + id: "acc1", name: "Test", email: "test@example.com", + imapHost: "imap.example.com", imapPort: 993 + )) + } + + // MARK: - Schema verification + + @Test("v2_smtp migration adds SMTP columns to account table") + func smtpColumnsExist() throws { + let store = try makeStore() + try store.insertAccount(AccountRecord( + id: "acc1", name: "Test", email: "test@example.com", + imapHost: "imap.example.com", imapPort: 993, + smtpHost: "smtp.example.com", smtpPort: 465, smtpSecurity: "ssl" + )) + let accounts = try store.accounts() + #expect(accounts[0].smtpHost == "smtp.example.com") + #expect(accounts[0].smtpPort == 465) + #expect(accounts[0].smtpSecurity == "ssl") + } + + @Test("v2_smtp columns default to nil for backwards compatibility") + func smtpColumnsDefaultNil() throws { + let store = try makeStore() + try store.insertAccount(AccountRecord( + id: "acc1", name: "Test", email: "test@example.com", + imapHost: "imap.example.com", imapPort: 993 + )) + let accounts = try store.accounts() + #expect(accounts[0].smtpHost == nil) + #expect(accounts[0].smtpPort == nil) + #expect(accounts[0].smtpSecurity == nil) + } + + @Test("v2_mailboxRole migration adds role column to mailbox table") + func mailboxRoleColumnExists() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "Sent", + uidValidity: 1, uidNext: 1, role: "sent" + )) + let mailboxes = try store.mailboxes(accountId: "acc1") + #expect(mailboxes[0].role == "sent") + } + + @Test("mailbox role defaults to nil") + func mailboxRoleDefaultsNil() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "INBOX", + uidValidity: 1, uidNext: 1 + )) + let mailboxes = try store.mailboxes(accountId: "acc1") + #expect(mailboxes[0].role == nil) + } + + // MARK: - DraftRecord round-trip + + @Test("DraftRecord insert and fetch round-trip") + func draftRoundTrip() throws { + let store = try makeStore() + try makeAccount(store) + + let draft = DraftRecord( + id: "d1", accountId: "acc1", + inReplyTo: "msg123@example.com", + toAddresses: "[{\"address\":\"bob@example.com\"}]", + subject: "Re: Hello", + bodyText: "Thanks for the email", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + ) + try store.insertDraft(draft) + + let fetched = try store.draft(id: "d1") + #expect(fetched != nil) + #expect(fetched?.accountId == "acc1") + #expect(fetched?.inReplyTo == "msg123@example.com") + #expect(fetched?.subject == "Re: Hello") + #expect(fetched?.bodyText == "Thanks for the email") + } + + @Test("DraftRecord update persists changes") + func draftUpdate() throws { + let store = try makeStore() + try makeAccount(store) + + var draft = DraftRecord( + id: "d1", accountId: "acc1", + subject: "Draft v1", + bodyText: "Initial", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + ) + try store.insertDraft(draft) + + draft.subject = "Draft v2" + draft.bodyText = "Updated body" + draft.updatedAt = "2026-03-14T10:05:00Z" + try store.updateDraft(draft) + + let fetched = try store.draft(id: "d1") + #expect(fetched?.subject == "Draft v2") + #expect(fetched?.bodyText == "Updated body") + } + + @Test("DraftRecord delete removes record") + func draftDelete() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertDraft(DraftRecord( + id: "d1", accountId: "acc1", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + try store.deleteDraft(id: "d1") + + let fetched = try store.draft(id: "d1") + #expect(fetched == nil) + } + + @Test("drafts(accountId:) returns drafts for account") + func draftsByAccount() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertDraft(DraftRecord( + id: "d1", accountId: "acc1", subject: "First", + createdAt: "2026-03-14T10:00:00Z", + updatedAt: "2026-03-14T10:00:00Z" + )) + try store.insertDraft(DraftRecord( + id: "d2", accountId: "acc1", subject: "Second", + createdAt: "2026-03-14T10:01:00Z", + updatedAt: "2026-03-14T10:05:00Z" + )) + + let drafts = try store.drafts(accountId: "acc1") + #expect(drafts.count == 2) + // ordered by updatedAt desc + #expect(drafts[0].id == "d2") + #expect(drafts[1].id == "d1") + } + + // MARK: - PendingActionRecord round-trip + + @Test("PendingActionRecord insert and fetch round-trip") + func pendingActionRoundTrip() throws { + let store = try makeStore() + try makeAccount(store) + + let action = PendingActionRecord( + id: "pa1", accountId: "acc1", + actionType: "move", + payload: "{\"messageId\":\"m1\",\"toMailbox\":\"trash\"}", + createdAt: "2026-03-14T10:00:00Z" + ) + try store.insertPendingAction(action) + + let actions = try store.pendingActions(accountId: "acc1") + #expect(actions.count == 1) + #expect(actions[0].actionType == "move") + #expect(actions[0].retryCount == 0) + #expect(actions[0].lastError == nil) + } + + @Test("PendingActionRecord update persists retryCount and lastError") + func pendingActionUpdate() throws { + let store = try makeStore() + try makeAccount(store) + + var action = PendingActionRecord( + id: "pa1", accountId: "acc1", + actionType: "send", + payload: "{}", + createdAt: "2026-03-14T10:00:00Z" + ) + try store.insertPendingAction(action) + + action.retryCount = 3 + action.lastError = "Connection timeout" + try store.updatePendingAction(action) + + let actions = try store.pendingActions(accountId: "acc1") + #expect(actions[0].retryCount == 3) + #expect(actions[0].lastError == "Connection timeout") + } + + @Test("PendingActionRecord delete removes record") + func pendingActionDelete() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertPendingAction(PendingActionRecord( + id: "pa1", accountId: "acc1", + actionType: "move", payload: "{}", + createdAt: "2026-03-14T10:00:00Z" + )) + try store.deletePendingAction(id: "pa1") + + let count = try store.pendingActionCount(accountId: "acc1") + #expect(count == 0) + } + + @Test("pendingActions ordered by createdAt ASC") + func pendingActionsOrdering() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertPendingActions([ + PendingActionRecord( + id: "pa2", accountId: "acc1", + actionType: "move", payload: "{}", + createdAt: "2026-03-14T10:05:00Z" + ), + PendingActionRecord( + id: "pa1", accountId: "acc1", + actionType: "send", payload: "{}", + createdAt: "2026-03-14T10:00:00Z" + ), + ]) + + let actions = try store.pendingActions(accountId: "acc1") + #expect(actions[0].id == "pa1") + #expect(actions[1].id == "pa2") + } + + @Test("pendingActionCount returns correct count") + func pendingActionCount() throws { + let store = try makeStore() + try makeAccount(store) + + try store.insertPendingActions([ + PendingActionRecord( + id: "pa1", accountId: "acc1", + actionType: "send", payload: "{}", + createdAt: "2026-03-14T10:00:00Z" + ), + PendingActionRecord( + id: "pa2", accountId: "acc1", + actionType: "move", payload: "{}", + createdAt: "2026-03-14T10:01:00Z" + ), + ]) + + let count = try store.pendingActionCount(accountId: "acc1") + #expect(count == 2) + } + + // MARK: - Mailbox role queries + + @Test("mailboxWithRole finds mailbox by role") + func mailboxWithRole() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "Trash", + uidValidity: 1, uidNext: 1, role: "trash" + )) + try store.upsertMailbox(MailboxRecord( + id: "mb2", accountId: "acc1", name: "INBOX", + uidValidity: 1, uidNext: 1 + )) + + let trash = try store.mailboxWithRole("trash", accountId: "acc1") + #expect(trash?.id == "mb1") + + let archive = try store.mailboxWithRole("archive", accountId: "acc1") + #expect(archive == nil) + } + + @Test("updateMailboxRole sets role on mailbox") + func updateMailboxRole() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "Archive", + uidValidity: 1, uidNext: 1 + )) + + try store.updateMailboxRole(id: "mb1", role: "archive") + let mailbox = try store.mailbox(id: "mb1") + #expect(mailbox?.role == "archive") + + try store.updateMailboxRole(id: "mb1", role: nil) + let cleared = try store.mailbox(id: "mb1") + #expect(cleared?.role == nil) + } + + // MARK: - Message mutations + + @Test("updateMessageMailbox moves message to new mailbox") + func updateMessageMailbox() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "INBOX", + uidValidity: 1, uidNext: 100 + )) + try store.upsertMailbox(MailboxRecord( + id: "mb2", accountId: "acc1", name: "Trash", + uidValidity: 1, uidNext: 1, role: "trash" + )) + try store.insertMessages([ + MessageRecord( + id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1, + messageId: nil, inReplyTo: nil, refs: nil, + subject: "Test", fromAddress: nil, fromName: nil, + toAddresses: nil, ccAddresses: nil, + date: "2026-03-14T10:00:00Z", snippet: nil, + bodyText: nil, bodyHtml: nil, + isRead: false, isFlagged: false, size: 0 + ), + ]) + + try store.updateMessageMailbox(messageId: "m1", newMailboxId: "mb2") + let msg = try store.message(id: "m1") + #expect(msg?.mailboxId == "mb2") + } + + @Test("deleteMessage removes message") + func deleteMessage() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "INBOX", + uidValidity: 1, uidNext: 100 + )) + try store.insertMessages([ + MessageRecord( + id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1, + messageId: nil, inReplyTo: nil, refs: nil, + subject: "Test", fromAddress: nil, fromName: nil, + toAddresses: nil, ccAddresses: nil, + date: "2026-03-14T10:00:00Z", snippet: nil, + bodyText: nil, bodyHtml: nil, + isRead: false, isFlagged: false, size: 0 + ), + ]) + + try store.deleteMessage(id: "m1") + let msg = try store.message(id: "m1") + #expect(msg == nil) + } + + @Test("messagesInThread filters by mailbox") + func messagesInThreadByMailbox() throws { + let store = try makeStore() + try makeAccount(store) + try store.upsertMailbox(MailboxRecord( + id: "mb1", accountId: "acc1", name: "INBOX", + uidValidity: 1, uidNext: 100 + )) + try store.upsertMailbox(MailboxRecord( + id: "mb2", accountId: "acc1", name: "Sent", + uidValidity: 1, uidNext: 1, role: "sent" + )) + try store.insertMessages([ + MessageRecord( + id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1, + messageId: "msg1@example.com", inReplyTo: nil, refs: nil, + subject: "Hello", fromAddress: "alice@example.com", fromName: "Alice", + toAddresses: nil, ccAddresses: nil, + date: "2026-03-14T10:00:00Z", snippet: nil, + bodyText: nil, bodyHtml: nil, + isRead: false, isFlagged: false, size: 0 + ), + MessageRecord( + id: "m2", accountId: "acc1", mailboxId: "mb2", uid: 1, + messageId: "msg2@example.com", inReplyTo: "msg1@example.com", refs: nil, + subject: "Re: Hello", fromAddress: "user@example.com", fromName: "User", + toAddresses: nil, ccAddresses: nil, + date: "2026-03-14T10:01:00Z", snippet: nil, + bodyText: nil, bodyHtml: nil, + isRead: true, isFlagged: false, size: 0 + ), + ]) + try store.insertThread(ThreadRecord( + id: "t1", accountId: "acc1", subject: "Hello", + lastDate: "2026-03-14T10:01:00Z", messageCount: 2 + )) + try store.linkMessageToThread(threadId: "t1", messageId: "m1") + try store.linkMessageToThread(threadId: "t1", messageId: "m2") + + let inboxMsgs = try store.messagesInThread(threadId: "t1", mailboxId: "mb1") + #expect(inboxMsgs.count == 1) + #expect(inboxMsgs[0].id == "m1") + + let sentMsgs = try store.messagesInThread(threadId: "t1", mailboxId: "mb2") + #expect(sentMsgs.count == 1) + #expect(sentMsgs[0].id == "m2") + } +}