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)
}
}
.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, 24)
.padding(.vertical, 36)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
Divider()
HStack {
@@ -94,9 +96,11 @@ struct AccountsView: View {
.disabled(selection == nil || store.isBusy)
Spacer()
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)
.disabled(testing)
.alert("Testmail senden?", isPresented: $askingTestMail) {
@@ -209,23 +213,73 @@ struct AccountSettingsForm: View {
@State private var showsPassword = false
var body: some View {
Form {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
GridRow(alignment: .center) {
Text("Name").gridColumnAlignment(.trailing)
TextField("Name", text: $account.name)
.labelsHidden()
.frame(maxWidth: .infinity)
.gridColumnAlignment(.leading)
}
GridRow(alignment: .center) {
Text("Anzeigename")
TextField("Anzeigename", text: Binding(
get: { account.displayName ?? "" },
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)
.labelsHidden()
.frame(maxWidth: .infinity)
}
GridRow(alignment: .center) {
Text("Server")
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("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort, format: .number.grouping(.never))
LabeledContent("Passwort") {
.labelsHidden()
.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) {
if showsPassword {
TextField("Passwort", text: $password)
.labelsHidden()
} else {
SecureField("Passwort", text: $password)
.labelsHidden()
}
Button(showsPassword ? "Passwort verbergen" : "Passwort anzeigen",
systemImage: showsPassword ? "eye.slash" : "eye") {
@@ -234,7 +288,9 @@ struct AccountSettingsForm: View {
.labelStyle(.iconOnly)
.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
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("mailSearch") private var search = ""
@AppStorage("searchSubject") private var searchSubject = true
@AppStorage("searchSender") private var searchSender = true
@AppStorage("searchBody") private var searchBody = false
@AppStorage("searchCaseSensitive") private var searchCaseSensitive = false
@AppStorage("searchRegex") private var searchRegex = false
@AppStorage("mailSearchScope") private var searchScope = MailSearchScope.currentFolder
@State private var selectedFolder: String?
@State private var selectedMessages: Set<String> = []
@State private var searchCandidates: [MailMessage] = []
@State private var accountPresentation = AccountPresentation()
@Environment(\.openWindow) private var openWindow
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
@@ -62,7 +62,28 @@ struct ContentView: View {
}
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 {
@@ -72,14 +93,12 @@ struct ContentView: View {
} detail: {
VStack(spacing: 0) {
if !search.isEmpty {
HStack {
Text("Suchen in:")
Toggle("Betreff", isOn: $searchSubject)
Toggle("Absender", isOn: $searchSender)
Toggle("Inhalt", isOn: $searchBody)
Spacer()
Toggle("Regulärer Ausdruck", isOn: $searchRegex)
}.toggleStyle(.checkbox).padding(10)
SearchOptionsBar(
scope: $searchScope,
caseSensitive: $searchCaseSensitive,
regularExpression: $searchRegex,
currentAccountAvailable: currentSearchAccountID != nil
)
if let error = searchFilter.error {
Text(error).foregroundStyle(.red).font(.caption).padding(.horizontal)
}
@@ -93,12 +112,18 @@ struct ContentView: View {
.frame(height: 26)
.padding(8)
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() }
.frame(minHeight: 160, idealHeight: 300)
.background(WindowPersistence())
MessageReader(message: store.messages.first { selectedMessages.contains($0.id) })
MessageReader(message: displayedMessages.first { selectedMessages.contains($0.id) })
.frame(minHeight: 220, maxHeight: .infinity)
}
}
@@ -111,7 +136,7 @@ struct ContentView: View {
.accessibilityLabel("Sofortempfang")
Text(store.status).font(.caption).foregroundStyle(.secondary)
Spacer()
Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
Text("\(displayedMessages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
}.padding(8).background(.bar)
}
}
@@ -136,6 +161,14 @@ struct ContentView: View {
.disabled(store.isBusy)
Button("Neue Nachricht", systemImage: "square.and.pencil") { openComposer(ComposeDraft(accountID: composeAccountID)) }
.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() }
.disabled(selectedMessages.isEmpty)
}
@@ -181,8 +214,17 @@ struct ContentView: View {
.onChange(of: selectedFolder) { _, folder in
savedFolder = folder ?? ""
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
savedMessage = ids.sorted().first ?? ""
savedMessages = (try? JSONEncoder().encode(ids)) ?? Data()
@@ -196,6 +238,7 @@ struct ContentView: View {
columnVisibility = sidebarVisible ? .all : .detailOnly
selectedFolder = savedFolder.isEmpty ? nil : savedFolder
await store.start(folderID: selectedFolder)
updateSearchCandidates()
selectedMessages = selection.intersection(Set(store.messages.map(\.id)))
}
.frame(minWidth: 850, minHeight: 550)
@@ -205,6 +248,18 @@ struct ContentView: View {
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>) {
guard !ids.isEmpty else { return }
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 {
let text: String
let subject: Bool
let sender: Bool
let body: Bool
let caseSensitive: Bool
let regularExpression: Bool
private let expression: NSRegularExpression?
let error: String?
init(text: String, subject: Bool, sender: Bool, body: Bool, regularExpression: Bool) {
self.text = text; self.subject = subject; self.sender = sender
self.body = body; self.regularExpression = regularExpression
init(text: String, caseSensitive: Bool, regularExpression: Bool) {
self.text = text
self.caseSensitive = caseSensitive
self.regularExpression = regularExpression
if regularExpression && !text.isEmpty {
do {
expression = try NSRegularExpression(pattern: text, options: [.caseInsensitive])
expression = try NSRegularExpression(pattern: text)
error = nil
} catch {
expression = nil
@@ -265,12 +380,14 @@ struct MailSearch {
}
func matches(_ message: MailMessage) -> Bool {
guard !text.isEmpty else { return true }
let fields = [(subject, message.subject), (sender, message.sender), (body, message.body)]
.filter(\.0).map(\.1)
let fields = [message.sender, message.subject, message.body]
if regularExpression {
guard let expression else { return false }
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) }
}
}
@@ -332,6 +449,7 @@ struct MessageTable: View {
let messages: [MailMessage]
@Binding var selection: Set<String>
var busy = false
var accountIndicators: [UUID: AccountIndicator] = [:]
var action: (MessageCommand, Set<String>) -> Void = { _, _ in }
@AppStorage("mailSortColumn") private var savedColumn = "date"
@AppStorage("mailSortAscending") private var savedAscending = false
@@ -342,6 +460,19 @@ struct MessageTable: View {
var body: some View {
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder,
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
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]))
}
}
.transaction { transaction in
transaction.animation = nil
}
.contextMenu(forSelectionType: String.self) { ids in
if ids.count == 1 {
Button("In neuem Fenster öffnen") { action(.open, ids) }
+23
View File
@@ -225,6 +225,12 @@
}
}
}
},
"Aktueller Ordner" : {
},
"Aktuelles Konto" : {
},
"Alle %lld Minuten" : {
"extractionState" : "stale",
@@ -459,6 +465,9 @@
},
"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." : {
@@ -839,6 +848,9 @@
}
}
}
},
"Groß-/Kleinschreibung beachten" : {
},
"HTML-Formatierung entfernen?" : {
@@ -884,6 +896,7 @@
}
},
"Inhalt" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"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." : {
"localizations" : {
@@ -983,6 +999,9 @@
}
}
}
},
"Kontofarbe" : {
},
"Kursiv" : {
"localizations" : {
@@ -1582,6 +1601,7 @@
}
},
"Suchen in:" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1626,6 +1646,9 @@
},
"Unbekannter Absender" : {
},
"Unterscheidet bei der Textsuche zwischen Groß- und Kleinschreibung." : {
},
"Unterstrichen" : {
"localizations" : {
+5
View File
@@ -5,6 +5,7 @@ struct MailAccount: Identifiable, Codable, Hashable, Sendable {
var id = UUID()
var name = ""
var displayName: String?
var colorHex: String?
var email = ""
var username = ""
var host = ""
@@ -95,6 +96,10 @@ struct MailMessage: Identifiable, Sendable {
var htmlBody: String? = nil
var displaySender: String { MailDisplay.sender(sender) }
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 {
+56 -25
View File
@@ -3,23 +3,20 @@ 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
printInfo.isHorizontallyCentered = false
printInfo.isVerticallyCentered = false
let printableWidth = max(
printInfo.paperSize.width - printInfo.leftMargin - printInfo.rightMargin,
400
)
let printableHeight = max(
printInfo.paperSize.height - printInfo.topMargin - printInfo.bottomMargin,
400
)
guard let attributedMessage = makeAttributedMessage(message, width: printableWidth) else {
NSSound.beep()
return
}
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: printableWidth, height: 1))
textView.isEditable = false
textView.isSelectable = false
@@ -36,9 +33,7 @@ enum MailPrinter {
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
textView.frame.size.height = max(contentHeight + 24, 1)
}
let operation = NSPrintOperation(view: textView, printInfo: printInfo)
@@ -47,7 +42,7 @@ enum MailPrinter {
operation.run()
}
private static func makeAttributedMessage(_ message: MailMessage) -> NSAttributedString? {
private static func makeAttributedMessage(_ message: MailMessage, width: CGFloat) -> NSAttributedString? {
let body: String
if let htmlBody = message.htmlBody, !htmlBody.isEmpty {
body = htmlBody
@@ -57,31 +52,67 @@ enum MailPrinter {
let ccRow = message.cc.isEmpty
? ""
: "<tr><td style=\"font-weight: bold; width: 58px;\">CC:</td><td>\(escaped(message.cc))</td></tr>"
let html = """
: "<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 headerHTML = """
<!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>
<body style="font-family: -apple-system; font-size: 9pt; color: #000; margin: 0;">
<table width="100%" cellspacing="0" cellpadding="0" style="font-size: 9pt; line-height: 1.25;">
<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 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 nowrap style="font-weight: bold; width: 105px; padding-bottom: 3pt;">An:</td><td nowrap style="padding-bottom: 3pt;">\(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>
<tr><td nowrap style="font-weight: bold; width: 105px;">Datum:</td><td nowrap>\(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>
"""
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(
data: Data(html.utf8),
guard let header = try? NSAttributedString(
data: Data(headerHTML.utf8),
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
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 {
+31 -3
View File
@@ -221,10 +221,10 @@ final class MailStore {
)
} catch { failures.append(error.localizedDescription) }
if failures.isEmpty {
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
status = "Abgleich abgeschlossen. Bereit."
} else {
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 }
do {
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 }
}
@@ -377,6 +377,34 @@ final class MailStore {
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 {
guard let account = accounts.first(where: { $0.id == id }) else { throw MailError.message("Konto nicht gefunden.") }
let client = try IMAPClient(account: account)
+5 -5
View File
@@ -7,11 +7,11 @@ struct MailWorkflowTests {
@Test func searchScopesAndExpressions() {
let mail = MailMessage(id: "1", sender: "anna@example.com", recipient: "", subject: "Rechnung 2026",
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", subject: true, sender: true, body: false, regularExpression: false).matches(mail))
#expect(MailSearch(text: #"Rechnung \d{4}$"#, subject: true, sender: false, body: false, regularExpression: true).matches(mail))
#expect(MailSearch(text: "^ANNA@", subject: false, sender: true, body: false, regularExpression: true).matches(mail))
let invalid = MailSearch(text: "[", subject: true, sender: true, body: true, regularExpression: true)
#expect(MailSearch(text: "123,45", caseSensitive: false, regularExpression: false).matches(mail))
#expect(MailSearch(text: #"Rechnung \d{4}$"#, caseSensitive: false, regularExpression: true).matches(mail))
#expect(!MailSearch(text: "^ANNA@", caseSensitive: false, regularExpression: true).matches(mail))
#expect(MailSearch(text: "(?i)^ANNA@", caseSensitive: false, regularExpression: true).matches(mail))
let invalid = MailSearch(text: "[", caseSensitive: false, regularExpression: true)
#expect(invalid.error != nil)
#expect(!invalid.matches(mail))
}