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

View File

@@ -163,9 +163,7 @@ struct ContentView: View {
do {
try viewModel.setup(config: config, credentials: credentials)
try KeychainService.saveCredentials(credentials, for: config.id)
if let data = try? JSONEncoder().encode(config) {
UserDefaults.standard.set(data, forKey: "accountConfig")
}
Self.saveAccountConfig(config)
Task {
await viewModel.syncNow()
await viewModel.loadMailboxes(accountId: config.id)
@@ -178,8 +176,7 @@ struct ContentView: View {
}
private func loadExistingAccount() {
guard let data = UserDefaults.standard.data(forKey: "accountConfig"),
let config = try? JSONDecoder().decode(AccountConfig.self, from: data),
guard let config = Self.loadAccountConfig(),
let credentials = try? KeychainService.loadCredentials(for: config.id)
else { return }
do {
@@ -196,6 +193,40 @@ struct ContentView: View {
}
}
extension ContentView {
/// Store account config in Application Support (not UserDefaults) for reliability.
static func saveAccountConfig(_ config: AccountConfig) {
let url = accountConfigURL
let dir = url.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
if let data = try? JSONEncoder().encode(config) {
try? data.write(to: url, options: .atomic)
}
}
static func loadAccountConfig() -> AccountConfig? {
// Try Application Support first, then migrate from UserDefaults
if let data = try? Data(contentsOf: accountConfigURL),
let config = try? JSONDecoder().decode(AccountConfig.self, from: data) {
return config
}
// Migrate from UserDefaults if present
if let data = UserDefaults.standard.data(forKey: "accountConfig"),
let config = try? JSONDecoder().decode(AccountConfig.self, from: data) {
saveAccountConfig(config)
UserDefaults.standard.removeObject(forKey: "accountConfig")
return config
}
return nil
}
private static var accountConfigURL: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MagnumOpus", isDirectory: true)
.appendingPathComponent("accountConfig.json")
}
}
// Wrapper to make ComposeMode Identifiable for .sheet(item:)
struct ComposeModeWrapper: Identifiable {
let id = UUID()

View File

@@ -2,6 +2,7 @@ import SwiftUI
import GRDB
import Models
import MailStore
import MIMEParser
import SyncEngine
import IMAPClient
import SMTPClient
@@ -152,9 +153,11 @@ final class MailViewModel {
do {
let records = try store.mailboxes(accountId: accountId)
mailboxes = records.map { record in
MailboxInfo(
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: 0, totalCount: 0
name: record.name, unreadCount: unread, totalCount: total
)
}
if selectedMailbox == nil, let inbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
@@ -231,14 +234,10 @@ final class MailViewModel {
switch perspective {
case .inbox:
// Emails in INBOX with no deferral
// Emails in INBOX with no deferral (single SQL query)
if let inboxMailbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
let msgs = try store.messages(mailboxId: inboxMailbox.id)
let msgs = try store.inboxMessagesExcludingDeferred(mailboxId: inboxMailbox.id)
for msg in msgs {
// Skip deferred emails
if let _ = try? store.deferralForMessage(messageId: msg.id) {
continue
}
result.append(.email(MailStore.toMessageSummary(msg)))
}
}
@@ -312,15 +311,10 @@ final class MailViewModel {
var counts: [Perspective: Int] = [:]
do {
// Inbox count
// Inbox count (single SQL query instead of N+1)
var inboxCount = 0
if let inboxMailbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
let msgs = try store.messages(mailboxId: inboxMailbox.id)
for msg in msgs {
if (try? store.deferralForMessage(messageId: msg.id)) == nil {
inboxCount += 1
}
}
inboxCount = try store.inboxMessageCountExcludingDeferred(mailboxId: inboxMailbox.id)
}
inboxCount += try store.inboxTasks(accountId: accountId).count
counts[.inbox] = inboxCount
@@ -649,18 +643,20 @@ final class MailViewModel {
let client = provider()
do {
try await client.connect()
let (text, html) = try await client.fetchBody(uid: record.uid)
let rawMessage = try await client.fetchFullMessage(uid: record.uid)
try? await client.disconnect()
if text != nil || html != nil {
try store.storeBody(messageId: message.id, text: text, html: html)
var updated = message
updated.bodyText = text
updated.bodyHtml = html
return updated
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()
// Offline or fetch failed compose without quoted text
}
return message
}

View File

@@ -187,7 +187,7 @@ struct StatusBadge: View {
struct MessageView: View {
let message: MessageSummary
@State private var showHTML = false
@State private var showSource = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -195,9 +195,9 @@ struct MessageView: View {
Text(message.from?.displayName ?? "Unknown")
.fontWeight(.semibold)
Spacer()
if message.bodyHtml != nil {
Toggle(isOn: $showHTML) {
Text("HTML")
if message.bodyHtml != nil && message.bodyText != nil {
Toggle(isOn: $showSource) {
Text("Plain")
.font(.caption)
}
.toggleStyle(.button)
@@ -214,13 +214,17 @@ struct MessageView: View {
.foregroundStyle(.secondary)
}
if showHTML, let html = message.bodyHtml {
MessageWebView(html: html)
.frame(minHeight: 200)
} else if let bodyText = message.bodyText {
if showSource, let bodyText = message.bodyText {
Text(bodyText)
.font(.body)
.textSelection(.enabled)
} else if let html = message.bodyHtml {
MessageWebView(html: html)
.frame(minHeight: 200)
} else if let bodyText = message.bodyText {
// Render plain text as HTML for proper Unicode/emoji rendering
MessageWebView(html: plainTextToHTML(bodyText))
.frame(minHeight: 100)
} else if let snippet = message.snippet {
Text(snippet)
.font(.body)
@@ -234,4 +238,13 @@ struct MessageView: View {
}
.padding()
}
private func plainTextToHTML(_ text: String) -> String {
let escaped = text
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\n", with: "<br>")
return "<pre style=\"white-space: pre-wrap; word-wrap: break-word; font-family: -apple-system, system-ui; font-size: 14px;\">\(escaped)</pre>"
}
}