diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17daccd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Xcode user-specific interface state +*.xcuserstate + diff --git a/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate b/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index 2a0987a..0000000 Binary files a/MikeMail.xcodeproj/project.xcworkspace/xcuserdata/michael.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/MikeMail/AccountOrderDrop.swift b/MikeMail/AccountOrderDrop.swift index 330f480..411a3db 100644 --- a/MikeMail/AccountOrderDrop.swift +++ b/MikeMail/AccountOrderDrop.swift @@ -169,15 +169,41 @@ struct MailSidebarList: NSViewRepresentable { texts.spacing = 2 let title = NSTextField(labelWithString: entry.title) title.lineBreakMode = .byTruncatingTail - title.font = .systemFont(ofSize: 13) - texts.addArrangedSubview(title) 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) email.font = .systemFont(ofSize: 11) email.textColor = .secondaryLabelColor email.lineBreakMode = .byTruncatingTail texts.addArrangedSubview(email) cell.setAccessibilityIdentifier("accountRow-" + account.email) + } else { + title.font = .systemFont(ofSize: 13) + texts.addArrangedSubview(title) } texts.setContentHuggingPriority(.defaultLow, for: .horizontal) stack.addArrangedSubview(texts) diff --git a/MikeMail/AccountsView.swift b/MikeMail/AccountsView.swift index 31d8389..825ec0b 100644 --- a/MikeMail/AccountsView.swift +++ b/MikeMail/AccountsView.swift @@ -13,12 +13,7 @@ struct AccountsView: View { @State private var confirmingReset = false @State private var resetResult: String? @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 = [] - @AppStorage("offerTestMail") private var offerTestMail = true var body: some View { VStack(spacing: 0) { @@ -67,12 +62,8 @@ struct AccountsView: View { HStack { Spacer() if testing { ProgressView().controlSize(.small) } - Button("Verbindung testen") { testConnection() } - .disabled(selection == nil || testing) Button("Speichern") { save() } - .disabled(selection == nil || testing || store.isBusy || - (newAccount?.id == selection && (testedAccount != selection.flatMap { drafts[$0] } || - testedPassword != (selection.flatMap { passwords[$0] } ?? "")))) + .disabled(selection == nil || testing || store.isBusy) .keyboardShortcut(.defaultAction) } } @@ -100,22 +91,9 @@ struct AccountsView: View { .padding(.horizontal, 16) .padding(.vertical, 22) } - .frame(width: 850, height: 600) + .frame(width: 1_000, height: 820) .interactiveDismissDisabled(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 { drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) }) selection = store.accounts.first?.id @@ -148,22 +126,32 @@ struct AccountsView: View { private func save() { guard let id = selection, let account = drafts[id] else { return } - do { - let isNew = !store.accounts.contains { $0.id == id } - try store.save(account: account, password: passwords[id] ?? "") - passwords[id] = nil - if newAccount?.id == id { newAccount = nil } - if isNew { - Task { - while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) } - await store.refresh(folderID: nil, accountID: id) - if offerTestMail { testMailAccount = account; askingTestMail = true } + let password = passwords[id] ?? "" + testing = true + error = nil + resetResult = nil + Task { + do { + try await store.testConnection(account: account, password: password) + let isNew = !store.accounts.contains { $0.id == id } + try store.save(account: account, password: password) + passwords[id] = nil + if newAccount?.id == id { newAccount = nil } + if isNew { + Task { + while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) } + await store.refresh(folderID: nil, accountID: id) + } + } else { + accountsNeedingRefresh.insert(id) } - } else { - accountsNeedingRefresh.insert(id) + testing = false + dismiss() + } catch { + self.error = error.localizedDescription + testing = false } - error = nil - } catch { self.error = error.localizedDescription } + } } 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() { guard let id = selection else { return } do { @@ -246,31 +217,20 @@ struct AccountSettingsForm: View { .frame(maxWidth: .infinity) .accessibilityIdentifier("accountEmail") } + Divider() + .gridCellColumns(2) + .padding(.vertical, 4) + GridRow { + Text("Login-Daten") + .font(.headline) + .gridCellColumns(2) + } 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)) - .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) { @@ -290,6 +250,47 @@ struct AccountSettingsForm: View { } .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) } diff --git a/MikeMail/ContentView.swift b/MikeMail/ContentView.swift index c647f23..778b620 100644 --- a/MikeMail/ContentView.swift +++ b/MikeMail/ContentView.swift @@ -117,6 +117,7 @@ struct ContentView: View { selection: $selectedMessages, busy: store.isBusy, accountIndicators: accountIndicators, + canRestoreToInbox: { store.selectionCanRestoreToInbox(Array($0)) }, action: perform ) .onDeleteCommand { deleteSelection() } @@ -277,6 +278,8 @@ struct ContentView: View { } case .delete: requestDeletion(Array(ids)) + case .restoreToInbox: + Task { await store.restoreToInbox(ids: Array(ids)) } case .read, .unread: Task { await store.setRead(ids: Array(ids), read: command == .read) } } @@ -450,6 +453,7 @@ struct MessageTable: View { @Binding var selection: Set var busy = false var accountIndicators: [UUID: AccountIndicator] = [:] + var canRestoreToInbox: (Set) -> Bool = { _ in false } var action: (MessageCommand, Set) -> Void = { _, _ in } @AppStorage("mailSortColumn") private var savedColumn = "date" @AppStorage("mailSortAscending") private var savedAscending = false @@ -507,6 +511,13 @@ struct MessageTable: View { transaction.animation = nil } .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 { Button("In neuem Fenster öffnen") { action(.open, ids) } MessageActionButtons { action($0, ids) } @@ -568,15 +579,8 @@ struct MessageReader: View { ScrollView { if let message { VStack(alignment: .leading, spacing: 16) { - Text(message.subject).font(.title2).bold().textSelection(.enabled) - VStack(alignment: .leading, spacing: 5) { - 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) + MessageHeader(subject: message.subject, sender: message.sender, + recipient: message.recipient, cc: message.cc, date: message.date) Divider() if let html = message.htmlBody { 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 { static let settingKey = "warnMissingAttachment" diff --git a/MikeMail/IMAPClient.swift b/MikeMail/IMAPClient.swift index c813ee2..58d1e50 100644 --- a/MikeMail/IMAPClient.swift +++ b/MikeMail/IMAPClient.swift @@ -17,7 +17,13 @@ final class IMAPClient { let greeting = try await connection.readLine() if greeting.uppercased().hasPrefix("* PREAUTH") { return } guard greeting.uppercased().hasPrefix("* OK") else { throw MailError.message("IMAP: \(greeting)") } - _ = try await command("LOGIN \(Self.quote(account.username)) \(Self.quote(password))") + 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))") + } } static func quote(_ value: String) throws -> String { diff --git a/MikeMail/Localizable.xcstrings b/MikeMail/Localizable.xcstrings index 10a3b69..221fe36 100644 --- a/MikeMail/Localizable.xcstrings +++ b/MikeMail/Localizable.xcstrings @@ -361,8 +361,12 @@ }, "An (mehrere mit Semikolon trennen)" : { + }, + "An:" : { + }, "An: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -514,6 +518,9 @@ } } } + }, + "Betreff:" : { + }, "CC" : { "extractionState" : "stale", @@ -528,8 +535,12 @@ }, "CC (mehrere mit Semikolon trennen)" : { + }, + "CC:" : { + }, "CC: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -580,6 +591,9 @@ } } } + }, + "Datum:" : { + }, "Der Inhalt und die Anhänge erscheinen hier." : { "localizations" : { @@ -714,6 +728,7 @@ } }, "Eine Testnachricht an %@ senden?" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -874,6 +889,9 @@ } } } + }, + "IMAP-Server" : { + }, "Immer" : { "localizations" : { @@ -894,6 +912,9 @@ } } } + }, + "In Posteingang verschieben" : { + }, "Inhalt" : { "extractionState" : "stale", @@ -1015,6 +1036,9 @@ }, "Lesestatus" : { + }, + "Login-Daten" : { + }, "Lokale Mails endgültig löschen" : { "localizations" : { @@ -1224,6 +1248,7 @@ } }, "Nein" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1443,6 +1468,12 @@ }, "Passwörter in das Backup aufnehmen" : { + }, + "Postausgang (SMTP)" : { + + }, + "Posteingang (IMAP)" : { + }, "Prüfen und speichern" : { "localizations" : { @@ -1537,6 +1568,7 @@ } }, "Server" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1558,6 +1590,9 @@ } } } + }, + "SMTP-Server" : { + }, "Sofort bei Auswahl" : { "localizations" : { @@ -1612,6 +1647,7 @@ } }, "Testmail senden" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1622,6 +1658,7 @@ } }, "Testmail senden?" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1661,6 +1698,7 @@ } }, "Verbindung testen" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1685,8 +1723,12 @@ } } } + }, + "Von:" : { + }, "Von: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/MikeMail/MailModels.swift b/MikeMail/MailModels.swift index 17e7ee2..588d3fc 100644 --- a/MikeMail/MailModels.swift +++ b/MikeMail/MailModels.swift @@ -9,9 +9,20 @@ struct MailAccount: Identifiable, Codable, Hashable, Sendable { var email = "" var username = "" var host = "" + var smtpHost: String? var smtpPort: UInt16 = 587 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 { let value = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return value.isEmpty ? email : "\(value) <\(email)>" @@ -41,6 +52,11 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable { || ["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 { if isInbox { return "tray" } if isTrash { return "trash" } @@ -54,7 +70,7 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable { if uses.contains("\\drafts") || value.contains("draft") || value.contains("entwurf") { return "doc.text" } - if uses.contains("\\junk") || value.contains("junk") || value.contains("spam") { + if isSpam { return "exclamationmark.octagon" } if uses.contains("\\archive") || value.contains("archive") || value.contains("archiv") { diff --git a/MikeMail/MailPrinter.swift b/MikeMail/MailPrinter.swift index 9978351..86ab176 100644 --- a/MikeMail/MailPrinter.swift +++ b/MikeMail/MailPrinter.swift @@ -56,8 +56,8 @@ enum MailPrinter { let headerHTML = """ - - + +
@@ -69,7 +69,7 @@ enum MailPrinter { let bodyHTML = """ - \(body) + \(body) """ guard let header = try? NSAttributedString( diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift index aee66a4..38f4d5d 100644 --- a/MikeMail/MailStore.swift +++ b/MikeMail/MailStore.swift @@ -65,6 +65,8 @@ final class MailStore { guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") } guard MIME.validAddress(account.email), !account.username.isEmpty, !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 { 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 { await mutate { let name = name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -587,6 +612,8 @@ final class MailStore { func testConnection(account: MailAccount, password: String) async throws { guard MIME.validAddress(account.email), !account.host.isEmpty, !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.") } let secret = password.isEmpty ? try PasswordStore.password(for: account.id) : password let client = try IMAPClient(account: account) diff --git a/MikeMail/MailTransport.swift b/MikeMail/MailTransport.swift index bf67c69..652a0f7 100644 --- a/MikeMail/MailTransport.swift +++ b/MikeMail/MailTransport.swift @@ -109,9 +109,12 @@ final class TLSConnection { enum SMTPClient { 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) { 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)) }.value 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 { throw MailError.message("Ungültige E-Mail-Adresse.") } - let host = account.host + let host = account.smtpServer let port = account.smtpPort let username = account.username let sender = account.email diff --git a/MikeMail/MessageActions.swift b/MikeMail/MessageActions.swift index abfe5f0..a0f1bb9 100644 --- a/MikeMail/MessageActions.swift +++ b/MikeMail/MessageActions.swift @@ -1,7 +1,7 @@ import SwiftUI enum MessageCommand { - case open, reply, forward, delete, read, unread + case open, reply, forward, delete, restoreToInbox, read, unread } struct ComposeDraft: Identifiable {
Betreff:\(escaped(message.subject))
Von:\(escaped(message.sender))
An:\(escaped(message.recipient))