This commit is contained in:
2026-11-08 22:00:47 +01:00
committed by michael300679
parent 01c3c20527
commit bbfbd976ed
19 changed files with 804 additions and 103 deletions
+70
View File
@@ -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
}
}
+10
View File
@@ -15,6 +15,7 @@
{ "kind": "folder", "path": "MikeMail", "target-membership": [ "MikeMail" ] }, { "kind": "folder", "path": "MikeMail", "target-membership": [ "MikeMail" ] },
{ "kind": "folder", "path": "MikeMailTests", "target-membership": [ "MikeMailTests" ] }, { "kind": "folder", "path": "MikeMailTests", "target-membership": [ "MikeMailTests" ] },
{ "kind": "folder", "path": "MikeMailUITests", "target-membership": [ "MikeMailUITests" ] }, { "kind": "folder", "path": "MikeMailUITests", "target-membership": [ "MikeMailUITests" ] },
{ "path": "MailNotifications.swift", "target-membership": [ "MikeMail/compile-sources" ] },
{ {
"kind": "group", "kind": "group",
"name": "Products", "name": "Products",
@@ -45,8 +46,17 @@
"DEVELOPMENT_TEAM": "GQR972L9NJ", "DEVELOPMENT_TEAM": "GQR972L9NJ",
"ENABLE_APP_SANDBOX": "YES", "ENABLE_APP_SANDBOX": "YES",
"ENABLE_HARDENED_RUNTIME": "YES", "ENABLE_HARDENED_RUNTIME": "YES",
"ENABLE_INCOMING_NETWORK_CONNECTIONS": "NO",
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES", "ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
"ENABLE_PREVIEWS": "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", "ENABLE_USER_SELECTED_FILES": "readwrite",
"GENERATE_INFOPLIST_FILE": "YES", "GENERATE_INFOPLIST_FILE": "YES",
"INFOPLIST_KEY_NSHumanReadableCopyright": "", "INFOPLIST_KEY_NSHumanReadableCopyright": "",
+5 -7
View File
@@ -157,7 +157,10 @@ struct MailSidebarList: NSViewRepresentable {
stack.addArrangedSubview(spacer) stack.addArrangedSubview(spacer)
} }
let icon = NSImageView(image: NSImage(systemSymbolName: entry.icon, accessibilityDescription: nil) ?? NSImage()) 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 icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
stack.addArrangedSubview(icon) stack.addArrangedSubview(icon)
let texts = NSStackView() let texts = NSStackView()
@@ -178,12 +181,7 @@ struct MailSidebarList: NSViewRepresentable {
} }
texts.setContentHuggingPriority(.defaultLow, for: .horizontal) texts.setContentHuggingPriority(.defaultLow, for: .horizontal)
stack.addArrangedSubview(texts) stack.addArrangedSubview(texts)
if entry.account != nil { 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 {
let count = parent.store.unreadCount(folderID: entry.id) let count = parent.store.unreadCount(folderID: entry.id)
if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) } if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) }
} }
+4
View File
@@ -211,6 +211,10 @@ struct AccountSettingsForm: View {
var body: some View { var body: some View {
Form { Form {
TextField("Name", text: $account.name) 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("E-Mail-Adresse", text: $account.email).accessibilityIdentifier("accountEmail")
TextField("Benutzername", text: $account.username) TextField("Benutzername", text: $account.username)
TextField("Server", text: $account.host) TextField("Server", text: $account.host)
+194 -33
View File
@@ -41,8 +41,6 @@ struct ContentView: View {
@State private var selectedMessages: Set<String> = [] @State private var selectedMessages: Set<String> = []
@State private var accountPresentation = AccountPresentation() @State private var accountPresentation = AccountPresentation()
@Environment(\.openWindow) private var openWindow @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("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
@AppStorage("markReadImmediately") private var markReadImmediately = false @AppStorage("markReadImmediately") private var markReadImmediately = false
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
@@ -107,12 +105,10 @@ struct ContentView: View {
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
HStack { HStack {
if store.isBusy { ProgressView().controlSize(.small) } if store.isBusy { ProgressView().controlSize(.small) }
if idleEnabled { Image(systemName: "bolt.horizontal.circle")
Image(systemName: "bolt.horizontal.circle") .foregroundStyle(store.idleStatuses.values.contains("Sofortempfang aktiv") ? .green : .secondary)
.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"))
.help(store.accounts.map { "\($0.email): \(store.idleStatuses[$0.id] ?? "Verbindung wird vorbereitet")" }.joined(separator: "\n")) .accessibilityLabel("Sofortempfang")
.accessibilityLabel("Sofortempfang")
}
Text(store.status).font(.caption).foregroundStyle(.secondary) Text(store.status).font(.caption).foregroundStyle(.secondary)
Spacer() Spacer()
Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary) Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
@@ -142,7 +138,6 @@ struct ContentView: View {
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty) .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
Button("Löschen", systemImage: "trash") { deleteSelection() } Button("Löschen", systemImage: "trash") { deleteSelection() }
.disabled(selectedMessages.isEmpty) .disabled(selectedMessages.isEmpty)
SettingsLink { Label("Einstellungen", systemImage: "gearshape") }
} }
} }
.focusedSceneValue(\.accountPresentation, accountPresentation) .focusedSceneValue(\.accountPresentation, accountPresentation)
@@ -155,10 +150,12 @@ struct ContentView: View {
})) { account in })) { account in
PasswordRecoveryView(store: store, account: account) PasswordRecoveryView(store: store, account: account)
} }
.task(id: IdleConfiguration(enabled: idleEnabled, accounts: store.accounts)) { .task(id: IdleConfiguration(accounts: store.accounts)) {
store.configureIdle(enabled: idleEnabled) 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)) { .task(id: AutoReadRequest(ids: selectedMessages, immediately: markReadImmediately, seconds: markReadDelaySeconds)) {
guard selectedMessages.count == 1, let id = selectedMessages.first, guard selectedMessages.count == 1, let id = selectedMessages.first,
let message = store.message(id: id), !message.isRead else { return } let message = store.message(id: id), !message.isRead else { return }
@@ -212,7 +209,13 @@ struct ContentView: View {
guard !ids.isEmpty else { return } guard !ids.isEmpty else { return }
switch command { switch command {
case .open: 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: case .reply, .forward:
if ids.count == 1, let id = ids.first, let message = store.message(id: id) { 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)) 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 { struct ComposeView: View {
let store: MailStore let store: MailStore
var draft: ComposeDraft? = nil var draft: ComposeDraft? = nil
@@ -477,6 +507,12 @@ struct ComposeView: View {
@State private var subject = "" @State private var subject = ""
@State private var editor = MailEditorModel() @State private var editor = MailEditorModel()
@State private var htmlEnabled = true @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("bodyFontName") private var bodyFontName = "Helvetica"
@AppStorage("bodyFontSize") private var bodyFontSize = 14.0 @AppStorage("bodyFontSize") private var bodyFontSize = 14.0
@State private var attachments: [MailAttachment] = [] @State private var attachments: [MailAttachment] = []
@@ -491,21 +527,49 @@ struct ComposeView: View {
Text("Neue Nachricht").font(.title2).bold() Text("Neue Nachricht").font(.title2).bold()
Form { Form {
Picker("Von", selection: $accountID) { 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) RecipientField(recipient: $recipient, history: store.recipientHistory)
TextField("CC", text: $cc).accessibilityIdentifier("composeCC") Toggle("CC/BCC aktivieren", isOn: $showAdditionalRecipients)
TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC") .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) 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) } 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, fontName: bodyFontName, fontSize: bodyFontSize, focusOnOpen: draft?.isReply == true,
richText: htmlEnabled) richText: htmlEnabled)
.frame(minHeight: 180, maxHeight: .infinity).border(.quaternary) .frame(minHeight: 180, maxHeight: .infinity).border(.quaternary)
VStack(alignment: .leading, spacing: 8) { 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 { ScrollView {
ForEach(attachments) { attachment in ForEach(attachments) { attachment in
HStack { HStack {
@@ -516,12 +580,18 @@ struct ComposeView: View {
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
} }
} }
}.frame(height: attachments.isEmpty ? 16 : 80) }.frame(height: attachments.isEmpty ? 0 : 80)
} }
.padding(12) .padding(12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(dropTargeted ? 0.18 : 0.06), in: RoundedRectangle(cornerRadius: 10)) .background(Color.blue.opacity(dropTargeted ? 0.12 : 0.04), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(dropTargeted ? Color.accentColor : Color.secondary.opacity(0.3))) .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 .dropDestination(for: URL.self) { urls, _ in
guard !sending, urls.allSatisfy(\.isFileURL) else { return false } guard !sending, urls.allSatisfy(\.isFileURL) else { return false }
addAttachments(urls) addAttachments(urls)
@@ -529,21 +599,17 @@ struct ComposeView: View {
} isTargeted: { dropTargeted = $0 } } isTargeted: { dropTargeted = $0 }
if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) } if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
HStack { HStack {
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) Button("Abbrechen") { requestClose() }.keyboardShortcut(.cancelAction)
Button("Anhang hinzufügen", systemImage: "paperclip") { importing = true } Button("Anhang hinzufügen", systemImage: "paperclip") { importing = true }
Spacer() Spacer()
if sending { ProgressView().controlSize(.small) } if sending { ProgressView().controlSize(.small) }
Button("Senden", systemImage: "paperplane") { Button("Senden", systemImage: "paperplane") {
sending = true let text = subject + "\n" + editor.plainText
error = nil if warnMissingAttachment && attachments.isEmpty
Task { && AttachmentMentionDetector.mentionsAttachment(in: text) {
do { confirmingMissingAttachment = true
try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc, } else {
subject: subject, body: editor.plainText, attachments: attachments, sendMessage()
htmlBody: htmlEnabled ? try editor.html() : nil)
dismiss()
} catch { self.error = error.localizedDescription }
sending = false
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@@ -551,6 +617,13 @@ struct ComposeView: View {
} }
} }
.padding(24) .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) .frame(minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 600, idealHeight: 780, maxHeight: .infinity)
.disabled(sending) .disabled(sending)
.interactiveDismissDisabled(sending) .interactiveDismissDisabled(sending)
@@ -559,13 +632,101 @@ struct ComposeView: View {
initialized = true initialized = true
accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id
recipient = draft?.recipient ?? "" recipient = draft?.recipient ?? ""
cc = draft?.cc ?? ""
bcc = draft?.bcc ?? ""
subject = draft?.subject ?? "" subject = draft?.subject ?? ""
htmlEnabled = draft?.htmlBody != nil || draft == nil
showAdditionalRecipients = !cc.isEmpty || !bcc.isEmpty
attachments = draft?.attachments ?? [] attachments = draft?.attachments ?? []
} }
.fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in .fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in
do { addAttachments(try result.get()) } do { addAttachments(try result.get()) }
catch { self.error = error.localizedDescription } 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]) { private func addAttachments(_ urls: [URL]) {
+90
View File
@@ -23,6 +23,9 @@
} }
} }
} }
},
"(Ohne Betreff)" : {
}, },
"%@" : { "%@" : {
"localizations" : { "localizations" : {
@@ -224,6 +227,7 @@
} }
}, },
"Alle %lld Minuten" : { "Alle %lld Minuten" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"variations" : { "variations" : {
@@ -314,6 +318,9 @@
} }
} }
} }
},
"Als Entwurf speichern" : {
}, },
"Als gelesen markieren" : { "Als gelesen markieren" : {
"localizations" : { "localizations" : {
@@ -336,6 +343,7 @@
} }
}, },
"An (mehrere mit Komma trennen)" : { "An (mehrere mit Komma trennen)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -344,6 +352,9 @@
} }
} }
} }
},
"An (mehrere mit Semikolon trennen)" : {
}, },
"An: %@" : { "An: %@" : {
"localizations" : { "localizations" : {
@@ -384,6 +395,9 @@
} }
} }
} }
},
"Anhang vergessen?" : {
}, },
"Anhänge – Dateien hier ablegen" : { "Anhänge – Dateien hier ablegen" : {
"localizations" : { "localizations" : {
@@ -404,8 +418,12 @@
} }
} }
} }
},
"Anzeigename" : {
}, },
"Auf neue Mails prüfen" : { "Auf neue Mails prüfen" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -426,6 +444,7 @@
} }
}, },
"BCC" : { "BCC" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "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" : { "Beim Löschen von Mails nachfragen" : {
"localizations" : { "localizations" : {
@@ -479,6 +507,7 @@
} }
}, },
"CC" : { "CC" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -487,6 +516,9 @@
} }
} }
} }
},
"CC (mehrere mit Semikolon trennen)" : {
}, },
"CC: %@" : { "CC: %@" : {
"localizations" : { "localizations" : {
@@ -497,6 +529,9 @@
} }
} }
} }
},
"CC/BCC aktivieren" : {
}, },
"Darstellung" : { "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." : { "Der Ordner wird auf dem Mailserver erstellt." : {
"localizations" : { "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 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." : { "Die ausgewählten Nachrichten werden auch auf dem Server in den Papierkorb verschoben." : {
"localizations" : { "localizations" : {
@@ -619,6 +660,9 @@
} }
} }
} }
},
"Drucken …" : {
}, },
"Durchgestrichen" : { "Durchgestrichen" : {
"localizations" : { "localizations" : {
@@ -671,6 +715,7 @@
} }
}, },
"Einstellungen" : { "Einstellungen" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -751,6 +796,9 @@
} }
} }
} }
},
"Formatierungen entfernen" : {
}, },
"Füge ein Konto hinzu oder wähle links ein Konto aus." : { "Füge ein Konto hinzu oder wähle links ein Konto aus." : {
"localizations" : { "localizations" : {
@@ -791,6 +839,9 @@
} }
} }
} }
},
"HTML-Formatierung entfernen?" : {
}, },
"HTML-Nachricht" : { "HTML-Nachricht" : {
"localizations" : { "localizations" : {
@@ -1011,6 +1062,12 @@
} }
} }
} }
},
"Mitteilungen" : {
},
"Möchtest du die verfasste Nachricht löschen oder als Entwurf speichern?" : {
}, },
"Nach %lld Sekunden" : { "Nach %lld Sekunden" : {
"localizations" : { "localizations" : {
@@ -1113,6 +1170,9 @@
} }
} }
} }
},
"Nachricht schließen?" : {
}, },
"Nachrichten durchsuchen" : { "Nachrichten durchsuchen" : {
"localizations" : { "localizations" : {
@@ -1156,8 +1216,12 @@
}, },
"Neue Konten" : { "Neue Konten" : {
},
"Neue Mail" : {
}, },
"Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung." : { "Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1238,6 +1302,7 @@
} }
}, },
"Nur manuell" : { "Nur manuell" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "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" : { "Regulärer Ausdruck" : {
"localizations" : { "localizations" : {
@@ -1487,6 +1561,7 @@
} }
}, },
"Sofortempfang mit IMAP IDLE" : { "Sofortempfang mit IMAP IDLE" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1545,6 +1620,12 @@
} }
} }
} }
},
"Trotzdem senden" : {
},
"Unbekannter Absender" : {
}, },
"Unterstrichen" : { "Unterstrichen" : {
"localizations" : { "localizations" : {
@@ -1565,6 +1646,9 @@
} }
} }
} }
},
"Verfassen" : {
}, },
"Verhalten" : { "Verhalten" : {
@@ -1588,6 +1672,9 @@
} }
} }
} }
},
"Vor möglicherweise vergessenem Anhang warnen" : {
}, },
"Vorschau Mailtext" : { "Vorschau Mailtext" : {
"localizations" : { "localizations" : {
@@ -1629,6 +1716,9 @@
} }
} }
} }
},
"Zurück zur Mail" : {
} }
}, },
"version" : "1.3" "version" : "1.3"
+11 -5
View File
@@ -94,8 +94,8 @@ enum MIME {
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""), return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"), 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"), 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"] ?? ""), attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), bcc: decodeHeader(fields["bcc"] ?? ""),
htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n")) 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]) { 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 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 { 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 {
guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress), 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 { !subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.") 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 { func base64(_ data: Data) -> String {
data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed]) 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 !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 += "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 += "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" result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
+73 -1
View File
@@ -12,6 +12,7 @@ final class MailEditorModel {
var italic = false var italic = false
var underline = false var underline = false
var strikethrough = false var strikethrough = false
var spellCheckingEnabled = true
var plainText: String { textView?.string ?? "" } var plainText: String { textView?.string ?? "" }
@@ -87,6 +88,52 @@ final class MailEditorModel {
return result 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 { struct MailFormattingBar: View {
@@ -118,6 +165,14 @@ struct MailFormattingBar: View {
Button { editor.setAttribute(.strikethroughStyle, value: editor.strikethrough ? 0 : NSUnderlineStyle.single.rawValue) } label: { Button { editor.setAttribute(.strikethroughStyle, value: editor.strikethrough ? 0 : NSUnderlineStyle.single.rawValue) } label: {
Image(systemName: "strikethrough").foregroundStyle(editor.strikethrough ? Color.accentColor : .primary) Image(systemName: "strikethrough").foregroundStyle(editor.strikethrough ? Color.accentColor : .primary)
}.help("Durchgestrichen") }.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) Spacer(minLength: 0)
} }
.padding(8).background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) .padding(8).background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
@@ -127,6 +182,7 @@ struct MailFormattingBar: View {
struct MailBodyEditor: NSViewRepresentable { struct MailBodyEditor: NSViewRepresentable {
let model: MailEditorModel let model: MailEditorModel
let initialText: String let initialText: String
let initialHTML: String?
let fontName: String let fontName: String
let fontSize: Double let fontSize: Double
let focusOnOpen: Bool let focusOnOpen: Bool
@@ -141,9 +197,23 @@ struct MailBodyEditor: NSViewRepresentable {
view.importsGraphics = false view.importsGraphics = false
view.allowsUndo = true view.allowsUndo = true
view.isAutomaticQuoteSubstitutionEnabled = false view.isAutomaticQuoteSubstitutionEnabled = false
view.isContinuousSpellCheckingEnabled = model.spellCheckingEnabled
view.isGrammarCheckingEnabled = model.spellCheckingEnabled
view.textContainerInset = NSSize(width: 8, height: 8) view.textContainerInset = NSSize(width: 8, height: 8)
view.font = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize) 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.setSelectedRange(NSRange(location: 0, length: 0))
view.setAccessibilityIdentifier("composeBody") view.setAccessibilityIdentifier("composeBody")
view.delegate = context.coordinator view.delegate = context.coordinator
@@ -158,6 +228,8 @@ struct MailBodyEditor: NSViewRepresentable {
func updateNSView(_ scroll: NSScrollView, context: Context) { func updateNSView(_ scroll: NSScrollView, context: Context) {
guard let view = scroll.documentView as? NSTextView else { return } guard let view = scroll.documentView as? NSTextView else { return }
view.isRichText = richText view.isRichText = richText
view.isContinuousSpellCheckingEnabled = model.spellCheckingEnabled
view.isGrammarCheckingEnabled = model.spellCheckingEnabled
} }
final class Coordinator: NSObject, NSTextViewDelegate { final class Coordinator: NSObject, NSTextViewDelegate {
-1
View File
@@ -102,6 +102,5 @@ final class MailIdleMonitor {
} }
struct IdleConfiguration: Equatable { struct IdleConfiguration: Equatable {
let enabled: Bool
let accounts: [MailAccount] let accounts: [MailAccount]
} }
+39 -6
View File
@@ -4,12 +4,18 @@ import Security
struct MailAccount: Identifiable, Codable, Hashable, Sendable { struct MailAccount: Identifiable, Codable, Hashable, Sendable {
var id = UUID() var id = UUID()
var name = "" var name = ""
var displayName: String?
var email = "" var email = ""
var username = "" var username = ""
var host = "" var host = ""
var smtpPort: UInt16 = 587 var smtpPort: UInt16 = 587
var imapPort: UInt16 = 993 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", static let testAccount = MailAccount(name: "Testkonto", email: "test@kuebler-sulzbach.de",
username: "test@kuebler-sulzbach.de", host: "w0099985.kasserver.com") 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()) 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 { var icon: String {
switch path.lowercased() { if isInbox { return "tray" }
case "inbox": "tray" if isTrash { return "trash" }
case let value where value.contains("sent"): "paperplane"
case let value where value.contains("trash"): "trash" let uses = Set((specialUse ?? []).map { $0.lowercased() })
case let value where value.contains("draft"): "doc" let value = (path + " " + name).lowercased()
default: "folder"
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] let attachments: [MailAttachment]
var isRead = false var isRead = false
var cc = "" var cc = ""
var bcc = ""
var replyTo = "" var replyTo = ""
var htmlBody: String? = nil var htmlBody: String? = nil
var displaySender: String { MailDisplay.sender(sender) } var displaySender: String { MailDisplay.sender(sender) }
+94
View File
@@ -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 = "<div style=\"white-space: pre-wrap;\">\(escaped(message.body))</div>"
}
let ccRow = message.cc.isEmpty
? ""
: "<tr><td style=\"font-weight: bold; width: 58px;\">CC:</td><td>\(escaped(message.cc))</td></tr>"
let html = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: -apple-system; font-size: 10.5pt; color: #000; margin: 0;">
<table cellspacing="0" cellpadding="0" style="font-size: 10.5pt; line-height: 1.3;">
<tr><td class="label" style="font-weight: bold; width: 58px;">Betreff:</td><td style="font-size: 13pt; font-weight: bold;">\(escaped(message.subject))</td></tr>
<tr><td class="label" style="font-weight: bold; width: 58px;">Von:</td><td>\(escaped(message.sender))</td></tr>
<tr><td class="label" style="font-weight: bold; width: 58px;">An:</td><td>\(escaped(message.recipient))</td></tr>
\(ccRow)
<tr><td class="label" style="font-weight: bold; width: 58px;">Datum:</td><td>\(escaped(message.date.formatted(date: .long, time: .shortened)))</td></tr>
</table>
<hr size="1" color="#999999" style="margin: 12px 0 24px 0;">
<div style="font-size: 10.5pt; line-height: 1.35;">\(body)</div>
</body></html>
"""
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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
}
+26 -19
View File
@@ -38,10 +38,10 @@ struct MailSettings: View {
@AppStorage("blockRemoteContent") private var blockRemoteContent = true @AppStorage("blockRemoteContent") private var blockRemoteContent = true
private let fontNames = NSFontManager.shared.availableFontFamilies.sorted() private let fontNames = NSFontManager.shared.availableFontFamilies.sorted()
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always @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("offerTestMail") private var offerTestMail = true
@AppStorage("checkOnLaunch") private var checkOnLaunch = 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("showUnifiedInbox") private var showInbox = true
@AppStorage("showUnifiedTrash") private var showTrash = true @AppStorage("showUnifiedTrash") private var showTrash = true
@@ -59,17 +59,17 @@ struct MailSettings: View {
} }
Tab("Abruf", systemImage: "arrow.clockwise") { Tab("Abruf", systemImage: "arrow.clockwise") {
RetrievalSettingsTab( RetrievalSettingsTab(
idleEnabled: $idleEnabled,
pollMinutes: $pollMinutes,
offerTestMail: $offerTestMail, offerTestMail: $offerTestMail,
checkOnLaunch: $checkOnLaunch checkOnLaunch: $checkOnLaunch,
newMailNotifications: $newMailNotifications
) )
} }
Tab("Verhalten", systemImage: "slider.horizontal.3") { Tab("Verhalten", systemImage: "slider.horizontal.3") {
BehaviorSettingsTab( BehaviorSettingsTab(
markReadImmediately: $markReadImmediately, markReadImmediately: $markReadImmediately,
markReadDelaySeconds: $markReadDelaySeconds, markReadDelaySeconds: $markReadDelaySeconds,
deleteConfirmation: $deleteConfirmation deleteConfirmation: $deleteConfirmation,
warnMissingAttachment: $warnMissingAttachment
) )
} }
Tab("Sicherung", systemImage: "externaldrive") { Tab("Sicherung", systemImage: "externaldrive") {
@@ -184,27 +184,28 @@ private struct DisplaySettingsTab: View {
} }
private struct RetrievalSettingsTab: View { private struct RetrievalSettingsTab: View {
@Binding var idleEnabled: Bool
@Binding var pollMinutes: Int
@Binding var offerTestMail: Bool @Binding var offerTestMail: Bool
@Binding var checkOnLaunch: Bool @Binding var checkOnLaunch: Bool
@Binding var newMailNotifications: Bool
var body: some View { var body: some View {
Form { Form {
Section("Mailabruf") { 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) 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") { Section("Neue Konten") {
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail) Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
} }
@@ -218,6 +219,7 @@ private struct BehaviorSettingsTab: View {
@Binding var markReadImmediately: Bool @Binding var markReadImmediately: Bool
@Binding var markReadDelaySeconds: Int @Binding var markReadDelaySeconds: Int
@Binding var deleteConfirmation: MailDeleteConfirmation @Binding var deleteConfirmation: MailDeleteConfirmation
@Binding var warnMissingAttachment: Bool
var body: some View { var body: some View {
Form { Form {
@@ -230,6 +232,11 @@ private struct BehaviorSettingsTab: View {
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600) 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") { Section("Löschen") {
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) { Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
Text("Immer").tag(MailDeleteConfirmation.always) Text("Immer").tag(MailDeleteConfirmation.always)
+79 -25
View File
@@ -21,7 +21,6 @@ final class MailStore {
private var selectedFolderID: String? private var selectedFolderID: String?
private var didStart = false private var didStart = false
private let idleMonitor = MailIdleMonitor() private let idleMonitor = MailIdleMonitor()
private var idleEnabled = false
private var pendingIdleAccounts: Set<UUID> = [] private var pendingIdleAccounts: Set<UUID> = []
private var idleRefreshGeneration = UUID() private var idleRefreshGeneration = UUID()
private var idleRefreshTask: Task<Void, Never>? private var idleRefreshTask: Task<Void, Never>?
@@ -88,7 +87,7 @@ final class MailStore {
validatedAccounts[account.id] = nil validatedAccounts[account.id] = nil
validatedPasswords[account.id] = nil validatedPasswords[account.id] = nil
status = "Konto lokal gespeichert." status = "Konto lokal gespeichert."
configureIdle(enabled: idleEnabled) configureIdle()
idleMonitor.restart(accountID: account.id) idleMonitor.restart(accountID: account.id)
} }
@@ -100,7 +99,7 @@ final class MailStore {
remainingFolders[id] = nil remainingFolders[id] = nil
try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory)) try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory))
accounts = remaining accounts = remaining
configureIdle(enabled: idleEnabled) configureIdle()
folders = remainingFolders folders = remainingFolders
messages = [] messages = []
totalMessages = 0 totalMessages = 0
@@ -131,11 +130,19 @@ final class MailStore {
items.flatMap { [$0] + allFolders($0.children ?? []) } 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 { func refresh(folderID: String?, accountID: UUID? = nil, inboxOnly: Bool = false) async {
guard !isBusy else { return } guard !isBusy else { return }
isBusy = true isBusy = true
defer { isBusy = false } defer { isBusy = false }
var failures: [String] = [] var failures: [String] = []
var newInboxMessages: [NewMailNotification] = []
do { do {
let archive = try storage() let archive = try storage()
for account in accounts where accountID == nil || account.id == accountID { for account in accounts where accountID == nil || account.id == accountID {
@@ -186,6 +193,13 @@ final class MailStore {
try Task.checkCancellation() try Task.checkCancellation()
status = "\(folder.name): \(index + 1) von \(missing.count) neuen Mails speichern …" status = "\(folder.name): \(index + 1) von \(missing.count) neuen Mails speichern …"
let raw = try await client.rawMessage(uid: uid) 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, try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity,
uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false) uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false)
} }
@@ -201,6 +215,10 @@ final class MailStore {
unreadCounts = try archive.unreadCounts() unreadCounts = try archive.unreadCounts()
archiveRevision += 1 archiveRevision += 1
if let selectedFolderID { try display(folderID: selectedFolderID) } if let selectedFolderID { try display(folderID: selectedFolderID) }
await MailNotifications.postNewMail(
newInboxMessages,
badgeCount: totalUnreadCount
)
} catch { failures.append(error.localizedDescription) } } catch { failures.append(error.localizedDescription) }
if failures.isEmpty { if failures.isEmpty {
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar." status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
@@ -244,7 +262,7 @@ final class MailStore {
let copies = Self.addresses(cc) let copies = Self.addresses(cc)
let blindCopies = Self.addresses(bcc) let blindCopies = Self.addresses(bcc)
let envelope = Array(Set(addresses + copies + blindCopies)).sorted() 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 localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien")
let localID = UUID().uuidString let localID = UUID().uuidString
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message. // 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 { func start(folderID: String?) async {
await load(folderID: folderID) await load(folderID: folderID)
guard !didStart else { return } guard !didStart else { return }
@@ -536,25 +606,9 @@ final class MailStore {
} }
} }
func poll(intervalMinutes: Int) async { func configureIdle() {
guard intervalMinutes > 0 else { return } idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } }
while !Task.isCancelled { idleMonitor.configure(accounts: accounts, enabled: true) { [weak self] id in
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
self?.scheduleIdleRefresh(accountID: id) self?.scheduleIdleRefresh(accountID: id)
} report: { [weak self] id, status in } report: { [weak self] id, status in
self?.idleStatuses[id] = status self?.idleStatuses[id] = status
@@ -564,7 +618,7 @@ final class MailStore {
} }
private func scheduleIdleRefresh(accountID: UUID) { 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) pendingIdleAccounts.insert(accountID)
guard idleRefreshTask == nil else { return } guard idleRefreshTask == nil else { return }
let generation = UUID() let generation = UUID()
@@ -574,7 +628,7 @@ final class MailStore {
defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } } defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } }
// Coalesce bursts and retain notifications arriving during another synchronization. // Coalesce bursts and retain notifications arriving during another synchronization.
try? await Task.sleep(for: .milliseconds(400)) try? await Task.sleep(for: .milliseconds(400))
while !Task.isCancelled, self.idleEnabled, !self.pendingIdleAccounts.isEmpty { while !Task.isCancelled, !self.pendingIdleAccounts.isEmpty {
if self.isBusy { if self.isBusy {
do { try await Task.sleep(for: .milliseconds(200)) } catch { return } do { try await Task.sleep(for: .milliseconds(200)) } catch { return }
continue continue
+22
View File
@@ -8,11 +8,27 @@ struct ComposeDraft: Identifiable {
let id = UUID() let id = UUID()
var accountID: UUID? var accountID: UUID?
var recipient = "" var recipient = ""
var cc = ""
var bcc = ""
var subject = "" var subject = ""
var body = "" var body = ""
var htmlBody: String?
var attachments: [MailAttachment] = [] var attachments: [MailAttachment] = []
var isReply = false 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 { static func from(_ message: MailMessage, accountID: UUID?, forwarding: Bool) -> ComposeDraft {
let source = message.replyTo.isEmpty ? message.sender : message.replyTo let source = message.replyTo.isEmpty ? message.sender : message.replyTo
let recipient: String let recipient: String
@@ -55,6 +71,12 @@ struct MessageDetailWindow: View {
.frame(minWidth: 600, minHeight: 450) .frame(minWidth: 600, minHeight: 450)
.navigationTitle(message?.subject ?? "Nachricht") .navigationTitle(message?.subject ?? "Nachricht")
.toolbar { .toolbar {
Button("Drucken …", systemImage: "printer") {
guard let message else { return }
MailPrinter.print(message)
}
.disabled(message == nil)
MessageActionButtons { command in MessageActionButtons { command in
guard let message else { return } guard let message else { return }
switch command { switch command {
+3
View File
@@ -4,6 +4,9 @@ import SwiftUI
struct MikeMailApp: App { struct MikeMailApp: App {
@State private var store: MailStore = { @State private var store: MailStore = {
SettingsBackupManager.applyPendingRestoreIfNeeded() SettingsBackupManager.applyPendingRestoreIfNeeded()
UserDefaults.standard.removeObject(forKey: "idleEnabled")
UserDefaults.standard.removeObject(forKey: "pollMinutes")
MailNotifications.configure()
#if DEBUG #if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() } if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() }
#endif #endif
+4 -4
View File
@@ -5,7 +5,7 @@ import ContactsUI
enum RecipientCompletion { enum RecipientCompletion {
static func matches(in history: [String], input: String) -> [String] { 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) let query = (parts.last ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return [] } guard !query.isEmpty else { return [] }
let selected = Set(parts.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) 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 { 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) $0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty } }.filter { !$0.isEmpty }
if !parts.contains(where: { $0.caseInsensitiveCompare(address) == .orderedSame }) { parts.append(address) } 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) recipient = RecipientCompletion.inserting(address, into: recipient)
focused = true focused = true
}.frame(width: 30, height: 26) }.frame(width: 30, height: 26)
TextField("An (mehrere mit Komma trennen)", text: $recipient) TextField("An (mehrere mit Semikolon trennen)", text: $recipient)
.accessibilityIdentifier("recipientField") .accessibilityIdentifier("recipientField")
.focused($focused) .focused($focused)
.onKeyPress(.downArrow) { .onKeyPress(.downArrow) {
+80
View File
@@ -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)
}
}
}
-2
View File
@@ -33,7 +33,5 @@ struct MailIdleTests {
#expect(store.authenticationAccount == nil) #expect(store.authenticationAccount == nil)
store.requestPassword(accountID: UUID()) store.requestPassword(accountID: UUID())
#expect(store.authenticationAccount == nil) #expect(store.authenticationAccount == nil)
store.configureIdle(enabled: false)
#expect(store.idleStatuses.isEmpty)
} }
} }