diff --git a/MikeMail.xcodeproj/project.xcproj b/MikeMail.xcodeproj/project.xcproj index 8c97254..12ab648 100644 --- a/MikeMail.xcodeproj/project.xcproj +++ b/MikeMail.xcodeproj/project.xcproj @@ -8,6 +8,7 @@ "development": "en", "supported": [ "Base", + "de", ], }, "files": [ @@ -46,13 +47,14 @@ "ENABLE_HARDENED_RUNTIME": "YES", "ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES", "ENABLE_PREVIEWS": "YES", - "ENABLE_USER_SELECTED_FILES": "readonly", + "ENABLE_USER_SELECTED_FILES": "readwrite", "GENERATE_INFOPLIST_FILE": "YES", "INFOPLIST_KEY_NSHumanReadableCopyright": "", "LD_RUNPATH_SEARCH_PATHS": [ "$(inherited)", "@executable_path/../Frameworks", ], + "MACOSX_DEPLOYMENT_TARGET": "27.0", "MARKETING_VERSION": "1.0", "OTHER_LDFLAGS": [ "$(inherited)", @@ -89,7 +91,7 @@ "CURRENT_PROJECT_VERSION": "1", "DEVELOPMENT_TEAM": "GQR972L9NJ", "GENERATE_INFOPLIST_FILE": "YES", - "MACOSX_DEPLOYMENT_TARGET": "27.2", + "MACOSX_DEPLOYMENT_TARGET": "27.0", "MARKETING_VERSION": "1.0", "PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailTests", "PRODUCT_NAME": "$(TARGET_NAME)", @@ -119,6 +121,7 @@ "CURRENT_PROJECT_VERSION": "1", "DEVELOPMENT_TEAM": "GQR972L9NJ", "GENERATE_INFOPLIST_FILE": "YES", + "MACOSX_DEPLOYMENT_TARGET": "27.0", "MARKETING_VERSION": "1.0", "PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMailUITests", "PRODUCT_NAME": "$(TARGET_NAME)", diff --git a/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate b/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..3c669f1 Binary files /dev/null and b/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MikeMail/AccountsView.swift b/MikeMail/AccountsView.swift index 7d1e0a8..d1c09c8 100644 --- a/MikeMail/AccountsView.swift +++ b/MikeMail/AccountsView.swift @@ -17,6 +17,7 @@ struct AccountsView: View { @State private var testedPassword = "" @State private var testMailAccount: MailAccount? @State private var askingTestMail = false + @State private var accountsNeedingRefresh: Set = [] @AppStorage("offerTestMail") private var offerTestMail = true var body: some View { @@ -116,6 +117,7 @@ struct AccountsView: View { selection = store.accounts.first?.id } .onChange(of: selection) { _, _ in error = nil; resetResult = nil } + .onDisappear { refreshChangedAccounts() } .confirmationDialog("Alle lokalen Mails dieses Kontos löschen?", isPresented: $confirmingReset, titleVisibility: .visible) { Button("Lokale Mails endgültig löschen", role: .destructive) { guard let account = resetAccount else { return } @@ -153,11 +155,25 @@ struct AccountsView: View { await store.refresh(folderID: nil, accountID: id) if offerTestMail { testMailAccount = account; askingTestMail = true } } + } else { + accountsNeedingRefresh.insert(id) } error = nil } catch { self.error = error.localizedDescription } } + private func refreshChangedAccounts() { + let accountIDs = accountsNeedingRefresh + accountsNeedingRefresh.removeAll() + guard !accountIDs.isEmpty else { return } + Task { + for id in accountIDs where store.accounts.contains(where: { $0.id == id }) { + while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) } + await store.refresh(folderID: nil, accountID: id) + } + } + } + private func testConnection() { guard let id = selection, let account = drafts[id] else { return } let password = passwords[id] ?? "" @@ -190,6 +206,7 @@ struct AccountsView: View { struct AccountSettingsForm: View { @Binding var account: MailAccount @Binding var password: String + @State private var showsPassword = false var body: some View { Form { @@ -199,7 +216,21 @@ struct AccountSettingsForm: View { TextField("Server", text: $account.host) TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never)) TextField("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort, format: .number.grouping(.never)) - SecureField("Passwort", text: $password) + LabeledContent("Passwort") { + HStack(spacing: 8) { + if showsPassword { + TextField("Passwort", text: $password) + } else { + SecureField("Passwort", text: $password) + } + Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen", + systemImage: showsPassword ? "eye.slash" : "eye") { + showsPassword.toggle() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } + } } } } diff --git a/MikeMail/ContentView.swift b/MikeMail/ContentView.swift index 6b8c82e..662d1ad 100644 --- a/MikeMail/ContentView.swift +++ b/MikeMail/ContentView.swift @@ -1,7 +1,30 @@ import SwiftUI +import Observation import AppKit import UniformTypeIdentifiers +@MainActor @Observable +final class AccountPresentation { + var isPresented = false +} + +extension FocusedValues { + @Entry var accountPresentation: AccountPresentation? +} + +struct AccountCommands: Commands { + @FocusedValue(\.accountPresentation) private var accountPresentation + + var body: some Commands { + CommandGroup(after: .appSettings) { + Button("Konten verwalten …") { + accountPresentation?.isPresented = true + } + .disabled(accountPresentation == nil) + } + } +} + struct ContentView: View { let store: MailStore @AppStorage("selectedFolder") private var savedFolder = "" @@ -16,7 +39,7 @@ struct ContentView: View { @AppStorage("searchRegex") private var searchRegex = false @State private var selectedFolder: String? @State private var selectedMessages: Set = [] - @State private var showingAccounts = false + @State private var accountPresentation = AccountPresentation() @Environment(\.openWindow) private var openWindow @AppStorage("idleEnabled") private var idleEnabled = true @AppStorage("pollMinutes") private var pollMinutes = 5 @@ -119,11 +142,14 @@ struct ContentView: View { .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty) Button("Löschen", systemImage: "trash") { deleteSelection() } .disabled(selectedMessages.isEmpty) - Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true } SettingsLink { Label("Einstellungen", systemImage: "gearshape") } } } - .sheet(isPresented: $showingAccounts) { AccountsView(store: store) } + .focusedSceneValue(\.accountPresentation, accountPresentation) + .sheet(isPresented: Binding( + get: { accountPresentation.isPresented }, + set: { accountPresentation.isPresented = $0 } + )) { AccountsView(store: store) } .sheet(item: Binding(get: { store.authenticationAccount }, set: { value in if value == nil { store.dismissPasswordRequest() } })) { account in @@ -307,24 +333,37 @@ struct MessageTable: View { @AppStorage("mailSortColumn") private var savedColumn = "date" @AppStorage("mailSortAscending") private var savedAscending = false @State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)] + @SceneStorage("MessageTableColumns") + private var columnCustomization: TableColumnCustomization var body: some View { - Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder) { - TableColumn("Anhang", value: \.attachmentCount) { message in - if !message.attachments.isEmpty { - Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge") - } - }.width(55) + Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder, + columnCustomization: $columnCustomization) { TableColumn("Absender", value: \.displaySender) { message in Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold) - }.width(min: 110, ideal: 160) + } + .width(min: 110, ideal: 160) + .customizationID("sender") + TableColumn("", value: \.attachmentCount) { message in + if !message.attachments.isEmpty { + Image(systemName: "paperclip") + .frame(maxWidth: .infinity, alignment: .center) + .accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge") + } + } + .width(28) + .customizationID("attachments") TableColumn("Datum", value: \.date) { message in if message.date == .distantPast { Text("—") } else { Text(MailDisplay.date(message.date)).monospacedDigit().fontWeight(message.isRead ? .regular : .bold) } - }.width(min: 120, ideal: 150) + } + .width(min: 120, ideal: 150) + .customizationID("date") TableColumn("Betreff", value: \.subject) { message in Text(message.subject).fontWeight(message.isRead ? .regular : .bold) - }.width(min: 160, ideal: 280) + } + .width(min: 160, ideal: 280) + .customizationID("subject") } rows: { ForEach(messages.sorted(using: sortOrder)) { message in TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id])) diff --git a/MikeMail/Localizable.xcstrings b/MikeMail/Localizable.xcstrings new file mode 100644 index 0000000..9016636 --- /dev/null +++ b/MikeMail/Localizable.xcstrings @@ -0,0 +1,1635 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "—" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "—" + } + } + } + }, + "„%@“ und alle Unterordner samt Nachrichten werden auf dem Server endgültig gelöscht." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "„%@“ und alle Unterordner samt Nachrichten werden auf dem Server endgültig gelöscht." + } + } + } + }, + "%@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@" + } + } + } + }, + "%lld Anhänge" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Anhang" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Anhänge" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Anhänge" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Anhang" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Anhänge" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Anhänge" + } + } + } + } + } + } + }, + "%lld Nachrichten" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachricht" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachricht" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten" + } + } + } + } + } + } + }, + "%lld Nachrichten löschen?" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachricht löschen?" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten löschen?" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten löschen?" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachricht löschen?" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten löschen?" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld Nachrichten löschen?" + } + } + } + } + } + } + }, + "%lld ungelesen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld ungelesen" + } + } + } + }, + "Abbrechen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Abbrechen" + } + } + } + }, + "Abruf" : { + + }, + "Absender" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Absender" + } + } + } + }, + "Alle %lld Minuten" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Jede %lld Minute" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle %lld Minuten" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle %lld Minuten" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Jede %lld Minute" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle %lld Minuten" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle %lld Minuten" + } + } + } + } + } + } + }, + "Alle Anhänge speichern unter…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle Anhänge speichern unter…" + } + } + } + }, + "Alle Konten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle Konten" + } + } + } + }, + "Alle lokalen Mails dieses Kontos löschen?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle lokalen Mails dieses Kontos löschen?" + } + } + } + }, + "Alle Nachrichten in diesem Papierkorb werden auch auf dem Server endgültig gelöscht. Beim gemeinsamen Papierkorb betrifft dies alle Konten." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alle Nachrichten in diesem Papierkorb werden auch auf dem Server endgültig gelöscht. Beim gemeinsamen Papierkorb betrifft dies alle Konten." + } + } + } + }, + "Als gelesen markieren" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Als gelesen markieren" + } + } + } + }, + "Als ungelesen markieren" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Als ungelesen markieren" + } + } + } + }, + "An (mehrere mit Komma trennen)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "An (mehrere mit Komma trennen)" + } + } + } + }, + "An: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "An: %@" + } + } + } + }, + "Anhang" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Anhang" + } + } + } + }, + "Anhang – Dateien hier ablegen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Anhang – Dateien hier ablegen" + } + } + } + }, + "Anhang hinzufügen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Anhang hinzufügen" + } + } + } + }, + "Anhänge – Dateien hier ablegen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Anhänge – Dateien hier ablegen" + } + } + } + }, + "Antworten…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Antworten …" + } + } + } + }, + "Auf neue Mails prüfen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Auf neue Mails prüfen" + } + } + } + }, + "Ausgewählte Nachrichten im Papierkorb werden auch auf dem Server endgültig gelöscht. Andere ausgewählte Nachrichten werden in den Papierkorb verschoben." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ausgewählte Nachrichten im Papierkorb werden auch auf dem Server endgültig gelöscht. Andere ausgewählte Nachrichten werden in den Papierkorb verschoben." + } + } + } + }, + "BCC" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "BCC" + } + } + } + }, + "Beim Löschen von Mails nachfragen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Beim Löschen von Mails nachfragen" + } + } + } + }, + "Beim nächsten Start wiederherstellen" : { + + }, + "Beim Start automatisch auf neue Mails prüfen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Beim Start automatisch auf neue Mails prüfen" + } + } + } + }, + "Benutzername" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Benutzername" + } + } + } + }, + "Betreff" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Betreff" + } + } + } + }, + "CC" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "CC" + } + } + } + }, + "CC: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "CC: %@" + } + } + } + }, + "Darstellung" : { + + }, + "Das Backup enthält App-Einstellungen, Konten sowie gespeicherte Fenster-, Split- und Tabellenlayouts. Mails und Anhänge werden nicht gesichert." : { + + }, + "Das Backup enthält die Passwörter im Klartext. Bewahre die Datei an einem geschützten Ort auf." : { + + }, + "Das Passwort wird erst nach erfolgreicher Anmeldung im Schlüsselbund gespeichert." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Das Passwort wird erst nach erfolgreicher Anmeldung im Schlüsselbund gespeichert." + } + } + } + }, + "Datei speichern unter…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Datei speichern unter …" + } + } + } + }, + "Datum" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Datum" + } + } + } + }, + "Der Inhalt und die Anhänge erscheinen hier." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Der Inhalt und die Anhänge erscheinen hier." + } + } + } + }, + "Der Mailserver hat die Anmeldung für %@ abgelehnt." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Der Mailserver hat die Anmeldung für %@ abgelehnt." + } + } + } + }, + "Der Ordner wird auf dem Mailserver erstellt." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Der Ordner wird auf dem Mailserver erstellt." + } + } + } + }, + "Die aktuellen Einstellungen und Konten werden beim nächsten Start ersetzt. Mails und Anhänge bleiben unverändert. MikeMail wird jetzt automatisch beendet; öffne die App anschließend erneut." : { + + }, + "Die ausgewählten Nachrichten werden auch auf dem Server in den Papierkorb verschoben." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Die ausgewählten Nachrichten werden auch auf dem Server in den Papierkorb verschoben." + } + } + } + }, + "Die lokalen Kontoeinstellungen und das gespeicherte Passwort werden entfernt. Mails auf dem Server bleiben erhalten." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Die lokalen Kontoeinstellungen und das gespeicherte Passwort werden entfernt. Mails auf dem Server bleiben erhalten." + } + } + } + }, + "Die Nachricht wird auch auf dem Server endgültig gelöscht." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Die Nachricht wird auch auf dem Server endgültig gelöscht." + } + } + } + }, + "Die Nachricht wird auch auf dem Server in den Papierkorb verschoben." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Die Nachricht wird auch auf dem Server in den Papierkorb verschoben." + } + } + } + }, + "Doppelklick zum Öffnen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Doppelklick zum Öffnen" + } + } + } + }, + "Durchgestrichen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Durchgestrichen" + } + } + } + }, + "E-Mail-Adresse" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "E-Mail-Adresse" + } + } + } + }, + "Ein Anhang" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ein Anhang" + } + } + } + }, + "Ein leeres Passwortfeld behält das gespeicherte Passwort bei. Änderungen mit „Speichern“ übernehmen." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ein leeres Passwortfeld behält das gespeicherte Passwort bei. Änderungen mit „Speichern“ übernehmen." + } + } + } + }, + "Eine Testnachricht an %@ senden?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eine Testnachricht an %@ senden?" + } + } + } + }, + "Einstellungen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Einstellungen" + } + } + } + }, + "Einstellungen sichern …" : { + + }, + "Einstellungen und Konten" : { + + }, + "Einstellungen wiederherstellen …" : { + + }, + "Einstellungen wiederherstellen?" : { + + }, + "Entfernen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Entfernen" + } + } + } + }, + "Erstellen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Erstellen" + } + } + } + }, + "Externe Inhalte blockiert" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Externe Inhalte blockiert" + } + } + } + }, + "Externe Inhalte erlaubt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Externe Inhalte erlaubt" + } + } + } + }, + "Externe Inhalte in HTML-Mails blockieren" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Externe Inhalte in HTML-Mails blockieren" + } + } + } + }, + "Fett" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Fett" + } + } + } + }, + "Füge ein Konto hinzu oder wähle links ein Konto aus." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Füge ein Konto hinzu oder wähle links ein Konto aus." + } + } + } + }, + "Gemeinsamen Papierkorb anzeigen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Gemeinsamen Papierkorb anzeigen" + } + } + } + }, + "Gemeinsamen Posteingang anzeigen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Gemeinsamen Posteingang anzeigen" + } + } + } + }, + "Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten." + } + } + } + }, + "HTML-Nachricht" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "HTML-Nachricht" + } + } + } + }, + "IMAP-Port (TLS)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "IMAP-Port (TLS)" + } + } + } + }, + "Immer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Immer" + } + } + } + }, + "In neuem Fenster öffnen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "In neuem Fenster öffnen" + } + } + } + }, + "Inhalt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Inhalt" + } + } + } + }, + "Inhalte laden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Inhalte laden" + } + } + } + }, + "Kein Konto ausgewählt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Kein Konto ausgewählt" + } + } + } + }, + "Keine Nachrichten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Keine Nachrichten" + } + } + } + }, + "Konten" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Konten" + } + } + } + }, + "Konten verwalten …" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Konten verwalten …" + } + } + } + }, + "Konteneinstellungen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Konteneinstellungen" + } + } + } + }, + "Konto aus MikeMail entfernen?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Konto aus MikeMail entfernen?" + } + } + } + }, + "Konto löschen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Konto löschen" + } + } + } + }, + "Konto: %@\n\nAlle lokal gespeicherten Mails und Anhänge dieses Kontos werden entfernt, auch archivierte Nachrichten und lokale Versandkopien. Nur lokal vorhandene Mails können nicht vom Server wiederhergestellt werden.\n\nMails auf dem Server, Kontoeinstellungen und Passwort bleiben unverändert. Beim nächsten „Abrufen“ werden alle vorhandenen Servermails neu heruntergeladen." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Konto: %@\n\nAlle lokal gespeicherten Mails und Anhänge dieses Kontos werden entfernt, auch archivierte Nachrichten und lokale Versandkopien. Nur lokal vorhandene Mails können nicht vom Server wiederhergestellt werden.\n\nMails auf dem Server, Kontoeinstellungen und Passwort bleiben unverändert. Beim nächsten „Abrufen“ werden alle vorhandenen Servermails neu heruntergeladen." + } + } + } + }, + "Kursiv" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Kursiv" + } + } + } + }, + "Lesestatus" : { + + }, + "Lokale Mails endgültig löschen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Lokale Mails endgültig löschen" + } + } + } + }, + "Lokale Mails löschen …" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Lokale Mails löschen  …" + } + } + } + }, + "Löschen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Löschen" + } + } + } + }, + "Löschen…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Löschen …" + } + } + } + }, + "Mailabruf" : { + + }, + "Maildarstellung" : { + + }, + "Mails abrufen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Mails abrufen" + } + } + } + }, + "MikeMail" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "MikeMail" + } + } + } + }, + "Nach %lld Sekunden" : { + "localizations" : { + "de" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach %lld Sekunde" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach %lld Sekunden" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach %lld Sekunden" + } + } + } + } + }, + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach %lld Sekunde" + } + }, + "other" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach %lld Sekunden" + } + }, + "zero" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach %lld Sekunden" + } + } + } + } + } + } + }, + "Nach einer Wartezeit" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach einer Wartezeit" + } + } + } + }, + "Nach neuem Konto Testmail anbieten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nach neuem Konto Testmail anbieten" + } + } + } + }, + "Nachricht" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nachricht" + } + } + } + }, + "Nachricht auswählen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nachricht auswählen" + } + } + } + }, + "Nachricht löschen?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nachricht löschen?" + } + } + } + }, + "Nachrichten durchsuchen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nachrichten durchsuchen" + } + } + } + }, + "Nachrichten löschen…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nachrichten löschen …" + } + } + } + }, + "Name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Name" + } + } + } + }, + "Nein" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nein" + } + } + } + }, + "Neue Konten" : { + + }, + "Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung." + } + } + } + }, + "Neue Nachricht" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Neue Nachricht" + } + } + } + }, + "Neuen Ordner erstellen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Neuen Ordner erstellen" + } + } + } + }, + "Neues Konto" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Neues Konto" + } + } + } + }, + "Neues Konto hinzufügen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Neues Konto hinzufügen" + } + } + } + }, + "Neues Passwort" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Neues Passwort" + } + } + } + }, + "Nie" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nie" + } + } + } + }, + "Nur bei mehreren Nachrichten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nur bei mehreren Nachrichten" + } + } + } + }, + "Nur manuell" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nur manuell" + } + } + } + }, + "Öffnen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Öffnen" + } + } + } + }, + "OK" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "OK" + } + } + } + }, + "Ordner löschen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ordner löschen" + } + } + } + }, + "Ordner löschen?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ordner löschen?" + } + } + } + }, + "Ordnername" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ordnername" + } + } + } + }, + "Papierkorb leeren" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Papierkorb leeren" + } + } + } + }, + "Papierkorb leeren?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Papierkorb leeren?" + } + } + } + }, + "Passwort" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Passwort" + } + } + } + }, + "Passwort anzeigen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Passwort anzeigen" + } + } + } + }, + "Passwort erforderlich" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Passwort erforderlich" + } + } + } + }, + "Passwort verbergen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Passwort verbergen" + } + } + } + }, + "Passwörter in das Backup aufnehmen" : { + + }, + "Prüfen und speichern" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Prüfen und speichern" + } + } + } + }, + "Regulärer Ausdruck" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Regulärer Ausdruck" + } + } + } + }, + "Schließen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Schließen" + } + } + } + }, + "Schriftart" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Schriftart" + } + } + } + }, + "Schriftart für Mailtexte" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Schriftart für Mailtexte" + } + } + } + }, + "Schriftgröße" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Schriftgröße" + } + } + } + }, + "Schriftgröße: %lld pt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Schriftgröße: %lld pt" + } + } + } + }, + "Seitenleiste" : { + + }, + "Senden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Senden" + } + } + } + }, + "Server" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Server" + } + } + } + }, + "Sicherung" : { + + }, + "SMTP-Port (587 STARTTLS / 465 TLS)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "SMTP-Port (587 STARTTLS / 465 TLS)" + } + } + } + }, + "Sofort bei Auswahl" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Sofort bei Auswahl" + } + } + } + }, + "Sofortempfang" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Sofortempfang" + } + } + } + }, + "Sofortempfang mit IMAP IDLE" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Sofortempfang mit IMAP IDLE" + } + } + } + }, + "Speichern" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Speichern" + } + } + } + }, + "Suchen in:" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Suchen in:" + } + } + } + }, + "Testmail senden" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Testmail senden" + } + } + } + }, + "Testmail senden?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Testmail senden?" + } + } + } + }, + "Textfarbe" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Textfarbe" + } + } + } + }, + "Unterstrichen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Unterstrichen" + } + } + } + }, + "Verbindung testen" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Verbindung testen" + } + } + } + }, + "Verhalten" : { + + }, + "Von" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Von" + } + } + } + }, + "Von: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Von: %@" + } + } + } + }, + "Vorschau Mailtext" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Vorschau Mailtext" + } + } + } + }, + "Wähle einen Ordner oder ändere die Suche." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Wähle einen Ordner oder ändere die Suche." + } + } + } + }, + "Wartezeit in Sekunden" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Wartezeit in Sekunden" + } + } + } + }, + "Weiterleiten" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Weiterleiten" + } + } + } + } + }, + "version" : "1.3" +} \ No newline at end of file diff --git a/MikeMail/MailModels.swift b/MikeMail/MailModels.swift index 5dafdec..e7abb5d 100644 --- a/MikeMail/MailModels.swift +++ b/MikeMail/MailModels.swift @@ -108,7 +108,7 @@ enum PasswordStore { let status = SecItemCopyMatching(entry as CFDictionary, &result) guard status == errSecSuccess, let data = result as? Data, let value = String(data: data, encoding: .utf8) else { - throw MailError.message("Bitte unter „Konten“ das Passwort für dieses Konto speichern.") + throw MailError.authentication("Für dieses Konto fehlt ein gültiges Passwort im Schlüsselbund.") } return value } diff --git a/MikeMail/MailSettings.swift b/MikeMail/MailSettings.swift index 8236e91..dba78e0 100644 --- a/MikeMail/MailSettings.swift +++ b/MikeMail/MailSettings.swift @@ -1,5 +1,6 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers enum MailDeleteConfirmation: String { case always, multiple, never @@ -25,6 +26,11 @@ struct AutoReadRequest: Equatable { } struct MailSettings: View { + let store: MailStore + @State private var includePasswordsInBackup = false + @State private var restoreData: Data? + @State private var confirmingRestore = false + @State private var backupStatus = UserDefaults.standard.string(forKey: SettingsBackupManager.restoreResultKey) @AppStorage("markReadImmediately") private var markReadImmediately = false @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 @AppStorage("bodyFontName") private var bodyFontName = "Helvetica" @@ -39,44 +45,237 @@ struct MailSettings: View { @AppStorage("showUnifiedInbox") private var showInbox = true @AppStorage("showUnifiedTrash") private var showTrash = true + var body: some View { + TabView { + Tab("Darstellung", systemImage: "textformat") { + DisplaySettingsTab( + bodyFontName: $bodyFontName, + bodyFontSize: $bodyFontSize, + blockRemoteContent: $blockRemoteContent, + showInbox: $showInbox, + showTrash: $showTrash, + fontNames: fontNames + ) + } + Tab("Abruf", systemImage: "arrow.clockwise") { + RetrievalSettingsTab( + idleEnabled: $idleEnabled, + pollMinutes: $pollMinutes, + offerTestMail: $offerTestMail, + checkOnLaunch: $checkOnLaunch + ) + } + Tab("Verhalten", systemImage: "slider.horizontal.3") { + BehaviorSettingsTab( + markReadImmediately: $markReadImmediately, + markReadDelaySeconds: $markReadDelaySeconds, + deleteConfirmation: $deleteConfirmation + ) + } + Tab("Sicherung", systemImage: "externaldrive") { + BackupSettingsTab( + includePasswords: $includePasswordsInBackup, + status: backupStatus, + save: saveSettingsBackup, + restore: selectSettingsBackup + ) + } + } + .padding(24) + .frame(width: 940) + .frame(minHeight: 620) + .onAppear { + if backupStatus != nil { + UserDefaults.standard.removeObject(forKey: SettingsBackupManager.restoreResultKey) + } + } + .alert("Einstellungen wiederherstellen?", isPresented: $confirmingRestore) { + Button("Abbrechen", role: .cancel) { restoreData = nil } + Button("Beim nächsten Start wiederherstellen", role: .destructive) { + stageSettingsRestore() + } + } message: { + Text("Die aktuellen Einstellungen und Konten werden beim nächsten Start ersetzt. Mails und Anhänge bleiben unverändert. MikeMail wird jetzt automatisch beendet; öffne die App anschließend erneut.") + } + } + + private func saveSettingsBackup() { + do { + let backup = try SettingsBackupManager.create( + accounts: store.accounts, + includePasswords: includePasswordsInBackup + ) + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "MikeMail-Einstellungen.json" + panel.title = "Einstellungen sichern" + guard panel.runModal() == .OK, let url = panel.url else { return } + try backup.data.write(to: url, options: .atomic) + backupStatus = backup.missingPasswords == 0 + ? "Einstellungen und \(store.accounts.count) Konten wurden gesichert." + : "Backup gespeichert. Für \(backup.missingPasswords) Konten war kein Passwort im Schlüsselbund vorhanden." + } catch { + backupStatus = error.localizedDescription + } + } + + private func selectSettingsBackup() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.title = "Einstellungen wiederherstellen" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let data = try Data(contentsOf: url) + _ = try SettingsBackupManager.validate(data) + restoreData = data + confirmingRestore = true + } catch { + backupStatus = error.localizedDescription + } + } + + private func stageSettingsRestore() { + guard let restoreData else { return } + do { + try SettingsBackupManager.stageRestore(restoreData) + backupStatus = "Backup geprüft. MikeMail wird beendet und stellt die Einstellungen beim nächsten Start wieder her." + NSApp.terminate(nil) + } catch { + backupStatus = error.localizedDescription + } + self.restoreData = nil + } +} + +private struct DisplaySettingsTab: View { + @Binding var bodyFontName: String + @Binding var bodyFontSize: Double + @Binding var blockRemoteContent: Bool + @Binding var showInbox: Bool + @Binding var showTrash: Bool + let fontNames: [String] + var body: some View { Form { - Picker("Schriftart für Mailtexte", selection: $bodyFontName) { - ForEach(fontNames, id: \.self) { Text($0).tag($0) } + Section("Maildarstellung") { + Picker("Schriftart für Mailtexte", selection: $bodyFontName) { + ForEach(fontNames, id: \.self) { Text($0).tag($0) } + } + Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48) + Text("Vorschau Mailtext") + .font(.custom(bodyFontName, size: bodyFontSize)) + .padding(.top, 8) + Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent) + Text("Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48) - Text("Vorschau Mailtext").font(.custom(bodyFontName, size: bodyFontSize)) - Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent) - Text("Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten.") - .font(.caption).foregroundStyle(.secondary) - Divider() - Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled) - Text("Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung.").font(.caption).foregroundStyle(.secondary) - Picker("Auf neue Mails prüfen", selection: $pollMinutes) { - Text("Nur manuell").tag(0) - ForEach([1, 2, 5, 10, 15, 30, 60], id: \.self) { Text("Alle \($0) Minuten").tag($0) } + Section("Seitenleiste") { + Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox) + Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash) } - Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail) - Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch) - Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox) - Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash) - Picker("Als gelesen markieren", selection: $markReadImmediately) { - Text("Sofort bei Auswahl").tag(true) - Text("Nach einer Wartezeit").tag(false) - } - if !markReadImmediately { - Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600) - TextField("Wartezeit in Sekunden", value: $markReadDelaySeconds, format: .number) - .onChange(of: markReadDelaySeconds) { _, value in - markReadDelaySeconds = min(3600, max(1, value)) + } + .formStyle(.grouped) + .padding(24) + } +} + +private struct RetrievalSettingsTab: View { + @Binding var idleEnabled: Bool + @Binding var pollMinutes: Int + @Binding var offerTestMail: Bool + @Binding var checkOnLaunch: Bool + + var body: some View { + Form { + Section("Mailabruf") { + Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled) + Text("Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Picker("Auf neue Mails prüfen", selection: $pollMinutes) { + Text("Nur manuell").tag(0) + ForEach([1, 2, 5, 10, 15, 30, 60], id: \.self) { + Text("Alle \($0) Minuten").tag($0) } + } + Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch) } - Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) { - Text("Immer").tag(MailDeleteConfirmation.always) - Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple) - Text("Nie").tag(MailDeleteConfirmation.never) + Section("Neue Konten") { + Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail) } - }.padding(24).frame(width: 460) + } + .formStyle(.grouped) + .padding(24) + } +} + +private struct BehaviorSettingsTab: View { + @Binding var markReadImmediately: Bool + @Binding var markReadDelaySeconds: Int + @Binding var deleteConfirmation: MailDeleteConfirmation + + var body: some View { + Form { + Section("Lesestatus") { + Picker("Als gelesen markieren", selection: $markReadImmediately) { + Text("Sofort bei Auswahl").tag(true) + Text("Nach einer Wartezeit").tag(false) + } + if !markReadImmediately { + Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600) + } + } + Section("Löschen") { + Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) { + Text("Immer").tag(MailDeleteConfirmation.always) + Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple) + Text("Nie").tag(MailDeleteConfirmation.never) + } + } + } + .formStyle(.grouped) + .padding(24) + } +} + +private struct BackupSettingsTab: View { + @Binding var includePasswords: Bool + let status: String? + let save: () -> Void + let restore: () -> Void + + var body: some View { + Form { + Section("Einstellungen und Konten") { + Text("Das Backup enthält App-Einstellungen, Konten sowie gespeicherte Fenster-, Split- und Tabellenlayouts. Mails und Anhänge werden nicht gesichert.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Toggle("Passwörter in das Backup aufnehmen", isOn: $includePasswords) + if includePasswords { + Text("Das Backup enthält die Passwörter im Klartext. Bewahre die Datei an einem geschützten Ort auf.") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + HStack { + Button("Einstellungen sichern …", systemImage: "square.and.arrow.up", action: save) + Button("Einstellungen wiederherstellen …", systemImage: "square.and.arrow.down", action: restore) + } + if let status { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .formStyle(.grouped) + .padding(24) } } diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift index 8254475..6d48588 100644 --- a/MikeMail/MailStore.swift +++ b/MikeMail/MailStore.swift @@ -313,7 +313,11 @@ final class MailStore { do { try await client.login(account: account, password: PasswordStore.password(for: id)) return client - } catch { client.close(); throw error } + } catch { + client.close() + if case MailError.authentication = error { requestPassword(accountID: id) } + throw error + } } /// Message mutations commit their confirmed changes locally; folder operations rediscover the tree. diff --git a/MikeMail/MikeMail-InfoPlist.xcstrings b/MikeMail/MikeMail-InfoPlist.xcstrings new file mode 100644 index 0000000..e6cfb43 --- /dev/null +++ b/MikeMail/MikeMail-InfoPlist.xcstrings @@ -0,0 +1,42 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "MikeMail" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "MikeMail" + } + } + } + }, + "NSHumanReadableCopyright" : { + "comment" : "Copyright (human-readable)", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "" + } + } + } + } + }, + "version" : "1.3" +} \ No newline at end of file diff --git a/MikeMail/MikeMailApp.swift b/MikeMail/MikeMailApp.swift index 65205b0..a7a6f6b 100644 --- a/MikeMail/MikeMailApp.swift +++ b/MikeMail/MikeMailApp.swift @@ -3,6 +3,7 @@ import SwiftUI @main struct MikeMailApp: App { @State private var store: MailStore = { + SettingsBackupManager.applyPendingRestoreIfNeeded() #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() } #endif @@ -14,6 +15,7 @@ struct MikeMailApp: App { ContentView(store: store) } .defaultSize(width: 1200, height: 800) + .commands { AccountCommands() } WindowGroup("Nachricht", id: "message", for: String.self) { $id in MessageDetailWindow(store: store, messageID: id) }.defaultSize(width: 800, height: 650) @@ -24,6 +26,6 @@ struct MikeMailApp: App { } .defaultSize(width: 760, height: 780) .windowResizability(.contentMinSize) - Settings { MailSettings() } + Settings { MailSettings(store: store) } } } diff --git a/MikeMail/PasswordRecoveryView.swift b/MikeMail/PasswordRecoveryView.swift index 2be5d86..9d11102 100644 --- a/MikeMail/PasswordRecoveryView.swift +++ b/MikeMail/PasswordRecoveryView.swift @@ -5,6 +5,7 @@ struct PasswordRecoveryView: View { let account: MailAccount @State private var password = "" @State private var checking = false + @State private var showsPassword = false @State private var error: String? @FocusState private var passwordFocused: Bool @@ -12,9 +13,23 @@ struct PasswordRecoveryView: View { VStack(alignment: .leading, spacing: 16) { Text("Passwort erforderlich").font(.title2).bold() Text("Der Mailserver hat die Anmeldung für \(account.email) abgelehnt.") - SecureField("Neues Passwort", text: $password) - .textContentType(.password) - .focused($passwordFocused) + HStack(spacing: 8) { + if showsPassword { + TextField("Neues Passwort", text: $password) + .textContentType(.password) + .focused($passwordFocused) + } else { + SecureField("Neues Passwort", text: $password) + .textContentType(.password) + .focused($passwordFocused) + } + Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen", + systemImage: showsPassword ? "eye.slash" : "eye") { + showsPassword.toggle() + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + } Text("Das Passwort wird erst nach erfolgreicher Anmeldung im Schlüsselbund gespeichert.") .font(.caption).foregroundStyle(.secondary) if let error { Text(error).foregroundStyle(.red) } diff --git a/MikeMail/SettingsBackup.swift b/MikeMail/SettingsBackup.swift new file mode 100644 index 0000000..1124050 --- /dev/null +++ b/MikeMail/SettingsBackup.swift @@ -0,0 +1,125 @@ +import Foundation + +struct SettingsBackup: Codable { + let formatVersion: Int + let createdAt: Date + let preferences: Data + let accounts: [MailAccount] + let passwords: [UUID: String]? +} + +enum SettingsBackupManager { + private static let formatVersion = 1 + private static let pendingRestoreKey = "MikeMail.PendingSettingsRestore" + static let restoreResultKey = "MikeMail.SettingsRestoreResult" + + static func create(accounts: [MailAccount], includePasswords: Bool) throws -> (data: Data, missingPasswords: Int) { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + throw MailError.message("Die App-Einstellungen konnten nicht ermittelt werden.") + } + + var preferences = UserDefaults.standard.persistentDomain(forName: bundleIdentifier) ?? [:] + preferences[pendingRestoreKey] = nil + preferences[restoreResultKey] = nil + let preferencesData = try PropertyListSerialization.data( + fromPropertyList: preferences, + format: .binary, + options: 0 + ) + + var passwords: [UUID: String]? + var missingPasswords = 0 + if includePasswords { + var storedPasswords: [UUID: String] = [:] + for account in accounts { + do { + storedPasswords[account.id] = try PasswordStore.password(for: account.id) + } catch { + missingPasswords += 1 + } + } + passwords = storedPasswords + } + + let backup = SettingsBackup( + formatVersion: formatVersion, + createdAt: Date(), + preferences: preferencesData, + accounts: accounts, + passwords: passwords + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return (try encoder.encode(backup), missingPasswords) + } + + static func validate(_ data: Data) throws -> SettingsBackup { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let backup: SettingsBackup + do { + backup = try decoder.decode(SettingsBackup.self, from: data) + } catch { + throw MailError.message("Die ausgewählte Datei ist kein gültiges MikeMail-Einstellungsbackup.") + } + guard backup.formatVersion == formatVersion else { + throw MailError.message("Diese Backup-Version wird nicht unterstützt.") + } + guard (try PropertyListSerialization.propertyList(from: backup.preferences, options: [], format: nil)) is [String: Any] else { + throw MailError.message("Das Backup enthält keine gültigen App-Einstellungen.") + } + return backup + } + + static func stageRestore(_ data: Data) throws { + _ = try validate(data) + UserDefaults.standard.set(data, forKey: pendingRestoreKey) + } + + static func applyPendingRestoreIfNeeded() { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: pendingRestoreKey) else { return } + + do { + let backup = try validate(data) + guard let bundleIdentifier = Bundle.main.bundleIdentifier, + var preferences = try PropertyListSerialization.propertyList( + from: backup.preferences, + options: [], + format: nil + ) as? [String: Any] else { + throw MailError.message("Die App-Einstellungen im Backup sind ungültig.") + } + + preferences[pendingRestoreKey] = nil + preferences[restoreResultKey] = nil + defaults.setPersistentDomain(preferences, forName: bundleIdentifier) + + let archive = try MailArchive() + let current = try archive.configuration() + let restoredAccountIDs = Set(backup.accounts.map(\.id)) + let retainedFolders = (current?.folders ?? [:]).filter { restoredAccountIDs.contains($0.key) } + try archive.saveConfiguration(ArchiveConfiguration( + accounts: backup.accounts, + folders: retainedFolders, + recipients: current?.recipients ?? [] + )) + + for (accountID, password) in backup.passwords ?? [:] { + try PasswordStore.save(password, for: accountID) + } + + defaults.set( + "Einstellungen und \(backup.accounts.count) Konten wurden wiederhergestellt.", + forKey: restoreResultKey + ) + } catch { + defaults.removeObject(forKey: pendingRestoreKey) + defaults.set( + "Wiederherstellung fehlgeschlagen: \(error.localizedDescription)", + forKey: restoreResultKey + ) + } + } +}