175 lines
9.1 KiB
Swift
175 lines
9.1 KiB
Swift
import Foundation
|
||
import Observation
|
||
|
||
@MainActor @Observable
|
||
final class MailStore {
|
||
var accounts: [MailAccount] = []
|
||
var recipientHistory: [String] = []
|
||
var folders: [UUID: [MailFolder]] = [:]
|
||
var messages: [MailMessage] = []
|
||
var isBusy = false
|
||
var totalMessages = 0
|
||
var status = "Lokales Archiv bereit."
|
||
var error: String?
|
||
private var archive: MailArchive?
|
||
private var selectedFolderID: String?
|
||
|
||
init(archive: MailArchive? = nil) {
|
||
do {
|
||
let storage = try archive ?? MailArchive()
|
||
self.archive = storage
|
||
if let configuration = try storage.configuration() {
|
||
accounts = configuration.accounts
|
||
folders = configuration.folders
|
||
recipientHistory = configuration.recipients
|
||
} else {
|
||
if let data = UserDefaults.standard.data(forKey: "mailAccounts") {
|
||
accounts = try JSONDecoder().decode([MailAccount].self, from: data)
|
||
} else { accounts = [.testAccount] }
|
||
recipientHistory = UserDefaults.standard.stringArray(forKey: "recipientHistory") ?? []
|
||
try persist()
|
||
}
|
||
} catch {
|
||
self.error = "Lokales Archiv konnte nicht geöffnet werden: \(error.localizedDescription)"
|
||
status = "Archivfehler – vorhandene Daten wurden nicht ersetzt."
|
||
}
|
||
}
|
||
|
||
private func storage() throws -> MailArchive {
|
||
guard let archive else { throw MailError.message("Lokales Archiv ist nicht verfügbar.") }
|
||
return archive
|
||
}
|
||
|
||
private func persist() throws {
|
||
try storage().saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: folders, recipients: recipientHistory))
|
||
}
|
||
|
||
func save(account: MailAccount, password: String) throws {
|
||
guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") }
|
||
guard MIME.validAddress(account.email), !account.username.isEmpty,
|
||
!account.host.isEmpty, !account.host.contains(where: { $0.isWhitespace || $0 == "/" }),
|
||
account.imapPort > 0, account.smtpPort > 0 else {
|
||
throw MailError.message("Bitte E-Mail-Adresse, Benutzername, Server und Ports prüfen.")
|
||
}
|
||
let archive = try storage()
|
||
if let old = accounts.first(where: { $0.id == account.id }),
|
||
old.host != account.host || old.username != account.username || old.email != account.email {
|
||
throw MailError.message("Für eine andere Server-, Benutzer- oder Mailadresse bitte ein neues Konto anlegen. So bleiben archivierte Mails eindeutig zugeordnet.")
|
||
}
|
||
var updated = accounts
|
||
if let index = updated.firstIndex(where: { $0.id == account.id }) { updated[index] = account }
|
||
else { updated.append(account) }
|
||
if !password.isEmpty { try PasswordStore.save(password, for: account.id) }
|
||
try archive.saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory))
|
||
accounts = updated
|
||
status = "Konto lokal gespeichert."
|
||
}
|
||
|
||
func deleteAccount(id: UUID) throws {
|
||
guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") }
|
||
let archive = try storage()
|
||
let remaining = accounts.filter { $0.id != id }
|
||
var remainingFolders = folders
|
||
remainingFolders[id] = nil
|
||
try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory))
|
||
accounts = remaining
|
||
folders = remainingFolders
|
||
messages = []
|
||
totalMessages = 0
|
||
try PasswordStore.delete(for: id)
|
||
status = "Konto entfernt; archivierte Originalmails und Serverdaten bleiben erhalten."
|
||
}
|
||
|
||
private func allFolders(_ items: [MailFolder]) -> [MailFolder] {
|
||
items.flatMap { [$0] + allFolders($0.children ?? []) }
|
||
}
|
||
|
||
func refresh(folderID: String?) async {
|
||
guard !isBusy else { return }
|
||
isBusy = true
|
||
defer { isBusy = false }
|
||
var failures: [String] = []
|
||
do {
|
||
let archive = try storage()
|
||
for account in accounts {
|
||
do {
|
||
let client = try IMAPClient(account: account)
|
||
defer { client.close() }
|
||
status = "\(account.email): Verbindung wird aufgebaut …"
|
||
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
||
let discovered = try await client.folders(accountID: account.id)
|
||
// Keep the previous listing if saving the new configuration fails.
|
||
var updated = folders
|
||
updated[account.id] = discovered
|
||
try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory))
|
||
folders = updated
|
||
for folder in allFolders(discovered) where folder.selectable {
|
||
do {
|
||
status = "\(folder.name): Änderungen prüfen …"
|
||
let inventory = try await client.inventory(folder: folder)
|
||
let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity)
|
||
let missing = Set(inventory.flags.keys).subtracting(known).sorted(by: >)
|
||
for (index, uid) in missing.enumerated() {
|
||
try Task.checkCancellation()
|
||
status = "\(folder.name): \(index + 1) von \(missing.count) neuen Mails speichern …"
|
||
let raw = try await client.rawMessage(uid: uid)
|
||
try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity,
|
||
uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false)
|
||
}
|
||
try archive.reconcile(folderID: folder.id, inventory: inventory)
|
||
if selectedFolderID == folder.id { try display(folderID: folder.id) }
|
||
} catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") }
|
||
}
|
||
} catch { failures.append("\(account.email): \(error.localizedDescription)") }
|
||
}
|
||
if let selectedFolderID { try display(folderID: selectedFolderID) }
|
||
} catch { failures.append(error.localizedDescription) }
|
||
if failures.isEmpty {
|
||
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
|
||
} else {
|
||
error = failures.joined(separator: "\n")
|
||
status = "Abgleich unvollständig. Bereits gespeicherte Mails bleiben offline verfügbar."
|
||
}
|
||
}
|
||
|
||
private func display(folderID: String) throws {
|
||
messages = try storage().messages(folderID: folderID)
|
||
totalMessages = messages.count
|
||
}
|
||
|
||
func load(folderID: String?, more: Bool = false) async {
|
||
selectedFolderID = folderID
|
||
guard let folderID else { messages = []; totalMessages = 0; return }
|
||
do {
|
||
try display(folderID: folderID)
|
||
if !isBusy { status = "\(messages.count) lokal gespeicherte Nachrichten · Abrufen gleicht Änderungen ab." }
|
||
} catch { self.error = error.localizedDescription }
|
||
}
|
||
|
||
func send(accountID: UUID?, recipients: String, subject: String, body: String, attachments: [MailAttachment]) async throws {
|
||
guard let account = accounts.first(where: { $0.id == accountID }) else {
|
||
throw MailError.message("Bitte ein Absenderkonto auswählen.")
|
||
}
|
||
let archive = try storage()
|
||
let addresses = recipients.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
let data = try MIME.compose(from: account.email, to: addresses, subject: subject, body: body, attachments: attachments)
|
||
let localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien")
|
||
let localID = UUID().uuidString
|
||
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message.
|
||
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["Pending"])
|
||
try await SMTPClient.send(account: account, password: PasswordStore.password(for: account.id), recipients: addresses, message: data)
|
||
do {
|
||
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["\\Seen", "Sent"])
|
||
for address in addresses {
|
||
recipientHistory.removeAll { $0.caseInsensitiveCompare(address) == .orderedSame }
|
||
recipientHistory.insert(address, at: 0)
|
||
}
|
||
try persist()
|
||
status = "Nachricht versendet und lokal archiviert."
|
||
} catch {
|
||
// SMTP already succeeded: do not offer an accidental resend by throwing here.
|
||
self.error = "Nachricht wurde versendet; Archivstatus konnte nicht aktualisiert werden: \(error.localizedDescription)"
|
||
}
|
||
}
|
||
}
|