Files
MikeMail/MikeMail/MailStore.swift
T

175 lines
9.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)"
}
}
}