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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.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>"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user