Alpha 6
This commit is contained in:
MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate
Generated
BIN
Binary file not shown.
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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) }
|
||||||
|
|||||||
@@ -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,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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user