add v0.3 schema: smtp fields, mailbox roles, drafts, pending actions, mailstore queries
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
33
Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift
Normal file
33
Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
public enum SMTPSecurity: String, Sendable, Codable {
|
||||
case ssl // implicit TLS, port 465
|
||||
case starttls // upgrade after connect, port 587
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user