- threadSummariesFromDB accepts optional mailboxId filter, uses EXISTS subquery to show only threads containing messages in the selected mailbox - add selectMailbox() and selectPerspective() to MailViewModel for clean state transitions between folder view and GTD perspective view - sidebar highlights selected mailbox/perspective, clears state on switch - default to INBOX mailbox after first sync Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
5.6 KiB
Swift
148 lines
5.6 KiB
Swift
import Foundation
|
|
import GRDB
|
|
import Models
|
|
|
|
extension MailStore {
|
|
/// Full-text search across subject, sender, and body via FTS5
|
|
public func search(query: String) throws -> [MessageRecord] {
|
|
try dbWriter.read { db in
|
|
guard let pattern = FTS5Pattern(matchingAllPrefixesIn: query) else { return [] }
|
|
return try MessageRecord.fetchAll(db, sql: """
|
|
SELECT message.* FROM message
|
|
JOIN messageFts ON messageFts.rowid = message.rowid
|
|
WHERE messageFts MATCH ?
|
|
""", arguments: [pattern])
|
|
}
|
|
}
|
|
|
|
/// Thread summaries with unread count and latest sender info, ordered by lastDate DESC
|
|
public func threadSummaries(accountId: String, mailboxId: String? = nil) throws -> [ThreadSummary] {
|
|
try dbWriter.read { db in
|
|
try Self.threadSummariesFromDB(db, accountId: accountId, mailboxId: mailboxId)
|
|
}
|
|
}
|
|
|
|
/// Observe thread summaries reactively — UI updates automatically on DB change.
|
|
public func observeThreadSummaries(accountId: String, mailboxId: String? = nil) -> AsyncValueObservation<[ThreadSummary]> {
|
|
ValueObservation.tracking { db -> [ThreadSummary] in
|
|
try Self.threadSummariesFromDB(db, accountId: accountId, mailboxId: mailboxId)
|
|
}.values(in: dbWriter)
|
|
}
|
|
|
|
/// Observe messages in a thread reactively
|
|
public func observeMessages(threadId: String) -> AsyncValueObservation<[MessageSummary]> {
|
|
ValueObservation.tracking { db -> [MessageSummary] in
|
|
let records = 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])
|
|
return records.map(Self.toMessageSummary)
|
|
}.values(in: dbWriter)
|
|
}
|
|
|
|
// MARK: - Internal helpers
|
|
|
|
static func threadSummariesFromDB(_ db: Database, accountId: String, mailboxId: String? = nil) throws -> [ThreadSummary] {
|
|
let mailboxFilter: String
|
|
let arguments: StatementArguments
|
|
|
|
if let mailboxId {
|
|
// Filter threads to only those containing messages in the specified mailbox
|
|
mailboxFilter = "AND EXISTS (SELECT 1 FROM threadMessage tm2 JOIN message m2 ON m2.id = tm2.messageId WHERE tm2.threadId = t.id AND m2.mailboxId = ?)"
|
|
arguments = [accountId, mailboxId]
|
|
} else {
|
|
mailboxFilter = ""
|
|
arguments = [accountId]
|
|
}
|
|
|
|
let sql = """
|
|
SELECT
|
|
t.id, t.accountId, t.subject, t.lastDate, t.messageCount,
|
|
(SELECT COUNT(*) FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.isRead = 0) as unreadCount,
|
|
(SELECT GROUP_CONCAT(DISTINCT m.fromName) FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.fromName IS NOT NULL) as senders,
|
|
(SELECT m.snippet FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id ORDER BY m.date DESC LIMIT 1) as snippet,
|
|
(SELECT COUNT(*) FROM attachment a JOIN threadMessage tm ON tm.messageId = a.messageId WHERE tm.threadId = t.id) as attachmentCount
|
|
FROM thread t
|
|
WHERE t.accountId = ? \(mailboxFilter)
|
|
ORDER BY t.lastDate DESC
|
|
"""
|
|
let rows = try Row.fetchAll(db, sql: sql, arguments: arguments)
|
|
return rows.map { row in
|
|
ThreadSummary(
|
|
id: row["id"],
|
|
accountId: row["accountId"],
|
|
subject: row["subject"],
|
|
lastDate: parseDate(row["lastDate"] as String? ?? "") ?? Date.distantPast,
|
|
messageCount: row["messageCount"],
|
|
unreadCount: row["unreadCount"],
|
|
senders: row["senders"] ?? "",
|
|
snippet: row["snippet"],
|
|
attachmentCount: row["attachmentCount"]
|
|
)
|
|
}
|
|
}
|
|
|
|
nonisolated(unsafe) private static let isoFormatterWithFractional: ISO8601DateFormatter = {
|
|
let f = ISO8601DateFormatter()
|
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return f
|
|
}()
|
|
|
|
nonisolated(unsafe) private static let isoFormatter: ISO8601DateFormatter = {
|
|
ISO8601DateFormatter()
|
|
}()
|
|
|
|
nonisolated(unsafe) private static let rfc2822Formatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
|
return f
|
|
}()
|
|
|
|
nonisolated(unsafe) private static let rfc2822FormatterNoDow: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.dateFormat = "dd MMM yyyy HH:mm:ss Z"
|
|
return f
|
|
}()
|
|
|
|
static func parseDate(_ dateString: String) -> Date? {
|
|
let trimmed = dateString.trimmingCharacters(in: .whitespaces)
|
|
// Try ISO 8601 first
|
|
if let d = isoFormatterWithFractional.date(from: trimmed) { return d }
|
|
if let d = isoFormatter.date(from: trimmed) { return d }
|
|
// Try RFC 2822 (IMAP envelope date format)
|
|
if let d = rfc2822Formatter.date(from: trimmed) { return d }
|
|
if let d = rfc2822FormatterNoDow.date(from: trimmed) { return d }
|
|
return nil
|
|
}
|
|
|
|
public static func toMessageSummary(_ record: MessageRecord) -> MessageSummary {
|
|
MessageSummary(
|
|
id: record.id,
|
|
messageId: record.messageId,
|
|
threadId: nil,
|
|
from: record.fromAddress.map { EmailAddress.parse($0) },
|
|
to: parseAddressList(record.toAddresses),
|
|
cc: parseAddressList(record.ccAddresses),
|
|
subject: record.subject,
|
|
date: parseDate(record.date) ?? Date.distantPast,
|
|
snippet: record.snippet,
|
|
bodyText: record.bodyText,
|
|
bodyHtml: record.bodyHtml,
|
|
isRead: record.isRead,
|
|
isFlagged: record.isFlagged,
|
|
hasAttachments: record.hasAttachments
|
|
)
|
|
}
|
|
|
|
static func parseAddressList(_ json: String?) -> [EmailAddress] {
|
|
guard let json, let data = json.data(using: .utf8) else { return [] }
|
|
struct Addr: Codable { var name: String?; var address: String }
|
|
guard let addrs = try? JSONDecoder().decode([Addr].self, from: data) else { return [] }
|
|
return addrs.map { EmailAddress(name: $0.name, address: $0.address) }
|
|
}
|
|
}
|