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)
|
||||
}
|
||||
}
|
||||
.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
@@ -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) }
|
||||
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user