This commit is contained in:
2026-11-09 19:51:11 +01:00
committed by michael300679
parent bbfbd976ed
commit 269a6e08b1
9 changed files with 387 additions and 73 deletions
+37
View File
@@ -0,0 +1,37 @@
import SwiftUI
import AppKit
struct AccountIndicator: Equatable {
let name: String
let colorHex: String?
}
extension Color {
private static let defaultAccountHex = "#3B82F6"
init(accountHex: String?) {
let value = accountHex ?? Self.defaultAccountHex
let hex = value.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard hex.count == 6, let number = UInt64(hex, radix: 16) else {
self = Color(nsColor: .systemBlue)
return
}
self.init(
.sRGB,
red: Double((number >> 16) & 0xFF) / 255,
green: Double((number >> 8) & 0xFF) / 255,
blue: Double(number & 0xFF) / 255,
opacity: 1
)
}
var accountHex: String {
guard let color = NSColor(self).usingColorSpace(.sRGB) else {
return Self.defaultAccountHex
}
let red = Int((color.redComponent * 255).rounded())
let green = Int((color.greenComponent * 255).rounded())
let blue = Int((color.blueComponent * 255).rounded())
return String(format: "#%02X%02X%02X", red, green, blue)
}
}
+63 -7
View File
@@ -76,7 +76,9 @@ struct AccountsView: View {
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
} }
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.horizontal, 24)
.padding(.vertical, 36)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} }
Divider() Divider()
HStack { HStack {
@@ -94,9 +96,11 @@ struct AccountsView: View {
.disabled(selection == nil || store.isBusy) .disabled(selection == nil || store.isBusy)
Spacer() Spacer()
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction) Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
}.padding(16)
} }
.frame(width: 850, height: 520) .padding(.horizontal, 16)
.padding(.vertical, 22)
}
.frame(width: 850, height: 600)
.interactiveDismissDisabled(testing) .interactiveDismissDisabled(testing)
.disabled(testing) .disabled(testing)
.alert("Testmail senden?", isPresented: $askingTestMail) { .alert("Testmail senden?", isPresented: $askingTestMail) {
@@ -209,23 +213,73 @@ struct AccountSettingsForm: View {
@State private var showsPassword = false @State private var showsPassword = false
var body: some View { var body: some View {
Form { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
GridRow(alignment: .center) {
Text("Name").gridColumnAlignment(.trailing)
TextField("Name", text: $account.name) TextField("Name", text: $account.name)
.labelsHidden()
.frame(maxWidth: .infinity)
.gridColumnAlignment(.leading)
}
GridRow(alignment: .center) {
Text("Anzeigename")
TextField("Anzeigename", text: Binding( TextField("Anzeigename", text: Binding(
get: { account.displayName ?? "" }, get: { account.displayName ?? "" },
set: { account.displayName = $0 } set: { account.displayName = $0 }
)) ))
TextField("E-Mail-Adresse", text: $account.email).accessibilityIdentifier("accountEmail") .labelsHidden()
.frame(maxWidth: .infinity)
}
GridRow(alignment: .center) {
Text("Kontofarbe")
ColorPicker("Kontofarbe", selection: Binding(
get: { Color(accountHex: account.colorHex) },
set: { account.colorHex = $0.accountHex }
), supportsOpacity: false)
.labelsHidden()
.fixedSize()
}
GridRow(alignment: .center) {
Text("E-Mail-Adresse")
TextField("E-Mail-Adresse", text: $account.email)
.labelsHidden()
.frame(maxWidth: .infinity)
.accessibilityIdentifier("accountEmail")
}
GridRow(alignment: .center) {
Text("Benutzername")
TextField("Benutzername", text: $account.username) TextField("Benutzername", text: $account.username)
.labelsHidden()
.frame(maxWidth: .infinity)
}
GridRow(alignment: .center) {
Text("Server")
TextField("Server", text: $account.host) TextField("Server", text: $account.host)
.labelsHidden()
.frame(maxWidth: .infinity)
}
GridRow(alignment: .center) {
Text("IMAP-Port (TLS)")
TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never)) TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never))
TextField("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort, format: .number.grouping(.never)) .labelsHidden()
LabeledContent("Passwort") { .frame(maxWidth: .infinity)
}
GridRow(alignment: .center) {
Text("SMTP-Port (587 STARTTLS / 465 TLS)")
TextField("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort,
format: .number.grouping(.never))
.labelsHidden()
.frame(maxWidth: .infinity)
}
GridRow(alignment: .center) {
Text("Passwort")
HStack(spacing: 8) { HStack(spacing: 8) {
if showsPassword { if showsPassword {
TextField("Passwort", text: $password) TextField("Passwort", text: $password)
.labelsHidden()
} else { } else {
SecureField("Passwort", text: $password) SecureField("Passwort", text: $password)
.labelsHidden()
} }
Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen", Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen",
systemImage: showsPassword ? "eye.slash" : "eye") { systemImage: showsPassword ? "eye.slash" : "eye") {
@@ -234,7 +288,9 @@ struct AccountSettingsForm: View {
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
.frame(maxWidth: .infinity)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
+159 -25
View File
@@ -33,12 +33,12 @@ struct ContentView: View {
@AppStorage("sidebarVisible") private var sidebarVisible = true @AppStorage("sidebarVisible") private var sidebarVisible = true
@State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("mailSearch") private var search = "" @AppStorage("mailSearch") private var search = ""
@AppStorage("searchSubject") private var searchSubject = true @AppStorage("searchCaseSensitive") private var searchCaseSensitive = false
@AppStorage("searchSender") private var searchSender = true
@AppStorage("searchBody") private var searchBody = false
@AppStorage("searchRegex") private var searchRegex = false @AppStorage("searchRegex") private var searchRegex = false
@AppStorage("mailSearchScope") private var searchScope = MailSearchScope.currentFolder
@State private var selectedFolder: String? @State private var selectedFolder: String?
@State private var selectedMessages: Set<String> = [] @State private var selectedMessages: Set<String> = []
@State private var searchCandidates: [MailMessage] = []
@State private var accountPresentation = AccountPresentation() @State private var accountPresentation = AccountPresentation()
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
@@ -62,7 +62,28 @@ struct ContentView: View {
} }
private var searchFilter: MailSearch { private var searchFilter: MailSearch {
MailSearch(text: search, subject: searchSubject, sender: searchSender, body: searchBody, regularExpression: searchRegex) MailSearch(text: search, caseSensitive: searchCaseSensitive, regularExpression: searchRegex)
}
private var displayedMessages: [MailMessage] {
guard !search.isEmpty else { return store.messages }
return searchCandidates.filter(searchFilter.matches)
}
private var accountIndicators: [UUID: AccountIndicator] {
Dictionary(uniqueKeysWithValues: store.accounts.map { account in
(account.id, AccountIndicator(
name: account.name.isEmpty ? account.email : account.name,
colorHex: account.colorHex
))
})
}
private var currentSearchAccountID: UUID? {
guard let selectedFolder else { return nil }
if let folder = store.folder(id: selectedFolder) { return folder.accountID }
guard selectedFolder.hasPrefix("account:") else { return nil }
return UUID(uuidString: String(selectedFolder.dropFirst("account:".count)))
} }
var body: some View { var body: some View {
@@ -72,14 +93,12 @@ struct ContentView: View {
} detail: { } detail: {
VStack(spacing: 0) { VStack(spacing: 0) {
if !search.isEmpty { if !search.isEmpty {
HStack { SearchOptionsBar(
Text("Suchen in:") scope: $searchScope,
Toggle("Betreff", isOn: $searchSubject) caseSensitive: $searchCaseSensitive,
Toggle("Absender", isOn: $searchSender) regularExpression: $searchRegex,
Toggle("Inhalt", isOn: $searchBody) currentAccountAvailable: currentSearchAccountID != nil
Spacer() )
Toggle("Regulärer Ausdruck", isOn: $searchRegex)
}.toggleStyle(.checkbox).padding(10)
if let error = searchFilter.error { if let error = searchFilter.error {
Text(error).foregroundStyle(.red).font(.caption).padding(.horizontal) Text(error).foregroundStyle(.red).font(.caption).padding(.horizontal)
} }
@@ -93,12 +112,18 @@ struct ContentView: View {
.frame(height: 26) .frame(height: 26)
.padding(8) .padding(8)
VSplitView { VSplitView {
MessageTable(messages: store.messages.filter(searchFilter.matches), selection: $selectedMessages, busy: store.isBusy, action: perform) MessageTable(
messages: displayedMessages,
selection: $selectedMessages,
busy: store.isBusy,
accountIndicators: accountIndicators,
action: perform
)
.onDeleteCommand { deleteSelection() } .onDeleteCommand { deleteSelection() }
.frame(minHeight: 160, idealHeight: 300) .frame(minHeight: 160, idealHeight: 300)
.background(WindowPersistence()) .background(WindowPersistence())
MessageReader(message: store.messages.first { selectedMessages.contains($0.id) }) MessageReader(message: displayedMessages.first { selectedMessages.contains($0.id) })
.frame(minHeight: 220, maxHeight: .infinity) .frame(minHeight: 220, maxHeight: .infinity)
} }
} }
@@ -111,7 +136,7 @@ struct ContentView: View {
.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("\(displayedMessages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
}.padding(8).background(.bar) }.padding(8).background(.bar)
} }
} }
@@ -136,6 +161,14 @@ struct ContentView: View {
.disabled(store.isBusy) .disabled(store.isBusy)
Button("Neue Nachricht", systemImage: "square.and.pencil") { openComposer(ComposeDraft(accountID: composeAccountID)) } Button("Neue Nachricht", systemImage: "square.and.pencil") { openComposer(ComposeDraft(accountID: composeAccountID)) }
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty) .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
Button("Drucken …", systemImage: "printer") {
guard selectedMessages.count == 1,
let id = selectedMessages.first,
let message = store.message(id: id) else { return }
MailPrinter.print(message)
}
.keyboardShortcut("p", modifiers: .command)
.disabled(selectedMessages.count != 1)
Button("Löschen", systemImage: "trash") { deleteSelection() } Button("Löschen", systemImage: "trash") { deleteSelection() }
.disabled(selectedMessages.isEmpty) .disabled(selectedMessages.isEmpty)
} }
@@ -181,8 +214,17 @@ struct ContentView: View {
.onChange(of: selectedFolder) { _, folder in .onChange(of: selectedFolder) { _, folder in
savedFolder = folder ?? "" savedFolder = folder ?? ""
selectedMessages = [] selectedMessages = []
Task { await store.load(folderID: folder) } if searchScope == .currentAccount && currentSearchAccountID == nil {
searchScope = .currentFolder
} }
Task {
await store.load(folderID: folder)
updateSearchCandidates()
}
}
.onChange(of: search) { _, _ in updateSearchCandidates() }
.onChange(of: searchScope) { _, _ in updateSearchCandidates() }
.onChange(of: store.archiveRevision) { _, _ in updateSearchCandidates() }
.onChange(of: selectedMessages) { _, ids in .onChange(of: selectedMessages) { _, ids in
savedMessage = ids.sorted().first ?? "" savedMessage = ids.sorted().first ?? ""
savedMessages = (try? JSONEncoder().encode(ids)) ?? Data() savedMessages = (try? JSONEncoder().encode(ids)) ?? Data()
@@ -196,6 +238,7 @@ struct ContentView: View {
columnVisibility = sidebarVisible ? .all : .detailOnly columnVisibility = sidebarVisible ? .all : .detailOnly
selectedFolder = savedFolder.isEmpty ? nil : savedFolder selectedFolder = savedFolder.isEmpty ? nil : savedFolder
await store.start(folderID: selectedFolder) await store.start(folderID: selectedFolder)
updateSearchCandidates()
selectedMessages = selection.intersection(Set(store.messages.map(\.id))) selectedMessages = selection.intersection(Set(store.messages.map(\.id)))
} }
.frame(minWidth: 850, minHeight: 550) .frame(minWidth: 850, minHeight: 550)
@@ -205,6 +248,18 @@ struct ContentView: View {
openWindow(id: "compose", value: store.prepareCompose(draft)) openWindow(id: "compose", value: store.prepareCompose(draft))
} }
private func updateSearchCandidates() {
guard !search.isEmpty else {
searchCandidates = []
return
}
searchCandidates = store.messagesForSearch(
scope: searchScope,
folderID: selectedFolder,
accountID: currentSearchAccountID
)
}
private func perform(_ command: MessageCommand, _ ids: Set<String>) { private func perform(_ command: MessageCommand, _ ids: Set<String>) {
guard !ids.isEmpty else { return } guard !ids.isEmpty else { return }
switch command { switch command {
@@ -241,21 +296,81 @@ struct ContentView: View {
} }
} }
enum MailSearchScope: String {
case currentFolder
case currentAccount
case allAccounts
}
struct SearchOptionsBar: View {
@Binding var scope: MailSearchScope
@Binding var caseSensitive: Bool
@Binding var regularExpression: Bool
let currentAccountAvailable: Bool
var body: some View {
HStack(spacing: 16) {
Menu {
scopeButton("Aktueller Ordner", value: .currentFolder)
scopeButton("Aktuelles Konto", value: .currentAccount)
.disabled(!currentAccountAvailable)
scopeButton("Alle Konten", value: .allAccounts)
} label: {
Label(scopeTitle, systemImage: "magnifyingglass")
}
Toggle("Groß-/Kleinschreibung beachten", isOn: $caseSensitive)
.disabled(regularExpression)
.help(regularExpression
? "Bei regulären Ausdrücken wird die Schreibweise durch den Ausdruck selbst gesteuert, zum Beispiel mit (?i)."
: "Unterscheidet bei der Textsuche zwischen Groß- und Kleinschreibung.")
Spacer()
Toggle("Regulärer Ausdruck", isOn: Binding(
get: { regularExpression },
set: { enabled in
regularExpression = enabled
if enabled { caseSensitive = false }
}
))
}
.toggleStyle(.checkbox)
.padding(10)
}
private func scopeButton(_ title: LocalizedStringKey, value: MailSearchScope) -> some View {
Button {
scope = value
} label: {
if scope == value {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
}
private var scopeTitle: LocalizedStringKey {
switch scope {
case .currentFolder: "Aktueller Ordner"
case .currentAccount: "Aktuelles Konto"
case .allAccounts: "Alle Konten"
}
}
}
struct MailSearch { struct MailSearch {
let text: String let text: String
let subject: Bool let caseSensitive: Bool
let sender: Bool
let body: Bool
let regularExpression: Bool let regularExpression: Bool
private let expression: NSRegularExpression? private let expression: NSRegularExpression?
let error: String? let error: String?
init(text: String, subject: Bool, sender: Bool, body: Bool, regularExpression: Bool) { init(text: String, caseSensitive: Bool, regularExpression: Bool) {
self.text = text; self.subject = subject; self.sender = sender self.text = text
self.body = body; self.regularExpression = regularExpression self.caseSensitive = caseSensitive
self.regularExpression = regularExpression
if regularExpression && !text.isEmpty { if regularExpression && !text.isEmpty {
do { do {
expression = try NSRegularExpression(pattern: text, options: [.caseInsensitive]) expression = try NSRegularExpression(pattern: text)
error = nil error = nil
} catch { } catch {
expression = nil expression = nil
@@ -265,12 +380,14 @@ struct MailSearch {
} }
func matches(_ message: MailMessage) -> Bool { func matches(_ message: MailMessage) -> Bool {
guard !text.isEmpty else { return true } guard !text.isEmpty else { return true }
let fields = [(subject, message.subject), (sender, message.sender), (body, message.body)] let fields = [message.sender, message.subject, message.body]
.filter(\.0).map(\.1)
if regularExpression { if regularExpression {
guard let expression else { return false } guard let expression else { return false }
return fields.contains { expression.firstMatch(in: $0, range: NSRange($0.startIndex..., in: $0)) != nil } return fields.contains { expression.firstMatch(in: $0, range: NSRange($0.startIndex..., in: $0)) != nil }
} }
if caseSensitive {
return fields.contains { $0.range(of: text) != nil }
}
return fields.contains { $0.localizedCaseInsensitiveContains(text) } return fields.contains { $0.localizedCaseInsensitiveContains(text) }
} }
} }
@@ -332,6 +449,7 @@ struct MessageTable: View {
let messages: [MailMessage] let messages: [MailMessage]
@Binding var selection: Set<String> @Binding var selection: Set<String>
var busy = false var busy = false
var accountIndicators: [UUID: AccountIndicator] = [:]
var action: (MessageCommand, Set<String>) -> Void = { _, _ in } var action: (MessageCommand, Set<String>) -> Void = { _, _ in }
@AppStorage("mailSortColumn") private var savedColumn = "date" @AppStorage("mailSortColumn") private var savedColumn = "date"
@AppStorage("mailSortAscending") private var savedAscending = false @AppStorage("mailSortAscending") private var savedAscending = false
@@ -342,6 +460,19 @@ struct MessageTable: View {
var body: some View { var body: some View {
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder, Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder,
columnCustomization: $columnCustomization) { columnCustomization: $columnCustomization) {
TableColumn("") { message in
if let accountID = message.accountID,
let indicator = accountIndicators[accountID] {
RoundedRectangle(cornerRadius: 3)
.fill(Color(accountHex: indicator.colorHex))
.frame(width: 12, height: 12)
.frame(maxWidth: .infinity, alignment: .center)
.accessibilityLabel("Konto: \(indicator.name)")
.help(indicator.name)
}
}
.width(28)
.customizationID("account")
TableColumn("Absender", value: \.displaySender) { message in TableColumn("Absender", value: \.displaySender) { message in
Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold) Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold)
} }
@@ -372,6 +503,9 @@ struct MessageTable: View {
TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id])) TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id]))
} }
} }
.transaction { transaction in
transaction.animation = nil
}
.contextMenu(forSelectionType: String.self) { ids in .contextMenu(forSelectionType: String.self) { ids in
if ids.count == 1 { if ids.count == 1 {
Button("In neuem Fenster öffnen") { action(.open, ids) } Button("In neuem Fenster öffnen") { action(.open, ids) }
+23
View File
@@ -225,6 +225,12 @@
} }
} }
} }
},
"Aktueller Ordner" : {
},
"Aktuelles Konto" : {
}, },
"Alle %lld Minuten" : { "Alle %lld Minuten" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -459,6 +465,9 @@
}, },
"Bei neuen Mails benachrichtigen" : { "Bei neuen Mails benachrichtigen" : {
},
"Bei regulären Ausdrücken wird die Schreibweise durch den Ausdruck selbst gesteuert, zum Beispiel mit (?i)." : {
}, },
"Beim Deaktivieren der HTML-Nachricht gehen alle Textformatierungen verloren." : { "Beim Deaktivieren der HTML-Nachricht gehen alle Textformatierungen verloren." : {
@@ -839,6 +848,9 @@
} }
} }
} }
},
"Groß-/Kleinschreibung beachten" : {
}, },
"HTML-Formatierung entfernen?" : { "HTML-Formatierung entfernen?" : {
@@ -884,6 +896,7 @@
} }
}, },
"Inhalt" : { "Inhalt" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -973,6 +986,9 @@
} }
} }
} }
},
"Konto: %@" : {
}, },
"Konto: %@\n\nAlle lokal gespeicherten Mails und Anhänge dieses Kontos werden entfernt, auch archivierte Nachrichten und lokale Versandkopien. Nur lokal vorhandene Mails können nicht vom Server wiederhergestellt werden.\n\nMails auf dem Server, Kontoeinstellungen und Passwort bleiben unverändert. Beim nächsten „Abrufen“ werden alle vorhandenen Servermails neu heruntergeladen." : { "Konto: %@\n\nAlle lokal gespeicherten Mails und Anhänge dieses Kontos werden entfernt, auch archivierte Nachrichten und lokale Versandkopien. Nur lokal vorhandene Mails können nicht vom Server wiederhergestellt werden.\n\nMails auf dem Server, Kontoeinstellungen und Passwort bleiben unverändert. Beim nächsten „Abrufen“ werden alle vorhandenen Servermails neu heruntergeladen." : {
"localizations" : { "localizations" : {
@@ -983,6 +999,9 @@
} }
} }
} }
},
"Kontofarbe" : {
}, },
"Kursiv" : { "Kursiv" : {
"localizations" : { "localizations" : {
@@ -1582,6 +1601,7 @@
} }
}, },
"Suchen in:" : { "Suchen in:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1626,6 +1646,9 @@
}, },
"Unbekannter Absender" : { "Unbekannter Absender" : {
},
"Unterscheidet bei der Textsuche zwischen Groß- und Kleinschreibung." : {
}, },
"Unterstrichen" : { "Unterstrichen" : {
"localizations" : { "localizations" : {
+5
View File
@@ -5,6 +5,7 @@ struct MailAccount: Identifiable, Codable, Hashable, Sendable {
var id = UUID() var id = UUID()
var name = "" var name = ""
var displayName: String? var displayName: String?
var colorHex: String?
var email = "" var email = ""
var username = "" var username = ""
var host = "" var host = ""
@@ -95,6 +96,10 @@ struct MailMessage: Identifiable, Sendable {
var htmlBody: String? = nil var htmlBody: String? = nil
var displaySender: String { MailDisplay.sender(sender) } var displaySender: String { MailDisplay.sender(sender) }
var attachmentCount: Int { attachments.count } var attachmentCount: Int { attachments.count }
var accountID: UUID? {
guard let separator = id.firstIndex(of: ":") else { return nil }
return UUID(uuidString: String(id[..<separator]))
}
} }
enum MailError: LocalizedError { enum MailError: LocalizedError {
+56 -25
View File
@@ -3,23 +3,20 @@ import AppKit
@MainActor @MainActor
enum MailPrinter { enum MailPrinter {
static func print(_ message: MailMessage) { static func print(_ message: MailMessage) {
guard let attributedMessage = makeAttributedMessage(message) else {
NSSound.beep()
return
}
let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo.shared let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo.shared
printInfo.horizontalPagination = .fit printInfo.horizontalPagination = .fit
printInfo.verticalPagination = .automatic printInfo.verticalPagination = .automatic
printInfo.isHorizontallyCentered = false
printInfo.isVerticallyCentered = false
let printableWidth = max( let printableWidth = max(
printInfo.paperSize.width - printInfo.leftMargin - printInfo.rightMargin, printInfo.paperSize.width - printInfo.leftMargin - printInfo.rightMargin,
400 400
) )
let printableHeight = max( guard let attributedMessage = makeAttributedMessage(message, width: printableWidth) else {
printInfo.paperSize.height - printInfo.topMargin - printInfo.bottomMargin, NSSound.beep()
400 return
) }
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: printableWidth, height: 1)) let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: printableWidth, height: 1))
textView.isEditable = false textView.isEditable = false
textView.isSelectable = false textView.isSelectable = false
@@ -36,9 +33,7 @@ enum MailPrinter {
if let textContainer = textView.textContainer { if let textContainer = textView.textContainer {
textView.layoutManager?.ensureLayout(for: textContainer) textView.layoutManager?.ensureLayout(for: textContainer)
let contentHeight = textView.layoutManager?.usedRect(for: textContainer).height ?? 1 let contentHeight = textView.layoutManager?.usedRect(for: textContainer).height ?? 1
let requiredHeight = max(contentHeight + 24, printableHeight) textView.frame.size.height = max(contentHeight + 24, 1)
let pageCount = ceil(requiredHeight / printableHeight)
textView.frame.size.height = pageCount * printableHeight
} }
let operation = NSPrintOperation(view: textView, printInfo: printInfo) let operation = NSPrintOperation(view: textView, printInfo: printInfo)
@@ -47,7 +42,7 @@ enum MailPrinter {
operation.run() operation.run()
} }
private static func makeAttributedMessage(_ message: MailMessage) -> NSAttributedString? { private static func makeAttributedMessage(_ message: MailMessage, width: CGFloat) -> NSAttributedString? {
let body: String let body: String
if let htmlBody = message.htmlBody, !htmlBody.isEmpty { if let htmlBody = message.htmlBody, !htmlBody.isEmpty {
body = htmlBody body = htmlBody
@@ -57,31 +52,67 @@ enum MailPrinter {
let ccRow = message.cc.isEmpty let ccRow = message.cc.isEmpty
? "" ? ""
: "<tr><td style=\"font-weight: bold; width: 58px;\">CC:</td><td>\(escaped(message.cc))</td></tr>" : "<tr><td nowrap style=\"font-weight: bold; width: 105px; padding-bottom: 3pt;\">CC:</td><td nowrap style=\"padding-bottom: 3pt;\">\(escaped(message.cc))</td></tr>"
let html = """ let headerHTML = """
<!doctype html> <!doctype html>
<html><head><meta charset="utf-8"></head> <html><head><meta charset="utf-8"></head>
<body style="font-family: -apple-system; font-size: 10.5pt; color: #000; margin: 0;"> <body style="font-family: -apple-system; font-size: 9pt; color: #000; margin: 0;">
<table cellspacing="0" cellpadding="0" style="font-size: 10.5pt; line-height: 1.3;"> <table width="100%" cellspacing="0" cellpadding="0" style="font-size: 9pt; line-height: 1.25;">
<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 nowrap style="font-weight: bold; width: 105px; padding-bottom: 3pt;">Betreff:</td><td nowrap style="padding-bottom: 3pt;">\(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 nowrap style="font-weight: bold; width: 105px; padding-bottom: 3pt;">Von:</td><td nowrap style="padding-bottom: 3pt;">\(escaped(message.sender))</td></tr>
<tr><td class="label" style="font-weight: bold; width: 58px;">An:</td><td>\(escaped(message.recipient))</td></tr> <tr><td nowrap style="font-weight: bold; width: 105px; padding-bottom: 3pt;">An:</td><td nowrap style="padding-bottom: 3pt;">\(escaped(message.recipient))</td></tr>
\(ccRow) \(ccRow)
<tr><td class="label" style="font-weight: bold; width: 58px;">Datum:</td><td>\(escaped(message.date.formatted(date: .long, time: .shortened)))</td></tr> <tr><td nowrap style="font-weight: bold; width: 105px;">Datum:</td><td nowrap>\(escaped(message.date.formatted(date: .long, time: .shortened)))</td></tr>
</table> </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> </body></html>
""" """
let bodyHTML = """
<!doctype html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: -apple-system; font-size: 9.5pt; color: #000; margin: 0; line-height: 1.35;">\(body)</body></html>
"""
return try? NSAttributedString( guard let header = try? NSAttributedString(
data: Data(html.utf8), data: Data(headerHTML.utf8),
options: [ options: [
.documentType: NSAttributedString.DocumentType.html, .documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue .characterEncoding: String.Encoding.utf8.rawValue
], ],
documentAttributes: nil documentAttributes: nil
), let messageBody = try? NSAttributedString(
data: Data(bodyHTML.utf8),
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
) else { return nil }
let result = NSMutableAttributedString(attributedString: header)
let separatorImage = NSImage(
size: NSSize(width: max(width - 16, 1), height: 0.5),
flipped: false
) { rect in
NSColor(calibratedWhite: 0.65, alpha: 1).setFill()
rect.fill()
return true
}
let attachment = NSTextAttachment()
attachment.image = separatorImage
let separator = NSMutableAttributedString(attachment: attachment)
separator.append(NSAttributedString(string: "\n"))
let separatorStyle = NSMutableParagraphStyle()
separatorStyle.paragraphSpacingBefore = 10
separatorStyle.paragraphSpacing = 18
separator.addAttribute(
.paragraphStyle,
value: separatorStyle,
range: NSRange(location: 0, length: separator.length)
) )
result.append(NSAttributedString(string: "\n"))
result.append(separator)
result.append(messageBody)
return result
} }
private static func escaped(_ text: String) -> String { private static func escaped(_ text: String) -> String {
+31 -3
View File
@@ -221,10 +221,10 @@ final class MailStore {
) )
} 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. Bereit."
} else { } else {
if authenticationAccountID == nil { error = failures.joined(separator: "\n") } if authenticationAccountID == nil { error = failures.joined(separator: "\n") }
status = "Abgleich unvollständig. Bereits gespeicherte Mails bleiben offline verfügbar." status = "Abgleich unvollständig."
} }
} }
@@ -244,7 +244,7 @@ final class MailStore {
guard let folderID else { messages = []; totalMessages = 0; return } guard let folderID else { messages = []; totalMessages = 0; return }
do { do {
try display(folderID: folderID) try display(folderID: folderID)
if !isBusy { status = "\(messages.count) lokal gespeicherte Nachrichten · Abrufen gleicht Änderungen ab." } if !isBusy { status = "Bereit." }
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
} }
@@ -377,6 +377,34 @@ final class MailStore {
accounts.flatMap { allFolders(folders[$0.id] ?? []) }.first { $0.id == id } accounts.flatMap { allFolders(folders[$0.id] ?? []) }.first { $0.id == id }
} }
func messagesForSearch(
scope: MailSearchScope,
folderID: String?,
accountID: UUID?
) -> [MailMessage] {
do {
switch scope {
case .currentFolder:
return messages
case .currentAccount:
guard let accountID else { return messages }
return try allFolders(folders[accountID] ?? [])
.filter(\.selectable)
.flatMap { try storage().messages(folderID: $0.id) }
.sorted { $0.date > $1.date }
case .allAccounts:
return try accounts
.flatMap { allFolders(folders[$0.id] ?? []) }
.filter(\.selectable)
.flatMap { try storage().messages(folderID: $0.id) }
.sorted { $0.date > $1.date }
}
} catch {
self.error = "Der Suchbereich konnte nicht geladen werden: \(error.localizedDescription)"
return folderID == nil ? [] : messages
}
}
private func client(for id: UUID) async throws -> IMAPClient { private func client(for id: UUID) async throws -> IMAPClient {
guard let account = accounts.first(where: { $0.id == id }) else { throw MailError.message("Konto nicht gefunden.") } guard let account = accounts.first(where: { $0.id == id }) else { throw MailError.message("Konto nicht gefunden.") }
let client = try IMAPClient(account: account) let client = try IMAPClient(account: account)
+5 -5
View File
@@ -7,11 +7,11 @@ struct MailWorkflowTests {
@Test func searchScopesAndExpressions() { @Test func searchScopesAndExpressions() {
let mail = MailMessage(id: "1", sender: "anna@example.com", recipient: "", subject: "Rechnung 2026", let mail = MailMessage(id: "1", sender: "anna@example.com", recipient: "", subject: "Rechnung 2026",
date: Date(), body: "Betrag: 123,45 EUR", attachments: []) date: Date(), body: "Betrag: 123,45 EUR", attachments: [])
#expect(MailSearch(text: "123,45", subject: false, sender: false, body: true, regularExpression: false).matches(mail)) #expect(MailSearch(text: "123,45", caseSensitive: false, regularExpression: false).matches(mail))
#expect(!MailSearch(text: "123,45", subject: true, sender: true, body: false, regularExpression: false).matches(mail)) #expect(MailSearch(text: #"Rechnung \d{4}$"#, caseSensitive: false, regularExpression: true).matches(mail))
#expect(MailSearch(text: #"Rechnung \d{4}$"#, subject: true, sender: false, body: false, regularExpression: true).matches(mail)) #expect(!MailSearch(text: "^ANNA@", caseSensitive: false, regularExpression: true).matches(mail))
#expect(MailSearch(text: "^ANNA@", subject: false, sender: true, body: false, regularExpression: true).matches(mail)) #expect(MailSearch(text: "(?i)^ANNA@", caseSensitive: false, regularExpression: true).matches(mail))
let invalid = MailSearch(text: "[", subject: true, sender: true, body: true, regularExpression: true) let invalid = MailSearch(text: "[", caseSensitive: false, regularExpression: true)
#expect(invalid.error != nil) #expect(invalid.error != nil)
#expect(!invalid.matches(mail)) #expect(!invalid.matches(mail))
} }