add mailstore CRUD: accounts, mailboxes, messages, threads, flags, body
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
191
Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift
Normal file
191
Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>")
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import Testing
|
||||
Reference in New Issue
Block a user