Alpha 5
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "",
|
||||||
|
|||||||
MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate
Generated
BIN
Binary file not shown.
@@ -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))) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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]) {
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -102,6 +102,5 @@ final class MailIdleMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct IdleConfiguration: Equatable {
|
struct IdleConfiguration: Equatable {
|
||||||
let enabled: Bool
|
|
||||||
let accounts: [MailAccount]
|
let accounts: [MailAccount]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-19
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user