diff --git a/Apps/MagnumOpus/ContentView.swift b/Apps/MagnumOpus/ContentView.swift index e554661..0490bbb 100644 --- a/Apps/MagnumOpus/ContentView.swift +++ b/Apps/MagnumOpus/ContentView.swift @@ -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() diff --git a/Apps/MagnumOpus/ViewModels/MailViewModel.swift b/Apps/MagnumOpus/ViewModels/MailViewModel.swift index 77778c3..155c608 100644 --- a/Apps/MagnumOpus/ViewModels/MailViewModel.swift +++ b/Apps/MagnumOpus/ViewModels/MailViewModel.swift @@ -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 } diff --git a/Apps/MagnumOpus/Views/ThreadDetailView.swift b/Apps/MagnumOpus/Views/ThreadDetailView.swift index b0cf9f5..1b8140a 100644 --- a/Apps/MagnumOpus/Views/ThreadDetailView.swift +++ b/Apps/MagnumOpus/Views/ThreadDetailView.swift @@ -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: "
") + return "
\(escaped)
" + } } diff --git a/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift b/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift index 26ae450..c8adf0c 100644 --- a/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift +++ b/Packages/MagnumOpusCore/Sources/MIMEParser/MIMEParser.swift @@ -29,13 +29,14 @@ public enum MIMEParser { // Single-part message let transferEncoding = parseTransferEncoding(headers["content-transfer-encoding"]) let decoded = decodeContent(body, encoding: transferEncoding) + let charset = extractParameter(contentType, name: "charset") if contentType.lowercased().contains("text/html") { - return MIMEMessage(headers: headers, htmlBody: String(data: decoded, encoding: .utf8)) + return MIMEMessage(headers: headers, htmlBody: decodeString(decoded, charset: charset)) } else { return MIMEMessage( headers: headers, - textBody: String(data: decoded, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + textBody: decodeString(decoded, charset: charset)?.trimmingCharacters(in: .whitespacesAndNewlines) ) } } @@ -238,9 +239,9 @@ public enum MIMEParser { if !part.subparts.isEmpty { extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "") } else if part.contentType == "text/plain" && message.textBody == nil { - message.textBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + message.textBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } else if part.contentType == "text/html" && message.htmlBody == nil { - message.htmlBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + message.htmlBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } } } else if lowerType.contains("multipart/related") { @@ -250,9 +251,9 @@ public enum MIMEParser { if !part.subparts.isEmpty { extractBodiesAndAttachments(from: part.subparts, contentType: part.contentType, into: &message, sectionPrefix: "") } else if part.contentType == "text/html" { - message.htmlBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + message.htmlBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } else if part.contentType == "text/plain" { - message.textBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + message.textBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } } else { let sectionIndex = sectionPrefix.isEmpty ? "\(index + 1)" : "\(sectionPrefix).\(index + 1)" @@ -279,9 +280,9 @@ public enum MIMEParser { bodyFound = true } else if !bodyFound && part.disposition != .attachment && part.contentType.hasPrefix("text/") { if part.contentType == "text/html" { - message.htmlBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + message.htmlBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } else { - message.textBody = String(data: part.body, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + message.textBody = decodeString(part.body, charset: part.charset)?.trimmingCharacters(in: .whitespacesAndNewlines) } bodyFound = true } else if part.disposition == .attachment || part.filename != nil || !part.contentType.hasPrefix("text/") { @@ -303,6 +304,35 @@ public enum MIMEParser { } } + // MARK: - String Decoding + + /// Decode Data to String using the specified charset, falling back to UTF-8. + public static func decodeString(_ data: Data, charset: String?) -> String? { + let encoding = charsetToEncoding(charset) + if let result = String(data: data, encoding: encoding) { + return result + } + // Fallback: try UTF-8, then Latin-1 (which never fails) + return String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) + } + + private static func charsetToEncoding(_ charset: String?) -> String.Encoding { + guard let charset = charset?.lowercased().trimmingCharacters(in: .whitespaces) else { return .utf8 } + switch charset { + case "utf-8", "utf8": return .utf8 + case "iso-8859-1", "latin1", "iso_8859-1": return .isoLatin1 + case "iso-8859-2", "latin2": return .isoLatin2 + case "iso-8859-15": return .isoLatin1 // close enough + case "windows-1252", "cp1252": return .windowsCP1252 + case "windows-1251", "cp1251": return .windowsCP1251 + case "us-ascii", "ascii": return .ascii + case "utf-16", "utf16": return .utf16 + case "utf-16be": return .utf16BigEndian + case "utf-16le": return .utf16LittleEndian + default: return .utf8 + } + } + // MARK: - Helper Functions private static func parseTransferEncoding(_ value: String?) -> TransferEncoding { diff --git a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift index 08e5cec..4444e36 100644 --- a/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift +++ b/Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift @@ -431,6 +431,47 @@ public final class MailStore: Sendable { } } + /// Fetch inbox messages excluding deferred ones in a single SQL query (avoids N+1). + public func inboxMessagesExcludingDeferred(mailboxId: String) throws -> [MessageRecord] { + try dbWriter.read { db in + try MessageRecord.fetchAll(db, sql: """ + SELECT m.* FROM message m + LEFT JOIN deferral d ON d.messageId = m.id + WHERE m.mailboxId = ? AND d.id IS NULL + ORDER BY m.date DESC + """, arguments: [mailboxId]) + } + } + + /// Count inbox messages excluding deferred ones. + public func inboxMessageCountExcludingDeferred(mailboxId: String) throws -> Int { + try dbWriter.read { db in + try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM message m + LEFT JOIN deferral d ON d.messageId = m.id + WHERE m.mailboxId = ? AND d.id IS NULL + """, arguments: [mailboxId]) ?? 0 + } + } + + /// Count unread messages in a mailbox. + public func unreadMessageCount(mailboxId: String) throws -> Int { + try dbWriter.read { db in + try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM message WHERE mailboxId = ? AND isRead = 0 + """, arguments: [mailboxId]) ?? 0 + } + } + + /// Count total messages in a mailbox. + public func totalMessageCount(mailboxId: String) throws -> Int { + try dbWriter.read { db in + try Int.fetchOne(db, sql: """ + SELECT COUNT(*) FROM message WHERE mailboxId = ? + """, arguments: [mailboxId]) ?? 0 + } + } + public func expiredDeferrals(beforeDate: String) throws -> [DeferralRecord] { try dbWriter.read { db in try DeferralRecord diff --git a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift index c9cb9e7..d9cd5ce 100644 --- a/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift +++ b/Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift @@ -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) 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)") } }