This commit is contained in:
2026-11-09 22:23:39 +01:00
committed by michael300679
parent 269a6e08b1
commit 469a9a11d2
12 changed files with 263 additions and 93 deletions
+3
View File
@@ -0,0 +1,3 @@
# Xcode user-specific interface state
*.xcuserstate
+28 -2
View File
@@ -169,15 +169,41 @@ struct MailSidebarList: NSViewRepresentable {
texts.spacing = 2 texts.spacing = 2
let title = NSTextField(labelWithString: entry.title) let title = NSTextField(labelWithString: entry.title)
title.lineBreakMode = .byTruncatingTail title.lineBreakMode = .byTruncatingTail
title.font = .systemFont(ofSize: 13)
texts.addArrangedSubview(title)
if let account = entry.account { if let account = entry.account {
title.font = .systemFont(ofSize: 13, weight: .semibold)
let nameRow = NSStackView()
nameRow.orientation = .horizontal
nameRow.alignment = .centerY
nameRow.spacing = 6
nameRow.addArrangedSubview(title)
let colorBadge = NSView()
colorBadge.wantsLayer = true
colorBadge.layer?.backgroundColor = NSColor(Color(accountHex: account.colorHex)).cgColor
colorBadge.layer?.cornerRadius = 6
colorBadge.translatesAutoresizingMaskIntoConstraints = false
colorBadge.setAccessibilityLabel("Kontofarbe")
let badgeContainer = NSView()
badgeContainer.translatesAutoresizingMaskIntoConstraints = false
badgeContainer.addSubview(colorBadge)
NSLayoutConstraint.activate([
badgeContainer.widthAnchor.constraint(equalToConstant: 12),
badgeContainer.heightAnchor.constraint(equalToConstant: 18),
colorBadge.widthAnchor.constraint(equalToConstant: 12),
colorBadge.heightAnchor.constraint(equalToConstant: 12),
colorBadge.centerXAnchor.constraint(equalTo: badgeContainer.centerXAnchor),
colorBadge.centerYAnchor.constraint(equalTo: badgeContainer.centerYAnchor, constant: 1)
])
nameRow.addArrangedSubview(badgeContainer)
texts.addArrangedSubview(nameRow)
let email = NSTextField(labelWithString: account.email) let email = NSTextField(labelWithString: account.email)
email.font = .systemFont(ofSize: 11) email.font = .systemFont(ofSize: 11)
email.textColor = .secondaryLabelColor email.textColor = .secondaryLabelColor
email.lineBreakMode = .byTruncatingTail email.lineBreakMode = .byTruncatingTail
texts.addArrangedSubview(email) texts.addArrangedSubview(email)
cell.setAccessibilityIdentifier("accountRow-" + account.email) cell.setAccessibilityIdentifier("accountRow-" + account.email)
} else {
title.font = .systemFont(ofSize: 13)
texts.addArrangedSubview(title)
} }
texts.setContentHuggingPriority(.defaultLow, for: .horizontal) texts.setContentHuggingPriority(.defaultLow, for: .horizontal)
stack.addArrangedSubview(texts) stack.addArrangedSubview(texts)
+65 -64
View File
@@ -13,12 +13,7 @@ struct AccountsView: View {
@State private var confirmingReset = false @State private var confirmingReset = false
@State private var resetResult: String? @State private var resetResult: String?
@State private var testing = false @State private var testing = false
@State private var testedAccount: MailAccount?
@State private var testedPassword = ""
@State private var testMailAccount: MailAccount?
@State private var askingTestMail = false
@State private var accountsNeedingRefresh: Set<UUID> = [] @State private var accountsNeedingRefresh: Set<UUID> = []
@AppStorage("offerTestMail") private var offerTestMail = true
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -67,12 +62,8 @@ struct AccountsView: View {
HStack { HStack {
Spacer() Spacer()
if testing { ProgressView().controlSize(.small) } if testing { ProgressView().controlSize(.small) }
Button("Verbindung testen") { testConnection() }
.disabled(selection == nil || testing)
Button("Speichern") { save() } Button("Speichern") { save() }
.disabled(selection == nil || testing || store.isBusy || .disabled(selection == nil || testing || store.isBusy)
(newAccount?.id == selection && (testedAccount != selection.flatMap { drafts[$0] } ||
testedPassword != (selection.flatMap { passwords[$0] } ?? ""))))
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
} }
} }
@@ -100,22 +91,9 @@ struct AccountsView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 22) .padding(.vertical, 22)
} }
.frame(width: 850, height: 600) .frame(width: 1_000, height: 820)
.interactiveDismissDisabled(testing) .interactiveDismissDisabled(testing)
.disabled(testing) .disabled(testing)
.alert("Testmail senden?", isPresented: $askingTestMail) {
Button("Nein", role: .cancel) {}
Button("Testmail senden") {
if let account = testMailAccount {
Task {
do {
try await store.send(accountID: account.id, recipients: account.email,
subject: "MikeMail – Testnachricht", body: "Versand und Empfang mit MikeMail sind eingerichtet.", attachments: [])
} catch { self.error = error.localizedDescription }
}
}
}
} message: { Text("Eine Testnachricht an \(testMailAccount?.email ?? "") senden?") }
.onAppear { .onAppear {
drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) }) drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) })
selection = store.accounts.first?.id selection = store.accounts.first?.id
@@ -148,22 +126,32 @@ struct AccountsView: View {
private func save() { private func save() {
guard let id = selection, let account = drafts[id] else { return } guard let id = selection, let account = drafts[id] else { return }
let password = passwords[id] ?? ""
testing = true
error = nil
resetResult = nil
Task {
do { do {
try await store.testConnection(account: account, password: password)
let isNew = !store.accounts.contains { $0.id == id } let isNew = !store.accounts.contains { $0.id == id }
try store.save(account: account, password: passwords[id] ?? "") try store.save(account: account, password: password)
passwords[id] = nil passwords[id] = nil
if newAccount?.id == id { newAccount = nil } if newAccount?.id == id { newAccount = nil }
if isNew { if isNew {
Task { Task {
while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) } while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) }
await store.refresh(folderID: nil, accountID: id) await store.refresh(folderID: nil, accountID: id)
if offerTestMail { testMailAccount = account; askingTestMail = true }
} }
} else { } else {
accountsNeedingRefresh.insert(id) accountsNeedingRefresh.insert(id)
} }
error = nil testing = false
} catch { self.error = error.localizedDescription } dismiss()
} catch {
self.error = error.localizedDescription
testing = false
}
}
} }
private func refreshChangedAccounts() { private func refreshChangedAccounts() {
@@ -178,23 +166,6 @@ struct AccountsView: View {
} }
} }
private func testConnection() {
guard let id = selection, let account = drafts[id] else { return }
let password = passwords[id] ?? ""
testing = true
error = nil
testedAccount = nil
Task {
defer { testing = false }
do {
try await store.testConnection(account: account, password: password)
testedAccount = account
testedPassword = password
resetResult = "IMAP- und SMTP-Verbindung erfolgreich geprüft."
} catch { self.error = error.localizedDescription }
}
}
private func delete() { private func delete() {
guard let id = selection else { return } guard let id = selection else { return }
do { do {
@@ -246,31 +217,20 @@ struct AccountSettingsForm: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.accessibilityIdentifier("accountEmail") .accessibilityIdentifier("accountEmail")
} }
Divider()
.gridCellColumns(2)
.padding(.vertical, 4)
GridRow {
Text("Login-Daten")
.font(.headline)
.gridCellColumns(2)
}
GridRow(alignment: .center) { GridRow(alignment: .center) {
Text("Benutzername") Text("Benutzername")
TextField("Benutzername", text: $account.username) TextField("Benutzername", text: $account.username)
.labelsHidden() .labelsHidden()
.frame(maxWidth: .infinity) .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))
.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) { GridRow(alignment: .center) {
Text("Passwort") Text("Passwort")
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -290,6 +250,47 @@ struct AccountSettingsForm: View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
Divider()
.gridCellColumns(2)
.padding(.vertical, 4)
GridRow {
Text("Posteingang (IMAP)")
.font(.headline)
.gridCellColumns(2)
}
GridRow(alignment: .center) {
Text("IMAP-Server")
TextField("IMAP-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))
.labelsHidden()
.frame(maxWidth: .infinity)
}
Divider()
.gridCellColumns(2)
.padding(.vertical, 4)
GridRow {
Text("Postausgang (SMTP)")
.font(.headline)
.gridCellColumns(2)
}
GridRow(alignment: .center) {
Text("SMTP-Server")
TextField("SMTP-Server", text: $account.smtpServer)
.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)
}
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
+55 -9
View File
@@ -117,6 +117,7 @@ struct ContentView: View {
selection: $selectedMessages, selection: $selectedMessages,
busy: store.isBusy, busy: store.isBusy,
accountIndicators: accountIndicators, accountIndicators: accountIndicators,
canRestoreToInbox: { store.selectionCanRestoreToInbox(Array($0)) },
action: perform action: perform
) )
.onDeleteCommand { deleteSelection() } .onDeleteCommand { deleteSelection() }
@@ -277,6 +278,8 @@ struct ContentView: View {
} }
case .delete: case .delete:
requestDeletion(Array(ids)) requestDeletion(Array(ids))
case .restoreToInbox:
Task { await store.restoreToInbox(ids: Array(ids)) }
case .read, .unread: case .read, .unread:
Task { await store.setRead(ids: Array(ids), read: command == .read) } Task { await store.setRead(ids: Array(ids), read: command == .read) }
} }
@@ -450,6 +453,7 @@ struct MessageTable: View {
@Binding var selection: Set<String> @Binding var selection: Set<String>
var busy = false var busy = false
var accountIndicators: [UUID: AccountIndicator] = [:] var accountIndicators: [UUID: AccountIndicator] = [:]
var canRestoreToInbox: (Set<String>) -> Bool = { _ in false }
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
@@ -507,6 +511,13 @@ struct MessageTable: View {
transaction.animation = nil transaction.animation = nil
} }
.contextMenu(forSelectionType: String.self) { ids in .contextMenu(forSelectionType: String.self) { ids in
if canRestoreToInbox(ids) {
Button("In Posteingang verschieben", systemImage: "tray.and.arrow.down") {
action(.restoreToInbox, ids)
}
.disabled(busy)
Divider()
}
if ids.count == 1 { if ids.count == 1 {
Button("In neuem Fenster öffnen") { action(.open, ids) } Button("In neuem Fenster öffnen") { action(.open, ids) }
MessageActionButtons { action($0, ids) } MessageActionButtons { action($0, ids) }
@@ -568,15 +579,8 @@ struct MessageReader: View {
ScrollView { ScrollView {
if let message { if let message {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text(message.subject).font(.title2).bold().textSelection(.enabled) MessageHeader(subject: message.subject, sender: message.sender,
VStack(alignment: .leading, spacing: 5) { recipient: message.recipient, cc: message.cc, date: message.date)
Text("Von: \(message.sender)")
Text("An: \(message.recipient)").foregroundStyle(.secondary)
if !message.cc.isEmpty { Text("CC: \(message.cc)").foregroundStyle(.secondary) }
if message.date != .distantPast {
Text(message.date, format: .dateTime.day().month(.wide).year().hour().minute()).foregroundStyle(.secondary)
}
}.textSelection(.enabled)
Divider() Divider()
if let html = message.htmlBody { if let html = message.htmlBody {
HTMLMessageBody(html: html, attachments: message.attachments) HTMLMessageBody(html: html, attachments: message.attachments)
@@ -602,6 +606,48 @@ struct MessageReader: View {
} }
} }
private struct MessageHeader: View {
let subject: String
let sender: String
let recipient: String
let cc: String
let date: Date
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 7) {
GridRow(alignment: .firstTextBaseline) {
Text("Von:").bold().gridColumnAlignment(.leading)
Text(sender).gridColumnAlignment(.leading)
}
GridRow(alignment: .firstTextBaseline) {
Text("An:").bold()
Text(recipient)
}
if !cc.isEmpty {
GridRow(alignment: .firstTextBaseline) {
Text("CC:").bold()
Text(cc)
}
}
if date != .distantPast {
GridRow(alignment: .firstTextBaseline) {
Text("Datum:").bold()
Text(date, format: .dateTime.day().month(.wide).year().hour().minute())
}
}
GridRow(alignment: .firstTextBaseline) {
Text("Betreff:").bold()
Text(subject).bold()
}
}
.foregroundStyle(.primary)
.textSelection(.enabled)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
}
}
enum AttachmentMentionDetector { enum AttachmentMentionDetector {
static let settingKey = "warnMissingAttachment" static let settingKey = "warnMissingAttachment"
+6
View File
@@ -17,8 +17,14 @@ final class IMAPClient {
let greeting = try await connection.readLine() let greeting = try await connection.readLine()
if greeting.uppercased().hasPrefix("* PREAUTH") { return } if greeting.uppercased().hasPrefix("* PREAUTH") { return }
guard greeting.uppercased().hasPrefix("* OK") else { throw MailError.message("IMAP: \(greeting)") } guard greeting.uppercased().hasPrefix("* OK") else { throw MailError.message("IMAP: \(greeting)") }
let capabilities = greeting.uppercased()
if capabilities.contains("AUTH=PLAIN"), capabilities.contains("SASL-IR") {
let credentials = Data(("\0" + account.username + "\0" + password).utf8).base64EncodedString()
_ = try await command("AUTHENTICATE PLAIN \(credentials)")
} else {
_ = try await command("LOGIN \(Self.quote(account.username)) \(Self.quote(password))") _ = try await command("LOGIN \(Self.quote(account.username)) \(Self.quote(password))")
} }
}
static func quote(_ value: String) throws -> String { static func quote(_ value: String) throws -> String {
guard !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { guard !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
+42
View File
@@ -361,8 +361,12 @@
}, },
"An (mehrere mit Semikolon trennen)" : { "An (mehrere mit Semikolon trennen)" : {
},
"An:" : {
}, },
"An: %@" : { "An: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -514,6 +518,9 @@
} }
} }
} }
},
"Betreff:" : {
}, },
"CC" : { "CC" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -528,8 +535,12 @@
}, },
"CC (mehrere mit Semikolon trennen)" : { "CC (mehrere mit Semikolon trennen)" : {
},
"CC:" : {
}, },
"CC: %@" : { "CC: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -580,6 +591,9 @@
} }
} }
} }
},
"Datum:" : {
}, },
"Der Inhalt und die Anhänge erscheinen hier." : { "Der Inhalt und die Anhänge erscheinen hier." : {
"localizations" : { "localizations" : {
@@ -714,6 +728,7 @@
} }
}, },
"Eine Testnachricht an %@ senden?" : { "Eine Testnachricht an %@ senden?" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -874,6 +889,9 @@
} }
} }
} }
},
"IMAP-Server" : {
}, },
"Immer" : { "Immer" : {
"localizations" : { "localizations" : {
@@ -894,6 +912,9 @@
} }
} }
} }
},
"In Posteingang verschieben" : {
}, },
"Inhalt" : { "Inhalt" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -1015,6 +1036,9 @@
}, },
"Lesestatus" : { "Lesestatus" : {
},
"Login-Daten" : {
}, },
"Lokale Mails endgültig löschen" : { "Lokale Mails endgültig löschen" : {
"localizations" : { "localizations" : {
@@ -1224,6 +1248,7 @@
} }
}, },
"Nein" : { "Nein" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1443,6 +1468,12 @@
}, },
"Passwörter in das Backup aufnehmen" : { "Passwörter in das Backup aufnehmen" : {
},
"Postausgang (SMTP)" : {
},
"Posteingang (IMAP)" : {
}, },
"Prüfen und speichern" : { "Prüfen und speichern" : {
"localizations" : { "localizations" : {
@@ -1537,6 +1568,7 @@
} }
}, },
"Server" : { "Server" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1558,6 +1590,9 @@
} }
} }
} }
},
"SMTP-Server" : {
}, },
"Sofort bei Auswahl" : { "Sofort bei Auswahl" : {
"localizations" : { "localizations" : {
@@ -1612,6 +1647,7 @@
} }
}, },
"Testmail senden" : { "Testmail senden" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1622,6 +1658,7 @@
} }
}, },
"Testmail senden?" : { "Testmail senden?" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1661,6 +1698,7 @@
} }
}, },
"Verbindung testen" : { "Verbindung testen" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1685,8 +1723,12 @@
} }
} }
} }
},
"Von:" : {
}, },
"Von: %@" : { "Von: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
+17 -1
View File
@@ -9,9 +9,20 @@ struct MailAccount: Identifiable, Codable, Hashable, Sendable {
var email = "" var email = ""
var username = "" var username = ""
var host = "" var host = ""
var smtpHost: String?
var smtpPort: UInt16 = 587 var smtpPort: UInt16 = 587
var imapPort: UInt16 = 993 var imapPort: UInt16 = 993
/// Older account archives only contain `host`. Falling back keeps those
/// accounts usable until a separate SMTP server is entered.
var smtpServer: String {
get {
let value = smtpHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? host : value
}
set { smtpHost = newValue }
}
var senderDisplay: String { var senderDisplay: String {
let value = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let value = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? email : "\(value) <\(email)>" return value.isEmpty ? email : "\(value) <\(email)>"
@@ -41,6 +52,11 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable {
|| ["drafts", "entwürfe", "entwuerfe"].contains(name.lowercased()) || ["drafts", "entwürfe", "entwuerfe"].contains(name.lowercased())
} }
var isSpam: Bool {
specialUse?.contains("\\junk") == true
|| ["junk", "spam", "junk-e-mail", "unerwünscht"].contains(name.lowercased())
}
var icon: String { var icon: String {
if isInbox { return "tray" } if isInbox { return "tray" }
if isTrash { return "trash" } if isTrash { return "trash" }
@@ -54,7 +70,7 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable {
if uses.contains("\\drafts") || value.contains("draft") || value.contains("entwurf") { if uses.contains("\\drafts") || value.contains("draft") || value.contains("entwurf") {
return "doc.text" return "doc.text"
} }
if uses.contains("\\junk") || value.contains("junk") || value.contains("spam") { if isSpam {
return "exclamationmark.octagon" return "exclamationmark.octagon"
} }
if uses.contains("\\archive") || value.contains("archive") || value.contains("archiv") { if uses.contains("\\archive") || value.contains("archive") || value.contains("archiv") {
+3 -3
View File
@@ -56,8 +56,8 @@ enum MailPrinter {
let headerHTML = """ 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: 9pt; color: #000; margin: 0;"> <body style="font-family: -apple-system; font-size: 7pt; color: #000; margin: 0;">
<table width="100%" cellspacing="0" cellpadding="0" style="font-size: 9pt; line-height: 1.25;"> <table width="100%" cellspacing="0" cellpadding="0" style="font-size: 7pt; 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;">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;">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> <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>
@@ -69,7 +69,7 @@ enum MailPrinter {
let bodyHTML = """ let bodyHTML = """
<!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: 9.5pt; color: #000; margin: 0; line-height: 1.35;">\(body)</body></html> <body style="font-family: -apple-system; font-size: 7.5pt; color: #000; margin: 0; line-height: 1.35;">\(body)</body></html>
""" """
guard let header = try? NSAttributedString( guard let header = try? NSAttributedString(
+27
View File
@@ -65,6 +65,8 @@ final class MailStore {
guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") } guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") }
guard MIME.validAddress(account.email), !account.username.isEmpty, guard MIME.validAddress(account.email), !account.username.isEmpty,
!account.host.isEmpty, !account.host.contains(where: { $0.isWhitespace || $0 == "/" }), !account.host.isEmpty, !account.host.contains(where: { $0.isWhitespace || $0 == "/" }),
!account.smtpServer.isEmpty,
!account.smtpServer.contains(where: { $0.isWhitespace || $0 == "/" }),
account.imapPort > 0, account.smtpPort > 0 else { account.imapPort > 0, account.smtpPort > 0 else {
throw MailError.message("Bitte E-Mail-Adresse, Benutzername, Server und Ports prüfen.") throw MailError.message("Bitte E-Mail-Adresse, Benutzername, Server und Ports prüfen.")
} }
@@ -538,6 +540,29 @@ final class MailStore {
} }
} }
func selectionCanRestoreToInbox(_ ids: [String]) -> Bool {
!ids.isEmpty && ids.allSatisfy { id in
guard let record = try? storage().record(id: id),
let source = folder(id: record.folderID) else { return false }
return source.isTrash || source.isSpam
}
}
func restoreToInbox(ids: [String]) async {
await mutate(refreshAfter: false) {
for id in Set(ids) {
let record = try self.storage().record(id: id)
guard let source = self.folder(id: record.folderID), source.isTrash || source.isSpam else {
throw MailError.message("Die Nachricht befindet sich weder im Papierkorb noch im Spam-Ordner.")
}
guard let inbox = self.allFolders(self.folders[source.accountID] ?? []).first(where: \.isInbox) else {
throw MailError.message("Für das ursprüngliche Konto wurde kein Posteingang gefunden.")
}
try await self.moveOne(id: id, to: inbox)
}
}
}
func createFolder(accountID: UUID, parent: MailFolder?, name: String) async { func createFolder(accountID: UUID, parent: MailFolder?, name: String) async {
await mutate { await mutate {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines) let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -587,6 +612,8 @@ final class MailStore {
func testConnection(account: MailAccount, password: String) async throws { func testConnection(account: MailAccount, password: String) async throws {
guard MIME.validAddress(account.email), !account.host.isEmpty, guard MIME.validAddress(account.email), !account.host.isEmpty,
!account.host.contains(where: { $0.isWhitespace || "/:@".contains($0) }), !account.host.contains(where: { $0.isWhitespace || "/:@".contains($0) }),
!account.smtpServer.isEmpty,
!account.smtpServer.contains(where: { $0.isWhitespace || "/:@".contains($0) }),
!account.username.isEmpty else { throw MailError.message("Bitte gültige Kontodaten eingeben.") } !account.username.isEmpty else { throw MailError.message("Bitte gültige Kontodaten eingeben.") }
let secret = password.isEmpty ? try PasswordStore.password(for: account.id) : password let secret = password.isEmpty ? try PasswordStore.password(for: account.id) : password
let client = try IMAPClient(account: account) let client = try IMAPClient(account: account)
+5 -2
View File
@@ -109,9 +109,12 @@ final class TLSConnection {
enum SMTPClient { enum SMTPClient {
static func check(account: MailAccount, password: String) async throws { static func check(account: MailAccount, password: String) async throws {
let host = account.smtpServer
let port = account.smtpPort
let username = account.username
let result = await Task.detached(priority: .userInitiated) { let result = await Task.detached(priority: .userInitiated) {
var error = [CChar](repeating: 0, count: 1024) var error = [CChar](repeating: 0, count: 1024)
let code = mail_smtp_check(account.host, account.smtpPort, account.username, password, &error, error.count) let code = mail_smtp_check(host, port, username, password, &error, error.count)
return (code, String(cString: error)) return (code, String(cString: error))
}.value }.value
guard result.0 == 0 else { throw MailError.message("SMTP-Verbindungstest: \(result.1)") } guard result.0 == 0 else { throw MailError.message("SMTP-Verbindungstest: \(result.1)") }
@@ -121,7 +124,7 @@ enum SMTPClient {
guard MIME.validAddress(account.email), !recipients.isEmpty, recipients.allSatisfy(MIME.validAddress) else { guard MIME.validAddress(account.email), !recipients.isEmpty, recipients.allSatisfy(MIME.validAddress) else {
throw MailError.message("Ungültige E-Mail-Adresse.") throw MailError.message("Ungültige E-Mail-Adresse.")
} }
let host = account.host let host = account.smtpServer
let port = account.smtpPort let port = account.smtpPort
let username = account.username let username = account.username
let sender = account.email let sender = account.email
+1 -1
View File
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
enum MessageCommand { enum MessageCommand {
case open, reply, forward, delete, read, unread case open, reply, forward, delete, restoreToInbox, read, unread
} }
struct ComposeDraft: Identifiable { struct ComposeDraft: Identifiable {