6bd1163c6c
Updates local flags and enqueues \\Seen flag action for IMAP sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
744 lines
23 KiB
Swift
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
|
|
}
|
|
}
|