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)" } } }