fix remaining code review issues, add HTML email rendering, charset-aware MIME decoding

- add charset-aware string decoding in MIMEParser (supports UTF-8, Latin-1, Windows-1252, etc.)
- fix prefetchBodies: remove broken ISO8601 date filter that prevented body fetching
- fix ensureBodyLoaded to use fetchFullMessage + MIMEParser instead of broken fetchBody
- add N+1 query fix: inboxMessagesExcludingDeferred uses SQL LEFT JOIN instead of per-message deferral check
- add inboxMessageCountExcludingDeferred for efficient perspective counts
- add unreadMessageCount, totalMessageCount queries to MailStore
- wire mailbox unread/total counts in loadMailboxes (were hardcoded to 0)
- add flag sync: reconcileFlags fetches flags for existing UIDs, updates local read/flagged state
- move account config from UserDefaults to Application Support file, auto-migrate existing config
- render HTML emails by default (toggle to plain text), render plain text as HTML for proper Unicode/emoji
- replace print() with os_log Logger in SyncCoordinator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 10:05:19 +01:00
parent 10b7cb2fd2
commit 31ab18cb2a
6 changed files with 188 additions and 53 deletions
@@ -1,10 +1,13 @@
import Foundation
import os
import Models
import IMAPClient
import MailStore
import MIMEParser
import TaskStore
private let logger = Logger(subsystem: "de.felixfoertsch.MagnumOpus", category: "SyncCoordinator")
@Observable
@MainActor
public final class SyncCoordinator {
@@ -84,7 +87,7 @@ public final class SyncCoordinator {
}
} catch {
// Resurfacing is non-fatal; log and continue
print("[SyncCoordinator] resurfaceDeferrals error: \(error)")
logger.warning("[SyncCoordinator] resurfaceDeferrals error: \(error)")
}
}
@@ -168,6 +171,11 @@ public final class SyncCoordinator {
emit(.newMessages(count: envelopes.count, mailbox: remoteMailbox.name))
}
// Reconcile flags for existing messages (read/unread state from other devices)
if lastUid > 0 {
await reconcileFlags(mailboxId: mailboxId, uidRange: 1...lastUid)
}
await prefetchBodies(mailboxId: mailboxId)
// Detect and store mailbox role from LIST attributes
@@ -183,14 +191,30 @@ public final class SyncCoordinator {
)
}
/// Reconcile read/flagged state for existing messages with the server.
private func reconcileFlags(mailboxId: String, uidRange: ClosedRange<Int>) async {
do {
let remoteFlagPairs = try await imapClient.fetchFlags(uids: uidRange)
let localMessages = try store.messages(mailboxId: mailboxId)
let localByUid = Dictionary(localMessages.map { ($0.uid, $0) }, uniquingKeysWith: { first, _ in first })
for pair in remoteFlagPairs {
guard let local = localByUid[pair.uid] else { continue }
if local.isRead != pair.isRead || local.isFlagged != pair.isFlagged {
try store.updateFlags(messageId: local.id, isRead: pair.isRead, isFlagged: pair.isFlagged)
}
}
} catch {
// Flag reconciliation is non-fatal
logger.warning("[SyncCoordinator] reconcileFlags error: \(error)")
}
}
/// Fetch full RFC822 messages and parse MIME for body + attachments
private func prefetchBodies(mailboxId: String) async {
let thirtyDaysAgo = ISO8601DateFormatter().string(
from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
)
do {
let messages = try store.messages(mailboxId: mailboxId)
let recent = messages.filter { $0.bodyText == nil && $0.bodyHtml == nil && $0.date >= thirtyDaysAgo }
let recent = messages.filter { $0.bodyText == nil && $0.bodyHtml == nil }
for message in recent.prefix(50) {
guard !Task.isCancelled else { break }
let rawMessage = try await imapClient.fetchFullMessage(uid: message.uid)
@@ -221,7 +245,7 @@ public final class SyncCoordinator {
}
} catch {
// Background prefetch failure is non-fatal
print("[SyncCoordinator] prefetchBodies error: \(error)")
logger.warning("[SyncCoordinator] prefetchBodies error: \(error)")
}
}
@@ -300,14 +324,14 @@ public final class SyncCoordinator {
/// Must be called after at least one successful sync (so capabilities are cached).
public func startIdleMonitoring() async {
guard let credentials else {
print("[SyncCoordinator] No credentials provided, cannot start IDLE")
logger.warning("[SyncCoordinator] No credentials provided, cannot start IDLE")
return
}
do {
let caps = try await imapClient.capabilities()
guard caps.contains("IDLE") else {
print("[SyncCoordinator] Server does not support IDLE, using periodic sync only")
logger.warning("[SyncCoordinator] Server does not support IDLE, using periodic sync only")
return
}
@@ -326,7 +350,7 @@ public final class SyncCoordinator {
}
}
} catch {
print("[SyncCoordinator] Failed to start IDLE monitoring: \(error)")
logger.warning("[SyncCoordinator] Failed to start IDLE monitoring: \(error)")
}
}