add mailstore CRUD: accounts, mailboxes, messages, threads, flags, body

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:18:59 +01:00
parent 0927d9827d
commit abee491cec
3 changed files with 323 additions and 1 deletions

View File

@@ -0,0 +1,191 @@
import GRDB
public final class MailStore: Sendable {
private let dbWriter: any DatabaseWriter
public init(dbWriter: any DatabaseWriter) {
self.dbWriter = dbWriter
}
// MARK: - Accounts
public func insertAccount(_ account: AccountRecord) throws {
try dbWriter.write { db in
try account.insert(db)
}
}
public func accounts() throws -> [AccountRecord] {
try dbWriter.read { db in
try AccountRecord.fetchAll(db)
}
}
// MARK: - Mailboxes
public func upsertMailbox(_ mailbox: MailboxRecord) throws {
try dbWriter.write { db in
try mailbox.save(db)
}
}
public func mailboxes(accountId: String) throws -> [MailboxRecord] {
try dbWriter.read { db in
try MailboxRecord
.filter(Column("accountId") == accountId)
.order(Column("name"))
.fetchAll(db)
}
}
public func mailbox(id: String) throws -> MailboxRecord? {
try dbWriter.read { db in
try MailboxRecord.fetchOne(db, key: id)
}
}
public func updateMailboxSync(id: String, uidValidity: Int, uidNext: Int) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE mailbox SET uidValidity = ?, uidNext = ? WHERE id = ?",
arguments: [uidValidity, uidNext, id]
)
}
}
// MARK: - Messages
public func insertMessages(_ messages: [MessageRecord]) throws {
try dbWriter.write { db in
for message in messages {
try message.save(db)
}
}
}
public func messages(mailboxId: String) throws -> [MessageRecord] {
try dbWriter.read { db in
try MessageRecord
.filter(Column("mailboxId") == mailboxId)
.order(Column("date").desc)
.fetchAll(db)
}
}
public func message(id: String) throws -> MessageRecord? {
try dbWriter.read { db in
try MessageRecord.fetchOne(db, key: id)
}
}
public func messagesForThread(threadId: 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 = ?
ORDER BY m.date ASC
""", arguments: [threadId])
}
}
public func updateFlags(messageId: String, isRead: Bool, isFlagged: Bool) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET isRead = ?, isFlagged = ? WHERE id = ?",
arguments: [isRead, isFlagged, messageId]
)
}
}
public func storeBody(messageId: String, text: String?, html: String?) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET bodyText = ?, bodyHtml = ? WHERE id = ?",
arguments: [text, html, messageId]
)
}
}
// MARK: - Threads
public func threads(accountId: String) throws -> [ThreadRecord] {
try dbWriter.read { db in
try ThreadRecord
.filter(Column("accountId") == accountId)
.order(Column("lastDate").desc)
.fetchAll(db)
}
}
public func insertThread(_ thread: ThreadRecord) throws {
try dbWriter.write { db in
try thread.save(db)
}
}
public func linkMessageToThread(threadId: String, messageId: String) throws {
try dbWriter.write { db in
try ThreadMessageRecord(threadId: threadId, messageId: messageId).save(db)
}
}
public func updateThread(id: String, lastDate: String, messageCount: Int, subject: String?) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE thread SET lastDate = ?, messageCount = ?, subject = COALESCE(?, subject) WHERE id = ?",
arguments: [lastDate, messageCount, subject, id]
)
}
}
/// Returns all message IDs linked to a thread
public func threadMessageIds(threadId: String) throws -> [String] {
try dbWriter.read { db in
try String.fetchAll(
db,
sql: "SELECT messageId FROM threadMessage WHERE threadId = ?",
arguments: [threadId]
)
}
}
/// Finds thread IDs that contain any of the given message IDs (by RFC 5322 Message-ID)
public func findThreadsByMessageIds(_ messageIds: Set<String>) throws -> [String] {
guard !messageIds.isEmpty else { return [] }
return try dbWriter.read { db in
let placeholders = databaseQuestionMarks(count: messageIds.count)
let sql = """
SELECT DISTINCT tm.threadId
FROM threadMessage tm
JOIN message m ON m.id = tm.messageId
WHERE m.messageId IN (\(placeholders))
"""
return try String.fetchAll(db, sql: sql, arguments: StatementArguments(Array(messageIds)))
}
}
/// Merges multiple threads into one, keeping the first thread ID
public func mergeThreads(_ threadIds: [String]) throws {
guard threadIds.count > 1 else { return }
let keepId = threadIds[0]
let mergeIds = Array(threadIds.dropFirst())
try dbWriter.write { db in
for mergeId in mergeIds {
try db.execute(
sql: "UPDATE threadMessage SET threadId = ? WHERE threadId = ?",
arguments: [keepId, mergeId]
)
try db.execute(
sql: "DELETE FROM thread WHERE id = ?",
arguments: [mergeId]
)
}
}
}
/// Access the underlying database writer (for ValueObservation)
public var databaseReader: any DatabaseReader {
dbWriter
}
}

View File

@@ -0,0 +1,132 @@
import Testing
import GRDB
@testable import MailStore
@Suite("MailStore CRUD")
struct MailStoreTests {
func makeStore() throws -> MailStore {
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
}
@Test("insert and retrieve account")
func accountCRUD() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
let accounts = try store.accounts()
#expect(accounts.count == 1)
#expect(accounts[0].name == "Personal")
}
@Test("insert and retrieve mailbox")
func mailboxCRUD() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
let mailboxes = try store.mailboxes(accountId: "acc1")
#expect(mailboxes.count == 1)
#expect(mailboxes[0].name == "INBOX")
}
@Test("insert and retrieve messages")
func messageCRUD() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
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: "msg001@example.com", inReplyTo: nil, refs: nil,
subject: "Hello", fromAddress: "alice@example.com", fromName: "Alice",
toAddresses: "[{\"address\":\"user@example.com\"}]", ccAddresses: nil,
date: "2024-03-08T10:15:32Z", snippet: "Hi there",
bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 1024
),
])
let messages = try store.messages(mailboxId: "mb1")
#expect(messages.count == 1)
#expect(messages[0].subject == "Hello")
}
@Test("update mailbox uidNext")
func updateMailboxUidNext() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
try store.updateMailboxSync(id: "mb1", uidValidity: 1, uidNext: 150)
let mailboxes = try store.mailboxes(accountId: "acc1")
#expect(mailboxes[0].uidNext == 150)
}
@Test("update message flags")
func updateFlags() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
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: "2024-03-08T10:15:32Z", snippet: nil,
bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 0
),
])
try store.updateFlags(messageId: "m1", isRead: true, isFlagged: true)
let messages = try store.messages(mailboxId: "mb1")
#expect(messages[0].isRead == true)
#expect(messages[0].isFlagged == true)
}
@Test("store body text and html")
func storeBody() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
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: "2024-03-08T10:15:32Z", snippet: nil,
bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 0
),
])
try store.storeBody(messageId: "m1", text: "Plain text body", html: "<p>HTML body</p>")
let msg = try store.message(id: "m1")
#expect(msg?.bodyText == "Plain text body")
#expect(msg?.bodyHtml == "<p>HTML body</p>")
}
}

View File

@@ -1 +0,0 @@
import Testing