diff --git a/MailNotifications.swift b/MailNotifications.swift new file mode 100644 index 0000000..c7ca10c --- /dev/null +++ b/MailNotifications.swift @@ -0,0 +1,70 @@ +import AppKit +import UserNotifications + +final class MailNotificationDelegate: NSObject, UNUserNotificationCenterDelegate { + static let shared = MailNotificationDelegate() + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound]) + } +} + +struct NewMailNotification: Sendable { + let sender: String + let subject: String +} + +@MainActor +enum MailNotifications { + static let enabledKey = "newMailNotifications" + + static func configure() { + UNUserNotificationCenter.current().delegate = MailNotificationDelegate.shared + } + + static func requestAuthorization() async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional: + return true + case .notDetermined: + return (try? await center.requestAuthorization(options: [.alert, .sound])) == true + default: + return false + } + } + + static func postNewMail(_ messages: [NewMailNotification], badgeCount: Int) async { + guard !messages.isEmpty, UserDefaults.standard.bool(forKey: enabledKey) else { return } + guard await requestAuthorization() else { return } + + for message in messages { + let content = UNMutableNotificationContent() + content.title = String(localized: "Neue Mail") + content.subtitle = message.sender.isEmpty + ? String(localized: "Unbekannter Absender") + : message.sender + content.body = message.subject.isEmpty + ? String(localized: "(Ohne Betreff)") + : message.subject + content.sound = .default + content.badge = NSNumber(value: badgeCount) + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + try? await UNUserNotificationCenter.current().add(request) + } + } + + static func updateBadge(_ count: Int) { + NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil + } +} diff --git a/MikeMail.xcodeproj/project.xcproj b/MikeMail.xcodeproj/project.xcproj index 12ab648..85d784e 100644 --- a/MikeMail.xcodeproj/project.xcproj +++ b/MikeMail.xcodeproj/project.xcproj @@ -15,6 +15,7 @@ { "kind": "folder", "path": "MikeMail", "target-membership": [ "MikeMail" ] }, { "kind": "folder", "path": "MikeMailTests", "target-membership": [ "MikeMailTests" ] }, { "kind": "folder", "path": "MikeMailUITests", "target-membership": [ "MikeMailUITests" ] }, + { "path": "MailNotifications.swift", "target-membership": [ "MikeMail/compile-sources" ] }, { "kind": "group", "name": "Products", @@ -45,8 +46,17 @@ "DEVELOPMENT_TEAM": "GQR972L9NJ", "ENABLE_APP_SANDBOX": "YES", "ENABLE_HARDENED_RUNTIME": "YES", + "ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO", "ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES", "ENABLE_PREVIEWS": "YES", + "ENABLE_RESOURCE_ACCESS_AUDIO_INPUT": "NO", + "ENABLE_RESOURCE_ACCESS_BLUETOOTH": "NO", + "ENABLE_RESOURCE_ACCESS_CALENDARS": "NO", + "ENABLE_RESOURCE_ACCESS_CAMERA": "NO", + "ENABLE_RESOURCE_ACCESS_CONTACTS": "NO", + "ENABLE_RESOURCE_ACCESS_LOCATION": "NO", + "ENABLE_RESOURCE_ACCESS_PRINTING": "YES", + "ENABLE_RESOURCE_ACCESS_USB": "NO", "ENABLE_USER_SELECTED_FILES": "readwrite", "GENERATE_INFOPLIST_FILE": "YES", "INFOPLIST_KEY_NSHumanReadableCopyright": "", diff --git a/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate b/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate index 3c669f1..6038d75 100644 Binary files a/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate and b/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/MikeMail/AccountOrderDrop.swift b/MikeMail/AccountOrderDrop.swift index 8e12736..330f480 100644 --- a/MikeMail/AccountOrderDrop.swift +++ b/MikeMail/AccountOrderDrop.swift @@ -157,7 +157,10 @@ struct MailSidebarList: NSViewRepresentable { stack.addArrangedSubview(spacer) } let icon = NSImageView(image: NSImage(systemSymbolName: entry.icon, accessibilityDescription: nil) ?? NSImage()) - icon.contentTintColor = .controlAccentColor + let isTrash = entry.id == "unified:trash" || entry.folder?.isTrash == true + icon.contentTintColor = isTrash + ? (NSColor.systemRed.blended(withFraction: 0.35, of: .black) ?? .systemRed) + : .controlAccentColor icon.widthAnchor.constraint(equalToConstant: 18).isActive = true stack.addArrangedSubview(icon) let texts = NSStackView() @@ -178,12 +181,7 @@ struct MailSidebarList: NSViewRepresentable { } texts.setContentHuggingPriority(.defaultLow, for: .horizontal) stack.addArrangedSubview(texts) - if entry.account != nil { - let handle = NSImageView(image: NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: "Konto verschieben") ?? NSImage()) - handle.widthAnchor.constraint(equalToConstant: 18).isActive = true - handle.toolTip = "Kontenzeile an die gewünschte Position ziehen" - stack.addArrangedSubview(handle) - } else { + if entry.account == nil { let count = parent.store.unreadCount(folderID: entry.id) if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) } } diff --git a/MikeMail/AccountsView.swift b/MikeMail/AccountsView.swift index d1c09c8..57f21c8 100644 --- a/MikeMail/AccountsView.swift +++ b/MikeMail/AccountsView.swift @@ -211,6 +211,10 @@ struct AccountSettingsForm: View { var body: some View { Form { TextField("Name", text: $account.name) + TextField("Anzeigename", text: Binding( + get: { account.displayName ?? "" }, + set: { account.displayName = $0 } + )) TextField("E-Mail-Adresse", text: $account.email).accessibilityIdentifier("accountEmail") TextField("Benutzername", text: $account.username) TextField("Server", text: $account.host) diff --git a/MikeMail/ContentView.swift b/MikeMail/ContentView.swift index 662d1ad..438af2d 100644 --- a/MikeMail/ContentView.swift +++ b/MikeMail/ContentView.swift @@ -41,8 +41,6 @@ struct ContentView: View { @State private var selectedMessages: Set = [] @State private var accountPresentation = AccountPresentation() @Environment(\.openWindow) private var openWindow - @AppStorage("idleEnabled") private var idleEnabled = true - @AppStorage("pollMinutes") private var pollMinutes = 5 @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always @AppStorage("markReadImmediately") private var markReadImmediately = false @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 @@ -107,12 +105,10 @@ struct ContentView: View { .safeAreaInset(edge: .bottom) { HStack { if store.isBusy { ProgressView().controlSize(.small) } - if idleEnabled { - Image(systemName: "bolt.horizontal.circle") - .foregroundStyle(store.idleStatuses.values.contains("Sofortempfang aktiv") ? .green : .secondary) - .help(store.accounts.map { "\($0.email): \(store.idleStatuses[$0.id] ?? "Verbindung wird vorbereitet")" }.joined(separator: "\n")) - .accessibilityLabel("Sofortempfang") - } + Image(systemName: "bolt.horizontal.circle") + .foregroundStyle(store.idleStatuses.values.contains("Sofortempfang aktiv") ? .green : .secondary) + .help(store.accounts.map { "\($0.email): \(store.idleStatuses[$0.id] ?? "Verbindung wird vorbereitet")" }.joined(separator: "\n")) + .accessibilityLabel("Sofortempfang") Text(store.status).font(.caption).foregroundStyle(.secondary) Spacer() Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary) @@ -142,7 +138,6 @@ struct ContentView: View { .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty) Button("Löschen", systemImage: "trash") { deleteSelection() } .disabled(selectedMessages.isEmpty) - SettingsLink { Label("Einstellungen", systemImage: "gearshape") } } } .focusedSceneValue(\.accountPresentation, accountPresentation) @@ -155,10 +150,12 @@ struct ContentView: View { })) { account in PasswordRecoveryView(store: store, account: account) } - .task(id: IdleConfiguration(enabled: idleEnabled, accounts: store.accounts)) { - store.configureIdle(enabled: idleEnabled) + .task(id: IdleConfiguration(accounts: store.accounts)) { + store.configureIdle() + } + .task(id: store.totalUnreadCount) { + MailNotifications.updateBadge(store.totalUnreadCount) } - .task(id: pollMinutes) { await store.poll(intervalMinutes: pollMinutes) } .task(id: AutoReadRequest(ids: selectedMessages, immediately: markReadImmediately, seconds: markReadDelaySeconds)) { guard selectedMessages.count == 1, let id = selectedMessages.first, let message = store.message(id: id), !message.isRead else { return } @@ -212,7 +209,13 @@ struct ContentView: View { guard !ids.isEmpty else { return } switch command { case .open: - if ids.count == 1, let id = ids.first { openWindow(id: "message", value: id) } + if ids.count == 1, let id = ids.first, let message = store.message(id: id) { + if selectedFolder.flatMap({ store.folder(id: $0) })?.isDrafts == true { + openComposer(.saved(message, accountID: store.accountID(for: id))) + } else { + openWindow(id: "message", value: id) + } + } case .reply, .forward: if ids.count == 1, let id = ids.first, let message = store.message(id: id) { openComposer(.from(message, accountID: store.accountID(for: id), forwarding: command == .forward)) @@ -465,6 +468,33 @@ struct MessageReader: View { } } +enum AttachmentMentionDetector { + static let settingKey = "warnMissingAttachment" + + private static let phrases = [ + "im anhang", + "in der anlage", + "als anhang", + "siehe anhang", + "siehe anlage", + "anbei", + "beigefügt", + "beigefuegt", + "angehängt", + "angehaengt", + "attachment", + "attached", + "enclosed" + ] + + static func mentionsAttachment(in text: String) -> Bool { + let normalized = text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + return phrases.contains { phrase in + normalized.contains(phrase.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)) + } + } +} + struct ComposeView: View { let store: MailStore var draft: ComposeDraft? = nil @@ -477,6 +507,12 @@ struct ComposeView: View { @State private var subject = "" @State private var editor = MailEditorModel() @State private var htmlEnabled = true + @State private var confirmingPlainText = false + @State private var confirmingMissingAttachment = false + @State private var confirmingClose = false + @State private var closeApproved = false + @State private var showAdditionalRecipients = false + @AppStorage(AttachmentMentionDetector.settingKey) private var warnMissingAttachment = true @AppStorage("bodyFontName") private var bodyFontName = "Helvetica" @AppStorage("bodyFontSize") private var bodyFontSize = 14.0 @State private var attachments: [MailAttachment] = [] @@ -491,21 +527,49 @@ struct ComposeView: View { Text("Neue Nachricht").font(.title2).bold() Form { Picker("Von", selection: $accountID) { - ForEach(store.accounts) { account in Text(account.email).tag(Optional(account.id)) } + ForEach(store.accounts) { account in + Text(account.senderDisplay).tag(Optional(account.id)) + } } RecipientField(recipient: $recipient, history: store.recipientHistory) - TextField("CC", text: $cc).accessibilityIdentifier("composeCC") - TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC") + Toggle("CC/BCC aktivieren", isOn: $showAdditionalRecipients) + .toggleStyle(.checkbox) + if showAdditionalRecipients { + TextField("CC (mehrere mit Semikolon trennen)", text: $cc) + .accessibilityIdentifier("composeCC") + TextField("BCC (mehrere mit Semikolon trennen)", text: $bcc) + .accessibilityIdentifier("composeBCC") + } TextField("Betreff", text: $subject) + .padding(.top, 8) } - Toggle("HTML-Nachricht", isOn: $htmlEnabled) + Toggle("HTML-Nachricht", isOn: Binding( + get: { htmlEnabled }, + set: { enabled in + if enabled { + htmlEnabled = true + } else if editor.hasRichTextFormatting { + confirmingPlainText = true + } else { + htmlEnabled = false + } + } + )) if htmlEnabled { MailFormattingBar(editor: editor) } - MailBodyEditor(model: editor, initialText: draft?.body ?? "", + MailBodyEditor(model: editor, initialText: draft?.body ?? "", initialHTML: draft?.htmlBody, fontName: bodyFontName, fontSize: bodyFontSize, focusOnOpen: draft?.isReply == true, richText: htmlEnabled) .frame(minHeight: 180, maxHeight: .infinity).border(.quaternary) VStack(alignment: .leading, spacing: 8) { - Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline) + Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip") + .font(.body) + .fontWeight(.regular) + .foregroundStyle(Color.blue.opacity(0.8)) + .frame( + maxWidth: .infinity, + minHeight: attachments.isEmpty ? 68 : nil, + alignment: .center + ) ScrollView { ForEach(attachments) { attachment in HStack { @@ -516,12 +580,18 @@ struct ComposeView: View { .labelStyle(.iconOnly) } } - }.frame(height: attachments.isEmpty ? 16 : 80) + }.frame(height: attachments.isEmpty ? 0 : 80) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor.opacity(dropTargeted ? 0.18 : 0.06), in: RoundedRectangle(cornerRadius: 10)) - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(dropTargeted ? Color.accentColor : Color.secondary.opacity(0.3))) + .background(Color.blue.opacity(dropTargeted ? 0.12 : 0.04), in: RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + dropTargeted ? Color.blue.opacity(0.9) : Color.blue.opacity(0.35), + style: StrokeStyle(lineWidth: 1.5, dash: [7, 5]) + ) + } .dropDestination(for: URL.self) { urls, _ in guard !sending, urls.allSatisfy(\.isFileURL) else { return false } addAttachments(urls) @@ -529,21 +599,17 @@ struct ComposeView: View { } isTargeted: { dropTargeted = $0 } if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) } HStack { - Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Abbrechen") { requestClose() }.keyboardShortcut(.cancelAction) Button("Anhang hinzufügen", systemImage: "paperclip") { importing = true } Spacer() if sending { ProgressView().controlSize(.small) } Button("Senden", systemImage: "paperplane") { - sending = true - error = nil - Task { - do { - try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc, - subject: subject, body: editor.plainText, attachments: attachments, - htmlBody: htmlEnabled ? try editor.html() : nil) - dismiss() - } catch { self.error = error.localizedDescription } - sending = false + let text = subject + "\n" + editor.plainText + if warnMissingAttachment && attachments.isEmpty + && AttachmentMentionDetector.mentionsAttachment(in: text) { + confirmingMissingAttachment = true + } else { + sendMessage() } } .buttonStyle(.borderedProminent) @@ -551,6 +617,13 @@ struct ComposeView: View { } } .padding(24) + .background { + WindowCloseGuard( + shouldClose: { closeApproved || (!hasComposedContent && !sending) }, + onCloseAttempt: { if !sending { requestClose() } } + ) + .frame(width: 0, height: 0) + } .frame(minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 600, idealHeight: 780, maxHeight: .infinity) .disabled(sending) .interactiveDismissDisabled(sending) @@ -559,13 +632,101 @@ struct ComposeView: View { initialized = true accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id recipient = draft?.recipient ?? "" + cc = draft?.cc ?? "" + bcc = draft?.bcc ?? "" subject = draft?.subject ?? "" + htmlEnabled = draft?.htmlBody != nil || draft == nil + showAdditionalRecipients = !cc.isEmpty || !bcc.isEmpty attachments = draft?.attachments ?? [] } .fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in do { addAttachments(try result.get()) } catch { self.error = error.localizedDescription } } + .alert("HTML-Formatierung entfernen?", isPresented: $confirmingPlainText) { + Button("Abbrechen", role: .cancel) {} + Button("Formatierungen entfernen", role: .destructive) { + editor.removeRichTextFormatting() + htmlEnabled = false + } + } message: { + Text("Beim Deaktivieren der HTML-Nachricht gehen alle Textformatierungen verloren.") + } + .alert("Nachricht schließen?", isPresented: $confirmingClose) { + Button("Zurück zur Mail", role: .cancel) {} + Button("Als Entwurf speichern") { saveDraftAndClose() } + Button("Löschen", role: .destructive) { + closeApproved = true + DispatchQueue.main.async { dismiss() } + } + } message: { + Text("Möchtest du die verfasste Nachricht löschen oder als Entwurf speichern?") + } + .alert("Anhang vergessen?", isPresented: $confirmingMissingAttachment) { + Button("Abbrechen", role: .cancel) {} + Button("Trotzdem senden") { sendMessage() } + } message: { + Text("Der Nachrichtentext deutet auf einen Anhang hin, es wurde aber keine Datei angefügt.") + } + } + + private var hasComposedContent: Bool { + !recipient.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !cc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !bcc.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !editor.plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !attachments.isEmpty + } + + private func requestClose() { + if hasComposedContent { + confirmingClose = true + } else { + closeApproved = true + DispatchQueue.main.async { dismiss() } + } + } + + private func saveDraftAndClose() { + sending = true + error = nil + Task { + do { + try await store.saveDraft( + accountID: accountID, + recipients: recipient, + cc: cc, + bcc: bcc, + subject: subject, + body: editor.plainText, + attachments: attachments, + htmlBody: htmlEnabled ? try editor.html() : nil + ) + closeApproved = true + DispatchQueue.main.async { dismiss() } + } catch { + self.error = error.localizedDescription + } + sending = false + } + } + + private func sendMessage() { + sending = true + error = nil + Task { + do { + try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc, + subject: subject, body: editor.plainText, attachments: attachments, + htmlBody: htmlEnabled ? try editor.html() : nil) + closeApproved = true + DispatchQueue.main.async { dismiss() } + } catch { + self.error = error.localizedDescription + } + sending = false + } } private func addAttachments(_ urls: [URL]) { diff --git a/MikeMail/Localizable.xcstrings b/MikeMail/Localizable.xcstrings index 9016636..1a2063b 100644 --- a/MikeMail/Localizable.xcstrings +++ b/MikeMail/Localizable.xcstrings @@ -23,6 +23,9 @@ } } } + }, + "(Ohne Betreff)" : { + }, "%@" : { "localizations" : { @@ -224,6 +227,7 @@ } }, "Alle %lld Minuten" : { + "extractionState" : "stale", "localizations" : { "de" : { "variations" : { @@ -314,6 +318,9 @@ } } } + }, + "Als Entwurf speichern" : { + }, "Als gelesen markieren" : { "localizations" : { @@ -336,6 +343,7 @@ } }, "An (mehrere mit Komma trennen)" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -344,6 +352,9 @@ } } } + }, + "An (mehrere mit Semikolon trennen)" : { + }, "An: %@" : { "localizations" : { @@ -384,6 +395,9 @@ } } } + }, + "Anhang vergessen?" : { + }, "Anhänge – Dateien hier ablegen" : { "localizations" : { @@ -404,8 +418,12 @@ } } } + }, + "Anzeigename" : { + }, "Auf neue Mails prüfen" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -426,6 +444,7 @@ } }, "BCC" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -434,6 +453,15 @@ } } } + }, + "BCC (mehrere mit Semikolon trennen)" : { + + }, + "Bei neuen Mails benachrichtigen" : { + + }, + "Beim Deaktivieren der HTML-Nachricht gehen alle Textformatierungen verloren." : { + }, "Beim Löschen von Mails nachfragen" : { "localizations" : { @@ -479,6 +507,7 @@ } }, "CC" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -487,6 +516,9 @@ } } } + }, + "CC (mehrere mit Semikolon trennen)" : { + }, "CC: %@" : { "localizations" : { @@ -497,6 +529,9 @@ } } } + }, + "CC/BCC aktivieren" : { + }, "Darstellung" : { @@ -556,6 +591,9 @@ } } } + }, + "Der Nachrichtentext deutet auf einen Anhang hin, es wurde aber keine Datei angefügt." : { + }, "Der Ordner wird auf dem Mailserver erstellt." : { "localizations" : { @@ -569,6 +607,9 @@ }, "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 Anzahl ungelesener Mails wird immer am App-Symbol angezeigt." : { + }, "Die ausgewählten Nachrichten werden auch auf dem Server in den Papierkorb verschoben." : { "localizations" : { @@ -619,6 +660,9 @@ } } } + }, + "Drucken …" : { + }, "Durchgestrichen" : { "localizations" : { @@ -671,6 +715,7 @@ } }, "Einstellungen" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -751,6 +796,9 @@ } } } + }, + "Formatierungen entfernen" : { + }, "Füge ein Konto hinzu oder wähle links ein Konto aus." : { "localizations" : { @@ -791,6 +839,9 @@ } } } + }, + "HTML-Formatierung entfernen?" : { + }, "HTML-Nachricht" : { "localizations" : { @@ -1011,6 +1062,12 @@ } } } + }, + "Mitteilungen" : { + + }, + "Möchtest du die verfasste Nachricht löschen oder als Entwurf speichern?" : { + }, "Nach %lld Sekunden" : { "localizations" : { @@ -1113,6 +1170,9 @@ } } } + }, + "Nachricht schließen?" : { + }, "Nachrichten durchsuchen" : { "localizations" : { @@ -1156,8 +1216,12 @@ }, "Neue Konten" : { + }, + "Neue Mail" : { + }, "Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1238,6 +1302,7 @@ } }, "Nur manuell" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1369,6 +1434,15 @@ } } } + }, + "Prüft Betreff und Nachrichtentext vor dem Senden auf Hinweise wie „im Anhang“, „anbei“ oder „beigefügt“." : { + + }, + "Rechtschreib- und Grammatikprüfung" : { + + }, + "Rechtschreibprüfung" : { + }, "Regulärer Ausdruck" : { "localizations" : { @@ -1487,6 +1561,7 @@ } }, "Sofortempfang mit IMAP IDLE" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1545,6 +1620,12 @@ } } } + }, + "Trotzdem senden" : { + + }, + "Unbekannter Absender" : { + }, "Unterstrichen" : { "localizations" : { @@ -1565,6 +1646,9 @@ } } } + }, + "Verfassen" : { + }, "Verhalten" : { @@ -1588,6 +1672,9 @@ } } } + }, + "Vor möglicherweise vergessenem Anhang warnen" : { + }, "Vorschau Mailtext" : { "localizations" : { @@ -1629,6 +1716,9 @@ } } } + }, + "Zurück zur Mail" : { + } }, "version" : "1.3" diff --git a/MikeMail/MIME.swift b/MikeMail/MIME.swift index e992d4d..559fa61 100644 --- a/MikeMail/MIME.swift +++ b/MikeMail/MIME.swift @@ -94,8 +94,8 @@ enum MIME { return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""), recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"), date: date ?? .distantPast, body: bodies.isEmpty ? plainText(htmlBodies.joined(separator: "\n")) : bodies.joined(separator: "\n"), - attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? ""), - htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n")) + attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), bcc: decodeHeader(fields["bcc"] ?? ""), + replyTo: decodeHeader(fields["reply-to"] ?? ""), htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n")) } private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) { @@ -151,8 +151,10 @@ enum MIME { value.range(of: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", options: .regularExpression) != nil } - static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil) throws -> Data { - guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress), + static func compose(from: String, senderName: String? = nil, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil, allowEmptyRecipients: Bool = false, includeBccHeader: Bool = false) throws -> Data { + let trimmedSenderName = senderName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard validAddress(from), (allowEmptyRecipients || !(to + cc + bcc).isEmpty), (to + cc + bcc).allSatisfy(validAddress), + !trimmedSenderName.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }), !subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else { throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.") } @@ -163,8 +165,12 @@ enum MIME { func base64(_ data: Data) -> String { data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed]) } - var result = "From: \(from)\r\nTo: \(to.joined(separator: ", "))\r\n" + let fromHeader = trimmedSenderName.isEmpty + ? from + : "=?UTF-8?B?\(Data(trimmedSenderName.utf8).base64EncodedString())?= <\(from)>" + var result = "From: \(fromHeader)\r\nTo: \(to.joined(separator: ", "))\r\n" if !cc.isEmpty { result += "Cc: \(cc.joined(separator: ", "))\r\n" } + if includeBccHeader && !bcc.isEmpty { result += "Bcc: \(bcc.joined(separator: ", "))\r\n" } result += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n" result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n" result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n" diff --git a/MikeMail/MailBodyEditor.swift b/MikeMail/MailBodyEditor.swift index d390465..722d0b9 100644 --- a/MikeMail/MailBodyEditor.swift +++ b/MikeMail/MailBodyEditor.swift @@ -12,6 +12,7 @@ final class MailEditorModel { var italic = false var underline = false var strikethrough = false + var spellCheckingEnabled = true var plainText: String { textView?.string ?? "" } @@ -87,6 +88,52 @@ final class MailEditorModel { return result } } + + func setSpellCheckingEnabled(_ enabled: Bool) { + spellCheckingEnabled = enabled + textView?.isContinuousSpellCheckingEnabled = enabled + textView?.isGrammarCheckingEnabled = enabled + } + + var hasRichTextFormatting: Bool { + guard let view = textView else { return false } + let text = view.attributedString() + guard text.length > 0 else { return false } + let baseFont = view.font ?? NSFont.systemFont(ofSize: 14) + var found = false + text.enumerateAttributes(in: NSRange(location: 0, length: text.length)) { attributes, _, stop in + let font = attributes[.font] as? NSFont ?? baseFont + let traits = NSFontManager.shared.traits(of: font) + let hasFontFormatting = font.familyName != baseFont.familyName + || font.pointSize != baseFont.pointSize + || traits.contains(.boldFontMask) + || traits.contains(.italicFontMask) + let hasDecoration = (attributes[.underlineStyle] as? Int ?? 0) != 0 + || (attributes[.strikethroughStyle] as? Int ?? 0) != 0 + let hasColor = (attributes[.foregroundColor] as? NSColor)?.isEqual(NSColor.textColor) == false + || attributes[.backgroundColor] != nil + if hasFontFormatting || hasDecoration || hasColor || attributes[.link] != nil { + found = true + stop.pointee = true + } + } + return found + } + + func removeRichTextFormatting() { + guard let view = textView, let storage = view.textStorage else { return } + let range = NSRange(location: 0, length: storage.length) + storage.setAttributes([ + .font: view.font ?? NSFont.systemFont(ofSize: 14), + .foregroundColor: NSColor.textColor + ], range: range) + view.typingAttributes = [ + .font: view.font ?? NSFont.systemFont(ofSize: 14), + .foregroundColor: NSColor.textColor + ] + view.didChangeText() + refreshSelection() + } } struct MailFormattingBar: View { @@ -118,6 +165,14 @@ struct MailFormattingBar: View { Button { editor.setAttribute(.strikethroughStyle, value: editor.strikethrough ? 0 : NSUnderlineStyle.single.rawValue) } label: { Image(systemName: "strikethrough").foregroundStyle(editor.strikethrough ? Color.accentColor : .primary) }.help("Durchgestrichen") + Divider() + .frame(height: 20) + Toggle("Rechtschreibprüfung", isOn: Binding( + get: { editor.spellCheckingEnabled }, + set: { editor.setSpellCheckingEnabled($0) } + )) + .toggleStyle(.switch) + .help("Rechtschreib- und Grammatikprüfung") Spacer(minLength: 0) } .padding(8).background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) @@ -127,6 +182,7 @@ struct MailFormattingBar: View { struct MailBodyEditor: NSViewRepresentable { let model: MailEditorModel let initialText: String + let initialHTML: String? let fontName: String let fontSize: Double let focusOnOpen: Bool @@ -141,9 +197,23 @@ struct MailBodyEditor: NSViewRepresentable { view.importsGraphics = false view.allowsUndo = true view.isAutomaticQuoteSubstitutionEnabled = false + view.isContinuousSpellCheckingEnabled = model.spellCheckingEnabled + view.isGrammarCheckingEnabled = model.spellCheckingEnabled view.textContainerInset = NSSize(width: 8, height: 8) view.font = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize) - view.string = initialText + if let initialHTML, + let attributedText = try? NSAttributedString( + data: Data(initialHTML.utf8), + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) { + view.textStorage?.setAttributedString(attributedText) + } else { + view.string = initialText + } view.setSelectedRange(NSRange(location: 0, length: 0)) view.setAccessibilityIdentifier("composeBody") view.delegate = context.coordinator @@ -158,6 +228,8 @@ struct MailBodyEditor: NSViewRepresentable { func updateNSView(_ scroll: NSScrollView, context: Context) { guard let view = scroll.documentView as? NSTextView else { return } view.isRichText = richText + view.isContinuousSpellCheckingEnabled = model.spellCheckingEnabled + view.isGrammarCheckingEnabled = model.spellCheckingEnabled } final class Coordinator: NSObject, NSTextViewDelegate { diff --git a/MikeMail/MailIdleMonitor.swift b/MikeMail/MailIdleMonitor.swift index 980a4ab..01a3586 100644 --- a/MikeMail/MailIdleMonitor.swift +++ b/MikeMail/MailIdleMonitor.swift @@ -102,6 +102,5 @@ final class MailIdleMonitor { } struct IdleConfiguration: Equatable { - let enabled: Bool let accounts: [MailAccount] } diff --git a/MikeMail/MailModels.swift b/MikeMail/MailModels.swift index e7abb5d..9d19a05 100644 --- a/MikeMail/MailModels.swift +++ b/MikeMail/MailModels.swift @@ -4,12 +4,18 @@ import Security struct MailAccount: Identifiable, Codable, Hashable, Sendable { var id = UUID() var name = "" + var displayName: String? var email = "" var username = "" var host = "" var smtpPort: UInt16 = 587 var imapPort: UInt16 = 993 + var senderDisplay: String { + let value = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? email : "\(value) <\(email)>" + } + static let testAccount = MailAccount(name: "Testkonto", email: "test@kuebler-sulzbach.de", username: "test@kuebler-sulzbach.de", host: "w0099985.kasserver.com") } @@ -29,14 +35,40 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable { specialUse?.contains("\\trash") == true || ["trash", "papierkorb", "deleted messages", "deleted items"].contains(name.lowercased()) } + var isDrafts: Bool { + specialUse?.contains("\\drafts") == true + || ["drafts", "entwürfe", "entwuerfe"].contains(name.lowercased()) + } + var icon: String { - switch path.lowercased() { - case "inbox": "tray" - case let value where value.contains("sent"): "paperplane" - case let value where value.contains("trash"): "trash" - case let value where value.contains("draft"): "doc" - default: "folder" + if isInbox { return "tray" } + if isTrash { return "trash" } + + let uses = Set((specialUse ?? []).map { $0.lowercased() }) + let value = (path + " " + name).lowercased() + + if uses.contains("\\sent") || value.contains("sent") || value.contains("gesendet") { + return "paperplane" } + if uses.contains("\\drafts") || value.contains("draft") || value.contains("entwurf") { + return "doc.text" + } + if uses.contains("\\junk") || value.contains("junk") || value.contains("spam") { + return "exclamationmark.octagon" + } + if uses.contains("\\archive") || value.contains("archive") || value.contains("archiv") { + return "archivebox" + } + if uses.contains("\\all") || value.contains("all mail") || value.contains("alle nachrichten") { + return "tray.full" + } + if uses.contains("\\flagged") || value.contains("flagged") || value.contains("markiert") { + return "flag" + } + if uses.contains("\\important") || value.contains("important") || value.contains("wichtig") { + return "star" + } + return "folder" } } @@ -58,6 +90,7 @@ struct MailMessage: Identifiable, Sendable { let attachments: [MailAttachment] var isRead = false var cc = "" + var bcc = "" var replyTo = "" var htmlBody: String? = nil var displaySender: String { MailDisplay.sender(sender) } diff --git a/MikeMail/MailPrinter.swift b/MikeMail/MailPrinter.swift new file mode 100644 index 0000000..5094f6b --- /dev/null +++ b/MikeMail/MailPrinter.swift @@ -0,0 +1,94 @@ +import AppKit + +@MainActor +enum MailPrinter { + static func print(_ message: MailMessage) { + guard let attributedMessage = makeAttributedMessage(message) else { + NSSound.beep() + return + } + + let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo.shared + printInfo.horizontalPagination = .fit + printInfo.verticalPagination = .automatic + + let printableWidth = max( + printInfo.paperSize.width - printInfo.leftMargin - printInfo.rightMargin, + 400 + ) + let printableHeight = max( + printInfo.paperSize.height - printInfo.topMargin - printInfo.bottomMargin, + 400 + ) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: printableWidth, height: 1)) + textView.isEditable = false + textView.isSelectable = false + textView.isRichText = true + textView.drawsBackground = false + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.containerSize = NSSize( + width: printableWidth, + height: .greatestFiniteMagnitude + ) + textView.textStorage?.setAttributedString(attributedMessage) + if let textContainer = textView.textContainer { + textView.layoutManager?.ensureLayout(for: textContainer) + let contentHeight = textView.layoutManager?.usedRect(for: textContainer).height ?? 1 + let requiredHeight = max(contentHeight + 24, printableHeight) + let pageCount = ceil(requiredHeight / printableHeight) + textView.frame.size.height = pageCount * printableHeight + } + + let operation = NSPrintOperation(view: textView, printInfo: printInfo) + operation.showsPrintPanel = true + operation.showsProgressPanel = true + operation.run() + } + + private static func makeAttributedMessage(_ message: MailMessage) -> NSAttributedString? { + let body: String + if let htmlBody = message.htmlBody, !htmlBody.isEmpty { + body = htmlBody + } else { + body = "
\(escaped(message.body))
" + } + + let ccRow = message.cc.isEmpty + ? "" + : "CC:\(escaped(message.cc))" + let html = """ + + + + + + + + \(ccRow) + +
Betreff:\(escaped(message.subject))
Von:\(escaped(message.sender))
An:\(escaped(message.recipient))
Datum:\(escaped(message.date.formatted(date: .long, time: .shortened)))
+
+
\(body)
+ + """ + + return try? NSAttributedString( + data: Data(html.utf8), + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) + } + + private static func escaped(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + } +} diff --git a/MikeMail/MailSettings.swift b/MikeMail/MailSettings.swift index dba78e0..1e1b70a 100644 --- a/MikeMail/MailSettings.swift +++ b/MikeMail/MailSettings.swift @@ -38,10 +38,10 @@ struct MailSettings: View { @AppStorage("blockRemoteContent") private var blockRemoteContent = true private let fontNames = NSFontManager.shared.availableFontFamilies.sorted() @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always - @AppStorage("idleEnabled") private var idleEnabled = true - @AppStorage("pollMinutes") private var pollMinutes = 5 @AppStorage("offerTestMail") private var offerTestMail = true @AppStorage("checkOnLaunch") private var checkOnLaunch = true + @AppStorage(AttachmentMentionDetector.settingKey) private var warnMissingAttachment = true + @AppStorage(MailNotifications.enabledKey) private var newMailNotifications = false @AppStorage("showUnifiedInbox") private var showInbox = true @AppStorage("showUnifiedTrash") private var showTrash = true @@ -59,17 +59,17 @@ struct MailSettings: View { } Tab("Abruf", systemImage: "arrow.clockwise") { RetrievalSettingsTab( - idleEnabled: $idleEnabled, - pollMinutes: $pollMinutes, offerTestMail: $offerTestMail, - checkOnLaunch: $checkOnLaunch + checkOnLaunch: $checkOnLaunch, + newMailNotifications: $newMailNotifications ) } Tab("Verhalten", systemImage: "slider.horizontal.3") { BehaviorSettingsTab( markReadImmediately: $markReadImmediately, markReadDelaySeconds: $markReadDelaySeconds, - deleteConfirmation: $deleteConfirmation + deleteConfirmation: $deleteConfirmation, + warnMissingAttachment: $warnMissingAttachment ) } Tab("Sicherung", systemImage: "externaldrive") { @@ -184,27 +184,28 @@ private struct DisplaySettingsTab: View { } private struct RetrievalSettingsTab: View { - @Binding var idleEnabled: Bool - @Binding var pollMinutes: Int @Binding var offerTestMail: Bool @Binding var checkOnLaunch: Bool + @Binding var newMailNotifications: 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) } + Section("Mitteilungen") { + Toggle("Bei neuen Mails benachrichtigen", isOn: $newMailNotifications) + .onChange(of: newMailNotifications) { _, enabled in + guard enabled else { return } + Task { + if await !MailNotifications.requestAuthorization() { + newMailNotifications = false + } + } + } + Text("Die Anzahl ungelesener Mails wird immer am App-Symbol angezeigt.") + .foregroundStyle(.secondary) + } Section("Neue Konten") { Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail) } @@ -218,6 +219,7 @@ private struct BehaviorSettingsTab: View { @Binding var markReadImmediately: Bool @Binding var markReadDelaySeconds: Int @Binding var deleteConfirmation: MailDeleteConfirmation + @Binding var warnMissingAttachment: Bool var body: some View { Form { @@ -230,6 +232,11 @@ private struct BehaviorSettingsTab: View { Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600) } } + Section("Verfassen") { + Toggle("Vor möglicherweise vergessenem Anhang warnen", isOn: $warnMissingAttachment) + Text("Prüft Betreff und Nachrichtentext vor dem Senden auf Hinweise wie „im Anhang“, „anbei“ oder „beigefügt“.") + .foregroundStyle(.secondary) + } Section("Löschen") { Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) { Text("Immer").tag(MailDeleteConfirmation.always) diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift index 6d48588..654c96d 100644 --- a/MikeMail/MailStore.swift +++ b/MikeMail/MailStore.swift @@ -21,7 +21,6 @@ final class MailStore { private var selectedFolderID: String? private var didStart = false private let idleMonitor = MailIdleMonitor() - private var idleEnabled = false private var pendingIdleAccounts: Set = [] private var idleRefreshGeneration = UUID() private var idleRefreshTask: Task? @@ -88,7 +87,7 @@ final class MailStore { validatedAccounts[account.id] = nil validatedPasswords[account.id] = nil status = "Konto lokal gespeichert." - configureIdle(enabled: idleEnabled) + configureIdle() idleMonitor.restart(accountID: account.id) } @@ -100,7 +99,7 @@ final class MailStore { remainingFolders[id] = nil try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory)) accounts = remaining - configureIdle(enabled: idleEnabled) + configureIdle() folders = remainingFolders messages = [] totalMessages = 0 @@ -131,11 +130,19 @@ final class MailStore { items.flatMap { [$0] + allFolders($0.children ?? []) } } + var totalUnreadCount: Int { + accounts + .flatMap { allFolders(folders[$0.id] ?? []) } + .filter(\.isInbox) + .reduce(0) { $0 + (unreadCounts[$1.id] ?? 0) } + } + func refresh(folderID: String?, accountID: UUID? = nil, inboxOnly: Bool = false) async { guard !isBusy else { return } isBusy = true defer { isBusy = false } var failures: [String] = [] + var newInboxMessages: [NewMailNotification] = [] do { let archive = try storage() for account in accounts where accountID == nil || account.id == accountID { @@ -186,6 +193,13 @@ final class MailStore { try Task.checkCancellation() status = "\(folder.name): \(index + 1) von \(missing.count) neuen Mails speichern …" let raw = try await client.rawMessage(uid: uid) + if inboxOnly && folder.isInbox { + let headers = MIME.split(raw).0 + newInboxMessages.append(NewMailNotification( + sender: MailDisplay.sender(MIME.decodeHeader(headers["from"] ?? "")), + subject: MIME.decodeHeader(headers["subject"] ?? "(Ohne Betreff)") + )) + } try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity, uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false) } @@ -201,6 +215,10 @@ final class MailStore { unreadCounts = try archive.unreadCounts() archiveRevision += 1 if let selectedFolderID { try display(folderID: selectedFolderID) } + await MailNotifications.postNewMail( + newInboxMessages, + badgeCount: totalUnreadCount + ) } catch { failures.append(error.localizedDescription) } if failures.isEmpty { status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar." @@ -244,7 +262,7 @@ final class MailStore { let copies = Self.addresses(cc) let blindCopies = Self.addresses(bcc) let envelope = Array(Set(addresses + copies + blindCopies)).sorted() - let data = try MIME.compose(from: account.email, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments, htmlBody: htmlBody) + let data = try MIME.compose(from: account.email, senderName: account.displayName, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments, htmlBody: htmlBody) 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. @@ -266,6 +284,58 @@ final class MailStore { } } + func saveDraft(accountID: UUID?, recipients: String, cc: String, bcc: String, subject: String, + body: String, attachments: [MailAttachment], htmlBody: String?) async throws { + guard let account = accounts.first(where: { $0.id == accountID }) else { + throw MailError.message("Bitte ein Absenderkonto auswählen.") + } + guard !sendingAccountIDs.contains(account.id) else { + throw MailError.message("Für dieses Konto läuft bereits ein Vorgang.") + } + sendingAccountIDs.insert(account.id) + defer { sendingAccountIDs.remove(account.id) } + + let client = try IMAPClient(account: account) + defer { client.close() } + try await client.login(account: account, password: PasswordStore.password(for: account.id)) + + var discovered = try await client.folders(accountID: account.id) + var draftsFolder = allFolders(discovered).first { $0.specialUse?.contains("\\drafts") == true } + ?? allFolders(discovered).first { + ["drafts", "entwürfe", "entwuerfe"].contains($0.name.lowercased()) + } + if draftsFolder == nil { + try await client.createFolder(path: "Drafts") + discovered = try await client.folders(accountID: account.id) + draftsFolder = allFolders(discovered).first { $0.specialUse?.contains("\\drafts") == true } + ?? allFolders(discovered).first { $0.path == "Drafts" } + } + guard let draftsFolder else { + throw MailError.message("Der Entwürfe-Ordner konnte nicht gefunden oder erstellt werden.") + } + + let data = try MIME.compose( + from: account.email, + senderName: account.displayName, + to: Self.addresses(recipients), + cc: Self.addresses(cc), + bcc: Self.addresses(bcc), + subject: subject, + body: body, + attachments: attachments, + htmlBody: htmlBody, + allowEmptyRecipients: true, + includeBccHeader: true + ) + try await client.append(data, path: draftsFolder.path, flags: ["\\Draft"]) + + folders[account.id] = discovered + try persist() + status = "Entwurf gespeichert." + while isBusy { try await Task.sleep(for: .milliseconds(200)) } + await refresh(folderID: selectedFolderID, accountID: account.id) + } + func start(folderID: String?) async { await load(folderID: folderID) guard !didStart else { return } @@ -536,25 +606,9 @@ final class MailStore { } } - func poll(intervalMinutes: Int) async { - guard intervalMinutes > 0 else { return } - while !Task.isCancelled { - do { try await Task.sleep(for: .seconds(max(1, intervalMinutes) * 60)) } - catch { return } - await refresh(folderID: selectedFolderID) - } - } - - func configureIdle(enabled: Bool) { - idleEnabled = enabled - if !enabled { - pendingIdleAccounts.removeAll() - idleRefreshGeneration = UUID() - idleRefreshTask?.cancel() - idleRefreshTask = nil - idleStatuses = [:] - } else { idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } } } - idleMonitor.configure(accounts: accounts, enabled: enabled) { [weak self] id in + func configureIdle() { + idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } } + idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id in self?.scheduleIdleRefresh(accountID: id) } report: { [weak self] id, status in self?.idleStatuses[id] = status @@ -564,7 +618,7 @@ final class MailStore { } private func scheduleIdleRefresh(accountID: UUID) { - guard idleEnabled, accounts.contains(where: { $0.id == accountID }) else { return } + guard accounts.contains(where: { $0.id == accountID }) else { return } pendingIdleAccounts.insert(accountID) guard idleRefreshTask == nil else { return } let generation = UUID() @@ -574,7 +628,7 @@ final class MailStore { defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } } // Coalesce bursts and retain notifications arriving during another synchronization. try? await Task.sleep(for: .milliseconds(400)) - while !Task.isCancelled, self.idleEnabled, !self.pendingIdleAccounts.isEmpty { + while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty { if self.isBusy { do { try await Task.sleep(for: .milliseconds(200)) } catch { return } continue diff --git a/MikeMail/MessageActions.swift b/MikeMail/MessageActions.swift index 1af3f44..abfe5f0 100644 --- a/MikeMail/MessageActions.swift +++ b/MikeMail/MessageActions.swift @@ -8,11 +8,27 @@ struct ComposeDraft: Identifiable { let id = UUID() var accountID: UUID? var recipient = "" + var cc = "" + var bcc = "" var subject = "" var body = "" + var htmlBody: String? var attachments: [MailAttachment] = [] var isReply = false + static func saved(_ message: MailMessage, accountID: UUID?) -> ComposeDraft { + ComposeDraft( + accountID: accountID, + recipient: message.recipient, + cc: message.cc, + bcc: message.bcc, + subject: message.subject, + body: message.body, + htmlBody: message.htmlBody, + attachments: message.attachments + ) + } + static func from(_ message: MailMessage, accountID: UUID?, forwarding: Bool) -> ComposeDraft { let source = message.replyTo.isEmpty ? message.sender : message.replyTo let recipient: String @@ -55,6 +71,12 @@ struct MessageDetailWindow: View { .frame(minWidth: 600, minHeight: 450) .navigationTitle(message?.subject ?? "Nachricht") .toolbar { + Button("Drucken …", systemImage: "printer") { + guard let message else { return } + MailPrinter.print(message) + } + .disabled(message == nil) + MessageActionButtons { command in guard let message else { return } switch command { diff --git a/MikeMail/MikeMailApp.swift b/MikeMail/MikeMailApp.swift index a7a6f6b..c81c657 100644 --- a/MikeMail/MikeMailApp.swift +++ b/MikeMail/MikeMailApp.swift @@ -4,6 +4,9 @@ import SwiftUI struct MikeMailApp: App { @State private var store: MailStore = { SettingsBackupManager.applyPendingRestoreIfNeeded() + UserDefaults.standard.removeObject(forKey: "idleEnabled") + UserDefaults.standard.removeObject(forKey: "pollMinutes") + MailNotifications.configure() #if DEBUG if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() } #endif diff --git a/MikeMail/RecipientField.swift b/MikeMail/RecipientField.swift index db552f9..48a46b8 100644 --- a/MikeMail/RecipientField.swift +++ b/MikeMail/RecipientField.swift @@ -5,7 +5,7 @@ import ContactsUI enum RecipientCompletion { static func matches(in history: [String], input: String) -> [String] { - let parts = input.components(separatedBy: ",") + let parts = input.components(separatedBy: CharacterSet(charactersIn: ",;")) let query = (parts.last ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return [] } let selected = Set(parts.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) @@ -16,11 +16,11 @@ enum RecipientCompletion { } static func inserting(_ address: String, into input: String) -> String { - var parts = input.components(separatedBy: ",").dropLast().map { + var parts = input.components(separatedBy: CharacterSet(charactersIn: ",;")).dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } if !parts.contains(where: { $0.caseInsensitiveCompare(address) == .orderedSame }) { parts.append(address) } - return parts.joined(separator: ", ") + ", " + return parts.joined(separator: "; ") + "; " } } @@ -82,7 +82,7 @@ struct RecipientField: View { recipient = RecipientCompletion.inserting(address, into: recipient) focused = true }.frame(width: 30, height: 26) - TextField("An (mehrere mit Komma trennen)", text: $recipient) + TextField("An (mehrere mit Semikolon trennen)", text: $recipient) .accessibilityIdentifier("recipientField") .focused($focused) .onKeyPress(.downArrow) { diff --git a/MikeMail/WindowCloseGuard.swift b/MikeMail/WindowCloseGuard.swift new file mode 100644 index 0000000..e8ec083 --- /dev/null +++ b/MikeMail/WindowCloseGuard.swift @@ -0,0 +1,80 @@ +import SwiftUI +import AppKit + +/// Intercepts the native close button while preserving SwiftUI's existing window delegate. +struct WindowCloseGuard: NSViewRepresentable { + let shouldClose: () -> Bool + let onCloseAttempt: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(shouldClose: shouldClose, onCloseAttempt: onCloseAttempt) + } + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + context.coordinator.attach(to: view.window) + } + return view + } + + func updateNSView(_ view: NSView, context: Context) { + context.coordinator.shouldClose = shouldClose + context.coordinator.onCloseAttempt = onCloseAttempt + DispatchQueue.main.async { + context.coordinator.attach(to: view.window) + } + } + + static func dismantleNSView(_ view: NSView, coordinator: Coordinator) { + coordinator.detach() + } + + final class Coordinator: NSObject, NSWindowDelegate { + var shouldClose: () -> Bool + var onCloseAttempt: () -> Void + weak var window: NSWindow? + weak var previousDelegate: NSWindowDelegate? + + init(shouldClose: @escaping () -> Bool, onCloseAttempt: @escaping () -> Void) { + self.shouldClose = shouldClose + self.onCloseAttempt = onCloseAttempt + } + + func attach(to window: NSWindow?) { + guard let window else { return } + if self.window === window, window.delegate === self { return } + detach() + self.window = window + previousDelegate = window.delegate + window.delegate = self + } + + func detach() { + if window?.delegate === self { + window?.delegate = previousDelegate + } + window = nil + previousDelegate = nil + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + guard shouldClose() else { + onCloseAttempt() + return false + } + return previousDelegate?.windowShouldClose?(sender) ?? true + } + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || previousDelegate?.responds(to: selector) == true + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + if previousDelegate?.responds(to: selector) == true { + return previousDelegate + } + return super.forwardingTarget(for: selector) + } + } +} diff --git a/MikeMailTests/MailIdleTests.swift b/MikeMailTests/MailIdleTests.swift index 735e2d3..07e543e 100644 --- a/MikeMailTests/MailIdleTests.swift +++ b/MikeMailTests/MailIdleTests.swift @@ -33,7 +33,5 @@ struct MailIdleTests { #expect(store.authenticationAccount == nil) store.requestPassword(accountID: UUID()) #expect(store.authenticationAccount == nil) - store.configureIdle(enabled: false) - #expect(store.idleStatuses.isEmpty) } }