383 lines
9.6 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|