Files
MagnumOpus/Apps/MagnumOpus/ViewModels/MailViewModel.swift
T
felixfoertsch 6bd1163c6c mark messages as read when selecting a thread
Updates local flags and enqueues \\Seen flag action for IMAP sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:36:57 +01:00

744 lines
23 KiB
Swift

import SwiftUI
import GRDB
import Models
import MailStore
import MIMEParser
import SyncEngine
import IMAPClient
import SMTPClient
import TaskStore
enum Perspective: String, CaseIterable, Identifiable {
case inbox
case today
case upcoming
case projects
case someday
case archive
var id: String { rawValue }
var label: String {
switch self {
case .inbox: "Inbox"
case .today: "Today"
case .upcoming: "Upcoming"
case .projects: "Projects"
case .someday: "Someday"
case .archive: "Archive"
}
}
var systemImage: String {
switch self {
case .inbox: "tray"
case .today: "sun.max"
case .upcoming: "calendar"
case .projects: "folder"
case .someday: "moon.zzz"
case .archive: "archivebox"
}
}
}
@Observable
@MainActor
final class MailViewModel {
private(set) var store: MailStore?
private var coordinator: SyncCoordinator?
var actionQueue: ActionQueue?
var accountConfig: AccountConfig?
var taskStore: TaskStore?
var threads: [ThreadSummary] = []
var selectedThread: ThreadSummary?
var messages: [MessageSummary] = []
var mailboxes: [MailboxInfo] = []
var selectedMailbox: MailboxInfo?
var syncState: SyncState = .idle
var errorMessage: String?
var selectedPerspective: Perspective = .inbox
var items: [ItemSummary] = []
var selectedItem: ItemSummary?
var perspectiveCounts: [Perspective: Int] = [:]
private var imapClientProvider: (() -> IMAPClient)?
private var threadObservation: Task<Void, Never>?
private var messageObservation: Task<Void, Never>?
var hasAccount: Bool {
store != nil && coordinator != nil
}
func setup(config: AccountConfig, credentials: Credentials) throws {
let dbPath = Self.databasePath(for: config.id)
let dbPool = try DatabaseSetup.openDatabase(atPath: dbPath)
let mailStore = MailStore(dbWriter: dbPool)
let imapClient = IMAPClient(
host: config.imapHost,
port: config.imapPort,
credentials: credentials
)
store = mailStore
accountConfig = config
let capturedImapHost = config.imapHost
let capturedImapPort = config.imapPort
let capturedCredentials = credentials
// Build smtpClientProvider when SMTP fields are present
var smtpProvider: (@Sendable () -> SMTPClient)?
if let smtpHost = config.smtpHost,
let smtpPort = config.smtpPort {
let security = config.smtpSecurity ?? .starttls
smtpProvider = {
SMTPClient(
host: smtpHost,
port: smtpPort,
security: security,
credentials: capturedCredentials
)
}
}
let queue = ActionQueue(
store: mailStore,
accountId: config.id,
imapClientProvider: {
IMAPClient(
host: capturedImapHost,
port: capturedImapPort,
credentials: capturedCredentials
)
},
smtpClientProvider: smtpProvider
)
actionQueue = queue
imapClientProvider = {
IMAPClient(
host: capturedImapHost,
port: capturedImapPort,
credentials: capturedCredentials
)
}
// Set up TaskStore sharing the same database
let taskDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MagnumOpus", isDirectory: true)
.appendingPathComponent(config.id, isDirectory: true)
.appendingPathComponent("tasks", isDirectory: true)
let ts = TaskStore(taskDirectory: taskDir, dbWriter: mailStore.databaseWriter)
taskStore = ts
coordinator = SyncCoordinator(
accountConfig: config,
imapClient: imapClient,
store: mailStore,
actionQueue: queue,
taskStore: ts,
credentials: credentials,
imapClientProvider: {
IMAPClient(
host: capturedImapHost,
port: capturedImapPort,
credentials: capturedCredentials
)
}
)
}
func loadMailboxes(accountId: String) async {
guard let store else { return }
do {
let records = try store.mailboxes(accountId: accountId)
mailboxes = records.map { record in
let unread = (try? store.unreadMessageCount(mailboxId: record.id)) ?? 0
let total = (try? store.totalMessageCount(mailboxId: record.id)) ?? 0
return MailboxInfo(
id: record.id, accountId: record.accountId,
name: record.name, unreadCount: unread, totalCount: total
)
}
if selectedMailbox == nil, let inbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
selectedMailbox = inbox
}
} catch {
errorMessage = error.localizedDescription
}
}
func startObservingThreads(accountId: String) {
guard let store else { return }
threadObservation?.cancel()
threadObservation = Task {
do {
for try await summaries in store.observeThreadSummaries(accountId: accountId) {
self.threads = summaries
}
} catch {
if !Task.isCancelled {
self.errorMessage = error.localizedDescription
}
}
}
}
func selectThread(_ thread: ThreadSummary) {
selectedThread = thread
messageObservation?.cancel()
guard let store, let accountConfig else { return }
messageObservation = Task {
do {
for try await msgs in store.observeMessages(threadId: thread.id) {
self.messages = msgs
}
} catch {
if !Task.isCancelled {
self.errorMessage = error.localizedDescription
}
}
}
// Mark all unread messages in the thread as read
if thread.unreadCount > 0 {
do {
let messages = try store.messagesForThread(threadId: thread.id)
for message in messages where !message.isRead {
try store.updateFlags(messageId: message.id, isRead: true, isFlagged: message.isFlagged)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .setFlags,
payload: .setFlags(uid: message.uid, mailbox: mailboxName(for: message.mailboxId) ?? "INBOX", add: ["\\Seen"], remove: [])
)
Task { try await actionQueue?.enqueue(action) }
}
} catch {
errorMessage = error.localizedDescription
}
}
}
private var isSyncing = false
func syncNow() async {
guard let coordinator, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
do {
try await coordinator.syncNow()
syncState = coordinator.syncState
// Reload mailboxes after sync (they may have been created on first sync)
if let accountConfig {
await loadMailboxes(accountId: accountConfig.id)
}
} catch {
let desc = "\(error)"
print("[MailViewModel] syncNow failed: \(desc)")
errorMessage = error.localizedDescription
syncState = .error(error.localizedDescription)
}
}
func startPeriodicSync() {
coordinator?.startPeriodicSync()
}
func stopSync() {
coordinator?.stopSync()
threadObservation?.cancel()
messageObservation?.cancel()
}
// MARK: - Perspective Loading
func loadPerspective(_ perspective: Perspective) {
selectedPerspective = perspective
guard let store, let accountConfig else { return }
let accountId = accountConfig.id
do {
var result: [ItemSummary] = []
let todayString = VTODOParser.formatDateOnly(Date())
switch perspective {
case .inbox:
// Emails in INBOX with no deferral (single SQL query)
if let inboxMailbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
let msgs = try store.inboxMessagesExcludingDeferred(mailboxId: inboxMailbox.id)
for msg in msgs {
result.append(.email(MailStore.toMessageSummary(msg)))
}
}
// Tasks: NEEDS-ACTION, no deferUntil, not someday
let tasks = try store.inboxTasks(accountId: accountId)
for task in tasks {
result.append(.task(taskRecordToSummary(task)))
}
case .today:
// Tasks due today or overdue
let tasks = try store.todayTasks(accountId: accountId, todayDate: todayString)
for task in tasks {
result.append(.task(taskRecordToSummary(task)))
}
case .upcoming:
// Tasks with due date after today
let tasks = try store.upcomingTasks(accountId: accountId, afterDate: todayString)
for task in tasks {
result.append(.task(taskRecordToSummary(task)))
}
case .projects:
// Items with project labels
let projectLabels = try store.projectLabels(accountId: accountId)
for label in projectLabels {
let itemLabels = try store.itemsWithLabel(labelId: label.id)
for il in itemLabels {
if il.itemType == "task" {
if let task = try store.task(id: il.itemId) {
result.append(.task(taskRecordToSummary(task)))
}
}
}
}
case .someday:
// Emails with deferral where deferUntil IS NULL (someday)
let somedayDeferrals = try store.somedayDeferrals()
for deferral in somedayDeferrals {
if let msg = try store.message(id: deferral.messageId) {
result.append(.email(MailStore.toMessageSummary(msg)))
}
}
// Tasks with isSomeday=true
let tasks = try store.somedayTasks(accountId: accountId)
for task in tasks {
result.append(.task(taskRecordToSummary(task)))
}
case .archive:
// Tasks with COMPLETED/CANCELLED status
let tasks = try store.archivedTasks(accountId: accountId)
for task in tasks {
result.append(.task(taskRecordToSummary(task)))
}
}
items = result.sorted { $0.date > $1.date }
} catch {
errorMessage = error.localizedDescription
}
}
func loadAllPerspectiveCounts() {
guard let store, let accountConfig else { return }
let accountId = accountConfig.id
let todayString = VTODOParser.formatDateOnly(Date())
var counts: [Perspective: Int] = [:]
do {
// Inbox count (single SQL query instead of N+1)
var inboxCount = 0
if let inboxMailbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
inboxCount = try store.inboxMessageCountExcludingDeferred(mailboxId: inboxMailbox.id)
}
inboxCount += try store.inboxTasks(accountId: accountId).count
counts[.inbox] = inboxCount
// Today count
counts[.today] = try store.todayTasks(accountId: accountId, todayDate: todayString).count
// Upcoming count
counts[.upcoming] = try store.upcomingTasks(accountId: accountId, afterDate: todayString).count
// Projects count
var projectCount = 0
let projectLabels = try store.projectLabels(accountId: accountId)
for label in projectLabels {
projectCount += try store.itemsWithLabel(labelId: label.id).count
}
counts[.projects] = projectCount
// Someday count
let somedayCount = try store.somedayDeferrals().count + store.somedayTasks(accountId: accountId).count
counts[.someday] = somedayCount
// Archive count
counts[.archive] = try store.archivedTasks(accountId: accountId).count
} catch {
// Counts are best-effort
}
perspectiveCounts = counts
}
// MARK: - Triage Actions
func archiveSelectedThread() {
guard let store, let actionQueue, let selectedThread, let selectedMailbox,
let accountConfig else { return }
do {
guard let archiveMailbox = try store.mailboxWithRole("archive", accountId: accountConfig.id) else {
errorMessage = "No archive mailbox found"
return
}
let messages = try store.messagesInThread(threadId: selectedThread.id, mailboxId: selectedMailbox.id)
for message in messages {
try store.updateMessageMailbox(messageId: message.id, newMailboxId: archiveMailbox.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: message.uid, from: selectedMailbox.name, to: archiveMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
}
autoAdvance()
} catch {
errorMessage = error.localizedDescription
}
}
func deleteSelectedThread() {
guard let store, let actionQueue, let selectedThread, let selectedMailbox,
let accountConfig else { return }
do {
guard let trashMailbox = try store.mailboxWithRole("trash", accountId: accountConfig.id) else {
errorMessage = "No trash mailbox found"
return
}
let messages = try store.messagesInThread(threadId: selectedThread.id, mailboxId: selectedMailbox.id)
let isAlreadyInTrash = selectedMailbox.id == trashMailbox.id
for message in messages {
if isAlreadyInTrash {
try store.deleteMessage(id: message.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .delete,
payload: .delete(uid: message.uid, mailbox: trashMailbox.name, trashMailbox: trashMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
} else {
try store.updateMessageMailbox(messageId: message.id, newMailboxId: trashMailbox.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: message.uid, from: selectedMailbox.name, to: trashMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
}
}
autoAdvance()
} catch {
errorMessage = error.localizedDescription
}
}
func toggleFlagSelectedThread() {
guard let store, let actionQueue, let selectedThread, let accountConfig else { return }
do {
let messages = try store.messagesForThread(threadId: selectedThread.id)
guard let first = messages.first else { return }
let newFlagged = !first.isFlagged
for message in messages {
try store.updateFlags(messageId: message.id, isRead: message.isRead, isFlagged: newFlagged)
let add = newFlagged ? ["\\Flagged"] : [String]()
let remove = newFlagged ? [String]() : ["\\Flagged"]
let action = PendingAction(
accountId: accountConfig.id,
actionType: .setFlags,
payload: .setFlags(uid: message.uid, mailbox: mailboxName(for: message.mailboxId) ?? "INBOX", add: add, remove: remove)
)
Task { try await actionQueue.enqueue(action) }
}
} catch {
errorMessage = error.localizedDescription
}
}
func toggleReadSelectedThread() {
guard let store, let actionQueue, let selectedThread, let accountConfig else { return }
do {
let messages = try store.messagesForThread(threadId: selectedThread.id)
guard let first = messages.first else { return }
let newRead = !first.isRead
for message in messages {
try store.updateFlags(messageId: message.id, isRead: newRead, isFlagged: message.isFlagged)
let add = newRead ? ["\\Seen"] : [String]()
let remove = newRead ? [String]() : ["\\Seen"]
let action = PendingAction(
accountId: accountConfig.id,
actionType: .setFlags,
payload: .setFlags(uid: message.uid, mailbox: mailboxName(for: message.mailboxId) ?? "INBOX", add: add, remove: remove)
)
Task { try await actionQueue.enqueue(action) }
}
} catch {
errorMessage = error.localizedDescription
}
}
func moveSelectedThread(to mailbox: MailboxInfo) {
guard let store, let actionQueue, let selectedThread, let selectedMailbox,
let accountConfig else { return }
do {
let messages = try store.messagesInThread(threadId: selectedThread.id, mailboxId: selectedMailbox.id)
for message in messages {
try store.updateMessageMailbox(messageId: message.id, newMailboxId: mailbox.id)
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: message.uid, from: selectedMailbox.name, to: mailbox.name)
)
Task { try await actionQueue.enqueue(action) }
}
autoAdvance()
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - GTD Triage Actions (unified items)
func deferItem(_ item: ItemSummary, until date: Date?) {
guard let store, let _ = accountConfig else { return }
do {
switch item {
case .email(let msg):
// Create a DeferralRecord
let deferUntilStr = date.map { VTODOParser.formatDateOnly($0) }
let deferral = DeferralRecord(
id: UUID().uuidString,
messageId: msg.id,
deferUntil: deferUntilStr,
originalMailbox: nil,
createdAt: ISO8601DateFormatter().string(from: Date())
)
try store.insertDeferral(deferral)
case .task(let task):
let isSomeday = date == nil
try taskStore?.updateDeferral(id: task.id, deferUntil: date, isSomeday: isSomeday)
}
loadPerspective(selectedPerspective)
loadAllPerspectiveCounts()
} catch {
errorMessage = error.localizedDescription
}
}
func fileItem(_ item: ItemSummary, label: LabelInfo) {
guard let store, let _ = accountConfig else { return }
do {
switch item {
case .email(let msg):
try store.attachLabel(labelId: label.id, itemType: "email", itemId: msg.id)
case .task(let task):
try store.attachLabel(labelId: label.id, itemType: "task", itemId: task.id)
// Also update VTODO CATEGORIES
var categories = task.categories
if !categories.contains(label.name) {
categories.append(label.name)
}
try taskStore?.writeTask(
id: task.id,
accountId: task.accountId,
summary: task.summary,
description: task.description,
status: task.status.rawValue,
priority: task.priority,
dueDate: task.dueDate,
deferUntil: task.deferUntil,
linkedMessageId: task.linkedMessageId,
categories: categories,
isSomeday: task.isSomeday
)
}
loadPerspective(selectedPerspective)
loadAllPerspectiveCounts()
} catch {
errorMessage = error.localizedDescription
}
}
func discardItem(_ item: ItemSummary) {
guard let store, let actionQueue, let accountConfig else { return }
do {
switch item {
case .email(let msg):
// Archive the email
guard let archiveMailbox = try store.mailboxWithRole("archive", accountId: accountConfig.id) else {
errorMessage = "No archive mailbox found"
return
}
guard let record = try store.message(id: msg.id) else { return }
try store.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id)
let mailboxName = self.mailboxName(for: record.mailboxId) ?? "INBOX"
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: record.uid, from: mailboxName, to: archiveMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
case .task(let task):
try taskStore?.updateStatus(id: task.id, status: TaskStatus.cancelled.rawValue)
}
loadPerspective(selectedPerspective)
loadAllPerspectiveCounts()
} catch {
errorMessage = error.localizedDescription
}
}
func completeItem(_ item: ItemSummary) {
guard let store, let actionQueue, let accountConfig else { return }
do {
switch item {
case .email(let msg):
// Archive the email
guard let archiveMailbox = try store.mailboxWithRole("archive", accountId: accountConfig.id) else {
errorMessage = "No archive mailbox found"
return
}
guard let record = try store.message(id: msg.id) else { return }
try store.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id)
let mailboxName = self.mailboxName(for: record.mailboxId) ?? "INBOX"
let action = PendingAction(
accountId: accountConfig.id,
actionType: .move,
payload: .move(uid: record.uid, from: mailboxName, to: archiveMailbox.name)
)
Task { try await actionQueue.enqueue(action) }
case .task(let task):
try taskStore?.updateStatus(id: task.id, status: TaskStatus.completed.rawValue)
}
loadPerspective(selectedPerspective)
loadAllPerspectiveCounts()
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Linked Tasks
func tasksLinkedToThread(threadId: String) -> [TaskSummary] {
guard let store else { return [] }
do {
let records = try store.tasksLinkedToThread(threadId: threadId)
return records.map { taskRecordToSummary($0) }
} catch {
return []
}
}
// MARK: - Auto Advance
private func autoAdvance() {
guard let current = selectedThread else { return }
guard let currentIndex = threads.firstIndex(where: { $0.id == current.id }) else {
selectedThread = nil
messages = []
return
}
let nextIndex = threads.index(after: currentIndex)
if nextIndex < threads.endIndex {
selectThread(threads[nextIndex])
} else if currentIndex > threads.startIndex {
let prevIndex = threads.index(before: currentIndex)
selectThread(threads[prevIndex])
} else {
selectedThread = nil
messages = []
messageObservation?.cancel()
}
}
// MARK: - Body Fetch for Reply/Forward
/// Ensures the message body is available for quoting in compose.
/// If bodyText is nil, attempts to fetch via IMAP. Returns the message
/// with body populated, or the original if fetch fails (offline).
func ensureBodyLoaded(for message: MessageSummary) async -> MessageSummary {
if message.bodyText != nil { return message }
guard let store, let provider = imapClientProvider else { return message }
// Look up the MessageRecord to get the IMAP uid
guard let record = try? store.message(id: message.id) else { return message }
let client = provider()
do {
try await client.connect()
let rawMessage = try await client.fetchFullMessage(uid: record.uid)
try? await client.disconnect()
if !rawMessage.isEmpty {
let parsed = MIMEParser.parse(rawMessage)
if parsed.textBody != nil || parsed.htmlBody != nil {
try store.storeBody(messageId: message.id, text: parsed.textBody, html: parsed.htmlBody)
var updated = message
updated.bodyText = parsed.textBody
updated.bodyHtml = parsed.htmlBody
return updated
}
}
} catch {
try? await client.disconnect()
}
return message
}
// MARK: - Attachments
func attachments(messageId: String) -> [AttachmentRecord] {
guard let store else { return [] }
return (try? store.attachments(messageId: messageId)) ?? []
}
func downloadAttachment(attachmentId: String, messageId: String) async throws -> URL {
guard let store, let coordinator else {
throw URLError(.badURL)
}
guard let record = try store.message(id: messageId) else {
throw URLError(.fileDoesNotExist)
}
let mailboxName = self.mailboxName(for: record.mailboxId) ?? "INBOX"
return try await coordinator.downloadAttachment(
attachmentId: attachmentId,
messageUid: record.uid,
mailboxName: mailboxName
)
}
// MARK: - Helpers
private func mailboxName(for mailboxId: String) -> String? {
mailboxes.first(where: { $0.id == mailboxId })?.name
}
private func taskRecordToSummary(_ record: TaskRecord) -> TaskSummary {
TaskSummary(
id: record.id,
accountId: record.accountId,
summary: record.summary,
description: record.description,
status: TaskStatus(rawValue: record.status) ?? .needsAction,
priority: record.priority,
dueDate: record.dueDate.flatMap { VTODOParser.parseDate($0) },
deferUntil: record.deferUntil.flatMap { VTODOParser.parseDate($0) },
createdAt: VTODOParser.parseDate(record.createdAt) ?? Date.distantPast,
linkedMessageId: record.linkedMessageId,
categories: (try? store?.labelsForItem(itemType: "task", itemId: record.id).map(\.name)) ?? [],
isSomeday: record.isSomeday
)
}
static func databasePath(for accountId: String) -> String {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MagnumOpus", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("\(accountId).sqlite").path
}
}