Files
MagnumOpus/Apps/MagnumOpus/ViewModels/ComposeViewModel.swift
2026-03-15 11:33:25 +01:00

383 lines
9.6 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
import Models
import MailStore
import SyncEngine
import SMTPClient
struct ComposeAttachment: Identifiable {
let id = UUID()
let url: URL
let filename: String
let mimeType: String
let size: Int
let data: Data
}
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 attachments: [ComposeAttachment] = []
var sizeWarningMessage: 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<Void, Never>?
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()
}
nonisolated deinit {
// autoSaveTask cancels itself when the ViewModel is deallocated
// since it holds a weak reference to self
}
// MARK: - Attachments
func addAttachment(url: URL) {
do {
let data = try Data(contentsOf: url)
let size = data.count
let maxSize = 25 * 1024 * 1024
let warnSize = 10 * 1024 * 1024
if size > maxSize {
errorMessage = "\(url.lastPathComponent) exceeds 25 MB limit (\(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)))."
return
}
if size > warnSize {
sizeWarningMessage = "\(url.lastPathComponent) is \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)). Large attachments may slow delivery."
}
let ext = url.pathExtension
let mimeType = UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream"
attachments.append(ComposeAttachment(url: url, filename: url.lastPathComponent, mimeType: mimeType, size: size, data: data))
} catch {
errorMessage = "Could not read file: \(error.localizedDescription)"
}
}
func removeAttachment(id: UUID) {
attachments.removeAll { $0.id == id }
}
// 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,
attachments: attachments.map { OutgoingAttachment(filename: $0.filename, mimeType: $0.mimeType, data: $0.data) }
)
let formatted: String
if attachments.isEmpty {
formatted = MessageFormatter.format(outgoing)
} else {
formatted = try MessageFormatter.formatMultipart(
outgoing,
attachments: attachments.map { ($0.filename, $0.mimeType, $0.data) }
)
}
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."
}
}
}