diff --git a/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift b/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift new file mode 100644 index 0000000..58fb376 --- /dev/null +++ b/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift @@ -0,0 +1,330 @@ +import SwiftUI +import Models +import MailStore +import SyncEngine +import SMTPClient + +enum ComposeMode: Sendable { + case new + case reply(to: MessageSummary) + case replyAll(to: MessageSummary) + case forward(of: MessageSummary) + case draft(DraftRecord) +} + +@Observable +@MainActor +final class ComposeViewModel { + var to: String = "" + var cc: String = "" + var bcc: String = "" + var subject: String = "" + var bodyText: String = "" + + var isSending: Bool = false + var errorMessage: String? + + let mode: ComposeMode + + private let accountConfig: AccountConfig + private let store: MailStore + private let actionQueue: ActionQueue + + private var draftId: String? + private var savedTo: String = "" + private var savedCc: String = "" + private var savedBcc: String = "" + private var savedSubject: String = "" + private var savedBodyText: String = "" + private var autoSaveTask: Task? + + var isDirty: Bool { + to != savedTo || cc != savedCc || bcc != savedBcc + || subject != savedSubject || bodyText != savedBodyText + } + + init(mode: ComposeMode, accountConfig: AccountConfig, store: MailStore, actionQueue: ActionQueue) { + self.mode = mode + self.accountConfig = accountConfig + self.store = store + self.actionQueue = actionQueue + + prefill(mode: mode) + snapshotSavedState() + startAutoSave() + } + + deinit { + autoSaveTask?.cancel() + } + + // MARK: - Send + + func send() async throws { + isSending = true + errorMessage = nil + + do { + let toAddresses = parseAddressList(to) + guard !toAddresses.isEmpty else { + throw ComposeError.noRecipients + } + + let ccAddresses = parseAddressList(cc) + let bccAddresses = parseAddressList(bcc) + + let domain = MessageFormatter.domainFromEmail(accountConfig.email) + let messageId = MessageFormatter.generateMessageId(domain: domain) + let from = EmailAddress(name: accountConfig.name, address: accountConfig.email) + + var inReplyTo: String? + var references: String? + + switch mode { + case .reply(let original), .replyAll(let original): + inReplyTo = original.messageId + if let origId = original.messageId { + references = "<\(origId)>" + } + case .forward(let original): + if let origId = original.messageId { + references = "<\(origId)>" + } + case .new, .draft: + break + } + + let outgoing = OutgoingMessage( + from: from, + to: toAddresses, + cc: ccAddresses, + bcc: bccAddresses, + subject: subject, + bodyText: bodyText, + inReplyTo: inReplyTo, + references: references, + messageId: messageId + ) + + let formatted = MessageFormatter.format(outgoing) + + let sendAction = PendingAction( + accountId: accountConfig.id, + actionType: .send, + payload: .send(message: outgoing) + ) + let appendAction = PendingAction( + accountId: accountConfig.id, + actionType: .append, + payload: .append(mailbox: "Sent", messageData: formatted, flags: ["\\Seen"]) + ) + + try await actionQueue.enqueue(sendAction) + try await actionQueue.enqueue(appendAction) + + if let draftId { + try? store.deleteDraft(id: draftId) + self.draftId = nil + } + + snapshotSavedState() + isSending = false + } catch { + isSending = false + errorMessage = error.localizedDescription + throw error + } + } + + // MARK: - Draft Management + + func saveDraft() { + let now = ISO8601DateFormatter().string(from: Date()) + let id = draftId ?? UUID().uuidString + + var inReplyTo: String? + var forwardOf: String? + + switch mode { + case .reply(let msg), .replyAll(let msg): + inReplyTo = msg.messageId + case .forward(let msg): + forwardOf = msg.messageId + case .new, .draft: + break + } + + let draft = DraftRecord( + id: id, + accountId: accountConfig.id, + inReplyTo: inReplyTo, + forwardOf: forwardOf, + toAddresses: encodeAddresses(to), + ccAddresses: encodeAddresses(cc), + bccAddresses: encodeAddresses(bcc), + subject: subject, + bodyText: bodyText, + createdAt: draftId == nil ? now : (try? store.draft(id: id))?.createdAt ?? now, + updatedAt: now + ) + + do { + if draftId != nil { + try store.updateDraft(draft) + } else { + try store.insertDraft(draft) + draftId = id + } + snapshotSavedState() + } catch { + errorMessage = error.localizedDescription + } + } + + func deleteDraft() { + guard let draftId else { return } + try? store.deleteDraft(id: draftId) + self.draftId = nil + } + + // MARK: - Prefill + + private func prefill(mode: ComposeMode) { + switch mode { + case .new: + break + + case .reply(let original): + if let sender = original.from { + to = MessageFormatter.formatAddress(sender) + } + subject = prefixSubject("Re:", original.subject ?? "") + bodyText = quoteBody(original) + + case .replyAll(let original): + if let sender = original.from { + to = MessageFormatter.formatAddress(sender) + } + // CC = all other recipients minus self + let selfAddress = accountConfig.email.lowercased() + let others = (original.to + original.cc) + .filter { $0.address.lowercased() != selfAddress } + .filter { $0.address.lowercased() != original.from?.address.lowercased() } + cc = others.map(MessageFormatter.formatAddress).joined(separator: ", ") + subject = prefixSubject("Re:", original.subject ?? "") + bodyText = quoteBody(original) + + case .forward(let original): + subject = prefixSubject("Fwd:", original.subject ?? "") + bodyText = forwardBody(original) + + case .draft(let draft): + to = draft.toAddresses ?? "" + cc = draft.ccAddresses ?? "" + bcc = draft.bccAddresses ?? "" + subject = draft.subject ?? "" + bodyText = draft.bodyText ?? "" + draftId = draft.id + } + } + + // MARK: - Auto-Save + + private func startAutoSave() { + autoSaveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + guard !Task.isCancelled else { break } + guard let self else { break } + if self.isDirty && !self.isSending { + self.saveDraft() + } + } + } + } + + private func snapshotSavedState() { + savedTo = to + savedCc = cc + savedBcc = bcc + savedSubject = subject + savedBodyText = bodyText + } + + // MARK: - Helpers + + func prefixSubject(_ prefix: String, _ original: String) -> String { + // Strip existing Re:/Fwd: prefixes (case-insensitive) + var stripped = original + while let range = stripped.range( + of: #"^(Re|Fwd|Fw)\s*:\s*"#, + options: [.regularExpression, .caseInsensitive] + ) { + stripped = String(stripped[range.upperBound...]) + } + return "\(prefix) \(stripped)" + } + + func quoteBody(_ msg: MessageSummary) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + let dateString = dateFormatter.string(from: msg.date) + let sender = msg.from?.displayName ?? "Unknown" + let originalBody = msg.bodyText ?? "" + let quoted = originalBody + .components(separatedBy: .newlines) + .map { "> \($0)" } + .joined(separator: "\n") + return "\n\nOn \(dateString), \(sender) wrote:\n\(quoted)" + } + + func forwardBody(_ msg: MessageSummary) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + let dateString = dateFormatter.string(from: msg.date) + let from = msg.from.map(MessageFormatter.formatAddress) ?? "Unknown" + let toList = msg.to.map(MessageFormatter.formatAddress).joined(separator: ", ") + let subjectLine = msg.subject ?? "(No Subject)" + let body = msg.bodyText ?? "" + + return """ + + + ---------- Forwarded message ---------- + From: \(from) + Date: \(dateString) + Subject: \(subjectLine) + To: \(toList) + + \(body) + """ + } + + func parseAddressList(_ field: String) -> [EmailAddress] { + let trimmed = field.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + return trimmed + .components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .map(EmailAddress.parse) + } + + private func encodeAddresses(_ field: String) -> String? { + let trimmed = field.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +enum ComposeError: LocalizedError { + case noRecipients + + var errorDescription: String? { + switch self { + case .noRecipients: + return "At least one recipient is required." + } + } +} diff --git a/Apps/MagnumOpus/Views/ComposeView.swift b/Apps/MagnumOpus/Views/ComposeView.swift new file mode 100644 index 0000000..6ce1a18 --- /dev/null +++ b/Apps/MagnumOpus/Views/ComposeView.swift @@ -0,0 +1,138 @@ +import SwiftUI +import Models + +struct ComposeView: View { + @Bindable var viewModel: ComposeViewModel + @Environment(\.dismiss) private var dismiss + + @State private var showBcc = false + @State private var showDiscardConfirmation = false + + var body: some View { + #if os(macOS) + content + .frame(minWidth: 500, minHeight: 400) + #else + NavigationStack { + content + } + #endif + } + + private var content: some View { + Form { + Section { + TextField("To", text: $viewModel.to) + #if os(iOS) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + #endif + + TextField("CC", text: $viewModel.cc) + #if os(iOS) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + #endif + + if showBcc { + TextField("BCC", text: $viewModel.bcc) + #if os(iOS) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .autocapitalization(.none) + #endif + } + + TextField("Subject", text: $viewModel.subject) + } + + Section { + TextEditor(text: $viewModel.bodyText) + .frame(minHeight: 200) + } + + if let error = viewModel.errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + .navigationTitle(navigationTitle) + #if os(macOS) + .navigationSubtitle(viewModel.subject) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Discard") { + if viewModel.isDirty { + showDiscardConfirmation = true + } else { + viewModel.deleteDraft() + dismiss() + } + } + } + + ToolbarItem(placement: .automatic) { + Button { + showBcc.toggle() + } label: { + Label("BCC", systemImage: showBcc ? "eye.slash" : "eye") + } + .help("Toggle BCC field") + } + + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + try? await viewModel.send() + if viewModel.errorMessage == nil { + dismiss() + } + } + } label: { + if viewModel.isSending { + ProgressView() + .controlSize(.small) + } else { + Label("Send", systemImage: "paperplane") + } + } + .keyboardShortcut(.return, modifiers: .command) + .disabled(viewModel.to.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSending) + } + } + .confirmationDialog("Discard Draft?", isPresented: $showDiscardConfirmation) { + Button("Discard", role: .destructive) { + viewModel.deleteDraft() + dismiss() + } + Button("Save Draft") { + viewModel.saveDraft() + dismiss() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("You have unsaved changes.") + } + .onDisappear { + if viewModel.isDirty && !viewModel.isSending { + viewModel.saveDraft() + } + } + } + + private var navigationTitle: String { + switch viewModel.mode { + case .new: return "New Message" + case .reply: return "Reply" + case .replyAll: return "Reply All" + case .forward: return "Forward" + case .draft: return "Draft" + } + } +}