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:
2026-03-14 05:01:17 +01:00
parent 8c3f51adb4
commit d659ed67de
10 changed files with 734 additions and 3 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -0,0 +1,4 @@
public enum SMTPSecurity: String, Sendable, Codable {
case ssl // implicit TLS, port 465
case starttls // upgrade after connect, port 587
}

View File

@@ -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")
}
}