diff --git a/MikeMail/AccountOrderDrop.swift b/MikeMail/AccountOrderDrop.swift new file mode 100644 index 0000000..8e12736 --- /dev/null +++ b/MikeMail/AccountOrderDrop.swift @@ -0,0 +1,279 @@ +import SwiftUI + +/// Flattened tree entries give each account and folder its own native table row and drop target. +struct SidebarEntry: Identifiable, Equatable { + let id: String + var account: MailAccount? + var folder: MailFolder? + var depth = 0 + var title: String + var icon: String + + static func visible(accounts: [MailAccount], folders: [UUID: [MailFolder]], inbox: Bool, trash: Bool, + expanded: (String) -> Bool) -> [SidebarEntry] { + var entries: [SidebarEntry] = [] + if inbox { entries.append(SidebarEntry(id: "unified:inbox", title: "Gemeinsamer Posteingang", icon: "tray.2")) } + if trash { entries.append(SidebarEntry(id: "unified:trash", title: "Gemeinsamer Papierkorb", icon: "trash")) } + func appendFolders(_ children: [MailFolder], depth: Int) { + for folder in children { + entries.append(SidebarEntry(id: folder.id, folder: folder, depth: depth, + title: folder.name, icon: folder.isTrash ? "trash" : folder.icon)) + if expanded("folderExpanded." + folder.id), let children = folder.children { + appendFolders(children, depth: depth + 1) + } + } + } + for account in accounts { + entries.append(SidebarEntry(id: "account:" + account.id.uuidString, account: account, + title: account.name.isEmpty ? account.email : account.name, icon: "person.crop.circle")) + if expanded("accountExpanded." + account.id.uuidString) { + appendFolders(folders[account.id] ?? [], depth: 1) + } + } + return entries + } + + static func destination(_ offset: Int, rows: [SidebarEntry], accounts: [MailAccount]) -> Int { + guard offset < rows.count else { return accounts.count } + // A drop within an account's folders goes before the next account, keeping the whole tree together. + let next = rows.dropFirst(max(0, offset)).compactMap(\.account).first + return next.flatMap { account in accounts.firstIndex { $0.id == account.id } } ?? accounts.count + } +} + + +struct MailSidebarList: NSViewRepresentable { + let store: MailStore + @Binding var selection: String? + let showInbox: Bool + let showTrash: Bool + let create: (UUID, MailFolder?) -> Void + let delete: (MailFolder) -> Void + let empty: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSScrollView() + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + let table = NSTableView() + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("sidebar")) + table.addTableColumn(column) + table.headerView = nil + table.backgroundColor = .clear + table.selectionHighlightStyle = .sourceList + table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + table.intercellSpacing = NSSize(width: 0, height: 2) + table.delegate = context.coordinator + table.dataSource = context.coordinator + table.setDraggingSourceOperationMask(.move, forLocal: true) + table.registerForDraggedTypes([Coordinator.accountType, .string]) + table.setAccessibilityIdentifier("accountSidebar") + let menu = NSMenu() + menu.delegate = context.coordinator + table.menu = menu + scroll.documentView = table + context.coordinator.table = table + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.parent = self + context.coordinator.reload() + } + + final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate { + static let accountType = NSPasteboard.PasteboardType("de.mikemail.account-order") + var parent: MailSidebarList + weak var table: NSTableView? + var rows: [SidebarEntry] = [] + var reloading = false + var displayedCounts: [String: Int] = [:] + + init(_ parent: MailSidebarList) { self.parent = parent } + + func expanded(_ key: String) -> Bool { + UserDefaults.standard.object(forKey: key) as? Bool ?? true + } + + func reload(force: Bool = false) { + reloading = true + let updated = SidebarEntry.visible(accounts: parent.store.accounts, folders: parent.store.folders, + inbox: parent.showInbox, trash: parent.showTrash, expanded: expanded) + let counts = parent.store.unreadCounts + if force || updated != rows || counts != displayedCounts { + rows = updated + displayedCounts = counts + table?.reloadData() + } + if let index = rows.firstIndex(where: { $0.id == parent.selection }) { + if table?.selectedRow != index { table?.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) } + } else if table?.selectedRow != -1 { table?.deselectAll(nil) } + reloading = false + } + + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + rows[row].account == nil ? 30 : 46 + } + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + rows[row].folder?.selectable != false + } + func tableViewSelectionDidChange(_ notification: Notification) { + guard !reloading, let table, rows.indices.contains(table.selectedRow) else { return } + parent.selection = rows[table.selectedRow].id + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let entry = rows[row] + let cell = NSTableCellView() + let stack = NSStackView() + stack.orientation = .horizontal + stack.distribution = .fill + stack.alignment = .centerY + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8 + CGFloat(entry.depth) * 12), + stack.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8), + stack.centerYAnchor.constraint(equalTo: cell.centerYAnchor) + ]) + let key = entry.account.map { "accountExpanded." + $0.id.uuidString } + ?? (entry.folder?.children?.isEmpty == false ? "folderExpanded." + entry.id : nil) + if let key { + let button = NSButton(image: NSImage(systemSymbolName: expanded(key) ? "chevron.down" : "chevron.right", + accessibilityDescription: expanded(key) ? "Zuklappen" : "Aufklappen") ?? NSImage(), + target: self, action: #selector(toggle(_:))) + button.isBordered = false + button.identifier = NSUserInterfaceItemIdentifier(key) + button.setAccessibilityIdentifier(key) + button.widthAnchor.constraint(equalToConstant: 16).isActive = true + stack.addArrangedSubview(button) + } else { + let spacer = NSView() + spacer.widthAnchor.constraint(equalToConstant: 16).isActive = true + stack.addArrangedSubview(spacer) + } + let icon = NSImageView(image: NSImage(systemSymbolName: entry.icon, accessibilityDescription: nil) ?? NSImage()) + icon.contentTintColor = .controlAccentColor + icon.widthAnchor.constraint(equalToConstant: 18).isActive = true + stack.addArrangedSubview(icon) + let texts = NSStackView() + texts.orientation = .vertical + texts.alignment = .leading + 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 { + 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) + } + texts.setContentHuggingPriority(.defaultLow, for: .horizontal) + stack.addArrangedSubview(texts) + if entry.account != nil { + let handle = NSImageView(image: NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: "Konto verschieben") ?? NSImage()) + handle.widthAnchor.constraint(equalToConstant: 18).isActive = true + handle.toolTip = "Kontenzeile an die gewünschte Position ziehen" + stack.addArrangedSubview(handle) + } else { + let count = parent.store.unreadCount(folderID: entry.id) + if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) } + } + return cell + } + + @objc func toggle(_ sender: NSButton) { + guard let key = sender.identifier?.rawValue else { return } + UserDefaults.standard.set(!expanded(key), forKey: key) + reload(force: true) + } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { + guard let account = rows[row].account else { return nil } + let item = NSPasteboardItem() + item.setString(account.id.uuidString, forType: Self.accountType) + return item + } + + func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo, + proposedRow row: Int, proposedDropOperation operation: NSTableView.DropOperation) -> NSDragOperation { + if info.draggingPasteboard.string(forType: Self.accountType) != nil { + tableView.setDropRow(row, dropOperation: .above) + return .move + } + if rows.indices.contains(row), rows[row].folder?.selectable == true, + let value = info.draggingPasteboard.string(forType: .string), !MailDrag.decode(value).isEmpty { + tableView.setDropRow(row, dropOperation: .on) + return .move + } + return [] + } + + func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, + row: Int, dropOperation: NSTableView.DropOperation) -> Bool { + if let value = info.draggingPasteboard.string(forType: Self.accountType), + let id = UUID(uuidString: value), + let source = parent.store.accounts.firstIndex(where: { $0.id == id }) { + parent.store.moveAccounts(from: IndexSet(integer: source), + to: SidebarEntry.destination(row, rows: rows, accounts: parent.store.accounts)) + reload() + return true + } + guard rows.indices.contains(row), let folder = rows[row].folder, + folder.selectable, let value = info.draggingPasteboard.string(forType: .string) else { return false } + let ids = MailDrag.decode(value) + guard !ids.isEmpty else { return false } + Task { await parent.store.moveMessages(ids: ids, to: folder) } + return true + } + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let table, rows.indices.contains(table.clickedRow) else { return } + let row = rows[table.clickedRow] + func add(_ title: String, _ action: String, enabled: Bool = true) { + let item = NSMenuItem(title: title, action: #selector(menuAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = [row.id, action] + item.isEnabled = enabled + menu.addItem(item) + } + menu.autoenablesItems = false + if let account = row.account, let index = parent.store.accounts.firstIndex(where: { $0.id == account.id }) { + add("Neuen Ordner erstellen…", "create") + add("Konto nach oben", "up", enabled: index > 0) + add("Konto nach unten", "down", enabled: index + 1 < parent.store.accounts.count) + } else if let folder = row.folder { + add("Unterordner erstellen…", "create") + add("Ordner löschen…", "delete", enabled: !folder.isInbox) + if folder.isTrash { add("Papierkorb leeren…", "empty") } + } else if row.id == "unified:trash" { add("Papierkorb leeren…", "empty") } + } + + @objc func menuAction(_ item: NSMenuItem) { + guard let values = item.representedObject as? [String], values.count == 2, + let row = rows.first(where: { $0.id == values[0] }) else { return } + switch values[1] { + case "create": + if let account = row.account { parent.create(account.id, nil) } + else if let folder = row.folder { parent.create(folder.accountID, folder) } + case "delete": if let folder = row.folder { parent.delete(folder) } + case "empty": parent.empty(row.id) + case "up", "down": + if let account = row.account, let index = parent.store.accounts.firstIndex(where: { $0.id == account.id }) { + parent.store.moveAccounts(from: IndexSet(integer: index), to: values[1] == "up" ? index - 1 : index + 2) + reload() + } + default: break + } + } + } +} diff --git a/MikeMail/ContentView.swift b/MikeMail/ContentView.swift index b29ec61..6b8c82e 100644 --- a/MikeMail/ContentView.swift +++ b/MikeMail/ContentView.swift @@ -17,12 +17,12 @@ struct ContentView: View { @State private var selectedFolder: String? @State private var selectedMessages: Set = [] @State private var showingAccounts = false - @State private var composing = false - @State private var draft: ComposeDraft? @Environment(\.openWindow) private var openWindow @AppStorage("idleEnabled") private var idleEnabled = true @AppStorage("pollMinutes") private var pollMinutes = 5 @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always + @AppStorage("markReadImmediately") private var markReadImmediately = false + @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 @State private var pendingDeletion: [String] = [] @State private var confirmingDelete = false @State private var restored = false @@ -71,7 +71,6 @@ struct ContentView: View { } .frame(height: 26) .padding(8) - .disabled(store.isBusy) VSplitView { MessageTable(messages: store.messages.filter(searchFilter.matches), selection: $selectedMessages, busy: store.isBusy, action: perform) .onDeleteCommand { deleteSelection() } @@ -116,17 +115,15 @@ struct ContentView: View { } .accessibilityIdentifier("Abrufen") .disabled(store.isBusy) - Button("Neue Nachricht", systemImage: "square.and.pencil") { composing = true } + Button("Neue Nachricht", systemImage: "square.and.pencil") { openComposer(ComposeDraft(accountID: composeAccountID)) } .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty) Button("Löschen", systemImage: "trash") { deleteSelection() } - .disabled(selectedMessages.isEmpty || store.isBusy) + .disabled(selectedMessages.isEmpty) Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true } SettingsLink { Label("Einstellungen", systemImage: "gearshape") } } } .sheet(isPresented: $showingAccounts) { AccountsView(store: store) } - .sheet(isPresented: $composing) { ComposeView(store: store, defaultAccountID: composeAccountID) } - .sheet(item: $draft) { ComposeView(store: store, draft: $0) } .sheet(item: Binding(get: { store.authenticationAccount }, set: { value in if value == nil { store.dismissPasswordRequest() } })) { account in @@ -136,11 +133,13 @@ struct ContentView: View { store.configureIdle(enabled: idleEnabled) } .task(id: pollMinutes) { await store.poll(intervalMinutes: pollMinutes) } - .task(id: selectedMessages) { + .task(id: AutoReadRequest(ids: selectedMessages, immediately: markReadImmediately, seconds: markReadDelaySeconds)) { guard selectedMessages.count == 1, let id = selectedMessages.first, let message = store.message(id: id), !message.isRead else { return } - try? await Task.sleep(for: .milliseconds(700)) - if !Task.isCancelled { await store.setRead(ids: [id], read: true) } + do { + try await AutoReadRequest(ids: [id], immediately: markReadImmediately, seconds: markReadDelaySeconds).wait() + await store.setRead(ids: [id], read: true) + } catch { /* Selection changed before the read delay elapsed. */ } } .alert(pendingDeletion.count == 1 ? "Nachricht löschen?" : "\(pendingDeletion.count) Nachrichten löschen?", isPresented: $confirmingDelete) { Button("Abbrechen", role: .cancel) {} @@ -179,6 +178,10 @@ struct ContentView: View { .frame(minWidth: 850, minHeight: 550) } + private func openComposer(_ draft: ComposeDraft) { + openWindow(id: "compose", value: store.prepareCompose(draft)) + } + private func perform(_ command: MessageCommand, _ ids: Set) { guard !ids.isEmpty else { return } switch command { @@ -186,7 +189,7 @@ struct ContentView: View { if ids.count == 1, let id = ids.first { openWindow(id: "message", value: id) } case .reply, .forward: if ids.count == 1, let id = ids.first, let message = store.message(id: id) { - draft = .from(message, accountID: store.accountID(for: id), forwarding: command == .forward) + openComposer(.from(message, accountID: store.accountID(for: id), forwarding: command == .forward)) } case .delete: requestDeletion(Array(ids)) @@ -196,12 +199,12 @@ struct ContentView: View { } private func deleteSelection() { - guard !store.isBusy, !selectedMessages.isEmpty else { return } + guard !selectedMessages.isEmpty else { return } requestDeletion(Array(selectedMessages)) } private func requestDeletion(_ ids: [String]) { - guard !store.isBusy, !ids.isEmpty else { return } + guard !ids.isEmpty else { return } if deleteConfirmation.isRequired(count: ids.count) { pendingDeletion = ids confirmingDelete = true @@ -258,22 +261,10 @@ struct AccountSidebar: View { @State private var confirmingTrash = false var body: some View { - List(selection: $selection) { - if showInbox { - HStack { Label("Gemeinsamer Posteingang", systemImage: "tray.2"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:inbox")) }.tag("unified:inbox") - } - if showTrash { - HStack { Label("Gemeinsamer Papierkorb", systemImage: "trash"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:trash")) }.tag("unified:trash") - .contextMenu { Button("Papierkorb leeren…") { confirmTrash("unified:trash") }.disabled(store.isBusy) } - } - ForEach(store.accounts) { account in - AccountTree(store: store, account: account, selection: $selection, - create: { parent in beginCreate(account.id, parent: parent) }, - delete: { folder in deleting = folder; confirmingFolder = true }, - empty: { confirmTrash($0.id) }) - } - } - .listStyle(.sidebar) + MailSidebarList(store: store, selection: $selection, showInbox: showInbox, showTrash: showTrash, + create: { beginCreate($0, parent: $1) }, + delete: { deleting = $0; confirmingFolder = true }, + empty: confirmTrash) .onChange(of: showInbox) { _, visible in if !visible && selection == "unified:inbox" { selection = nil } } .onChange(of: showTrash) { _, visible in if !visible && selection == "unified:trash" { selection = nil } } .alert("Neuen Ordner erstellen", isPresented: $creating) { @@ -308,113 +299,6 @@ struct AccountSidebar: View { private func confirmTrash(_ id: String) { trashID = id; confirmingTrash = true } } -struct AccountTree: View { - let store: MailStore - let account: MailAccount - @Binding var selection: String? - let create: (MailFolder?) -> Void - let delete: (MailFolder) -> Void - let empty: (MailFolder) -> Void - @AppStorage private var expanded: Bool - - init(store: MailStore, account: MailAccount, selection: Binding, - create: @escaping (MailFolder?) -> Void, delete: @escaping (MailFolder) -> Void, - empty: @escaping (MailFolder) -> Void) { - self.store = store; self.account = account; _selection = selection - self.create = create; self.delete = delete; self.empty = empty - _expanded = AppStorage(wrappedValue: true, "accountExpanded.\(account.id)") - } - - var body: some View { - DisclosureGroup(isExpanded: $expanded) { - ForEach(store.folders[account.id] ?? []) { folder in - FolderTree(store: store, folder: folder, create: create, delete: delete, empty: empty) - } - if store.folders[account.id] == nil { - Text("Zum Laden „Abrufen“ wählen").font(.caption).foregroundStyle(.secondary).selectionDisabled() - } - } label: { - Label { - VStack(alignment: .leading) { - Text(account.name.isEmpty ? account.email : account.name) - Text(account.email).font(.caption).foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "person.crop.circle") - } - .draggable("mikemail-account:" + account.id.uuidString) - .dropDestination(for: String.self) { values, _ in - guard !store.isBusy, let value = values.first, value.hasPrefix("mikemail-account:"), - let id = UUID(uuidString: String(value.dropFirst("mikemail-account:".count))) else { return false } - store.reorderAccount(id, before: account.id) - return true - } - .contextMenu { - Button("Neuen Ordner erstellen…") { create(nil) } - if let index = store.accounts.firstIndex(where: { $0.id == account.id }) { - Button("Konto nach oben") { - store.reorderAccount(account.id, before: store.accounts[index - 1].id) - }.disabled(index == 0) - Button("Konto nach unten") { - store.reorderAccount(store.accounts[index + 1].id, before: account.id) - }.disabled(index + 1 == store.accounts.count) - } - } - .disabled(store.isBusy) - } - .tag("account:" + account.id.uuidString) - } -} - -struct FolderTree: View { - let store: MailStore - let folder: MailFolder - let create: (MailFolder?) -> Void - let delete: (MailFolder) -> Void - let empty: (MailFolder) -> Void - @AppStorage private var expanded: Bool - - init(store: MailStore, folder: MailFolder, create: @escaping (MailFolder?) -> Void, - delete: @escaping (MailFolder) -> Void, empty: @escaping (MailFolder) -> Void) { - self.store = store; self.folder = folder - self.create = create; self.delete = delete; self.empty = empty - _expanded = AppStorage(wrappedValue: true, "folderExpanded.\(folder.id)") - } - var body: some View { - Group { - if let children = folder.children, !children.isEmpty { - DisclosureGroup(isExpanded: $expanded) { - ForEach(children) { child in - FolderTree(store: store, folder: child, create: create, delete: delete, empty: empty) - } - } label: { label } - } else { label } - } - .tag(folder.id).selectionDisabled(!folder.selectable) - } - - private var label: some View { - HStack { - Label(folder.name, systemImage: folder.isTrash ? "trash" : folder.icon) - Spacer() - UnreadBadge(count: store.unreadCount(folderID: folder.id)) - } - .contentShape(Rectangle()) - .dropDestination(for: String.self) { values, _ in - guard !store.isBusy, folder.selectable else { return false } - let ids = values.flatMap(MailDrag.decode) - guard !ids.isEmpty else { return false } - Task { await store.moveMessages(ids: ids, to: folder) } - return true - } - .contextMenu { - Button("Unterordner erstellen…") { create(folder) } - Button("Ordner löschen…", role: .destructive) { delete(folder) }.disabled(folder.isInbox) - if folder.isTrash { Button("Papierkorb leeren…", role: .destructive) { empty(folder) } } - }.disabled(store.isBusy) - } -} - struct MessageTable: View { let messages: [MailMessage] @Binding var selection: Set @@ -431,12 +315,12 @@ struct MessageTable: View { Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge") } }.width(55) - TableColumn("Absender", value: \.sender) { message in - Text(message.sender).fontWeight(message.isRead ? .regular : .bold) + TableColumn("Absender", value: \.displaySender) { message in + Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold) }.width(min: 110, ideal: 160) TableColumn("Datum", value: \.date) { message in if message.date == .distantPast { Text("—") } - else { Text(message.date, format: .dateTime.day().month().year().hour().minute()).fontWeight(message.isRead ? .regular : .bold) } + else { Text(MailDisplay.date(message.date)).monospacedDigit().fontWeight(message.isRead ? .regular : .bold) } }.width(min: 120, ideal: 150) TableColumn("Betreff", value: \.subject) { message in Text(message.subject).fontWeight(message.isRead ? .regular : .bold) @@ -449,9 +333,9 @@ struct MessageTable: View { .contextMenu(forSelectionType: String.self) { ids in if ids.count == 1 { Button("In neuem Fenster öffnen") { action(.open, ids) } - MessageActionButtons { action($0, ids) }.disabled(busy) + MessageActionButtons { action($0, ids) } } else if !ids.isEmpty { - Button("Nachrichten löschen…") { action(.delete, ids) }.disabled(busy) + Button("Nachrichten löschen…") { action(.delete, ids) } } if !ids.isEmpty { Divider() @@ -462,7 +346,7 @@ struct MessageTable: View { .onAppear { let order: SortOrder = savedAscending ? .forward : .reverse switch savedColumn { - case "sender": sortOrder = [KeyPathComparator(\MailMessage.sender, order: order)] + case "sender": sortOrder = [KeyPathComparator(\MailMessage.displaySender, order: order)] case "subject": sortOrder = [KeyPathComparator(\MailMessage.subject, order: order)] case "attachments": sortOrder = [KeyPathComparator(\MailMessage.attachmentCount, order: order)] default: sortOrder = [KeyPathComparator(\MailMessage.date, order: order)] @@ -472,7 +356,7 @@ struct MessageTable: View { guard let first = value.first else { return } savedAscending = first.order == .forward switch first.keyPath { - case \MailMessage.sender: savedColumn = "sender" + case \MailMessage.displaySender: savedColumn = "sender" case \MailMessage.subject: savedColumn = "subject" case \MailMessage.attachmentCount: savedColumn = "attachments" default: savedColumn = "date" @@ -502,6 +386,8 @@ enum MailDrag { struct MessageReader: View { let message: MailMessage? + @AppStorage("bodyFontName") private var bodyFontName = "Helvetica" + @AppStorage("bodyFontSize") private var bodyFontSize = 14.0 var body: some View { ScrollView { if let message { @@ -516,7 +402,13 @@ struct MessageReader: View { } }.textSelection(.enabled) Divider() - Text(message.body).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + if let html = message.htmlBody { + HTMLMessageBody(html: html, attachments: message.attachments) + .id(message.id) + } else { + Text(message.body).font(.custom(bodyFontName, size: bodyFontSize)) + .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + } if !message.attachments.isEmpty { AttachmentList(attachments: message.attachments) .padding(16) @@ -544,13 +436,15 @@ struct ComposeView: View { @State private var cc = "" @State private var bcc = "" @State private var subject = "" - @State private var text = "" + @State private var editor = MailEditorModel() + @State private var htmlEnabled = true + @AppStorage("bodyFontName") private var bodyFontName = "Helvetica" + @AppStorage("bodyFontSize") private var bodyFontSize = 14.0 @State private var attachments: [MailAttachment] = [] @State private var importing = false @State private var sending = false @State private var initialized = false @State private var dropTargeted = false - @FocusState private var bodyFocused: Bool @State private var error: String? var body: some View { @@ -565,8 +459,12 @@ struct ComposeView: View { TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC") TextField("Betreff", text: $subject) } - TextEditor(text: $text).font(.body).frame(minHeight: 180).border(.quaternary) - .focused($bodyFocused) + Toggle("HTML-Nachricht", isOn: $htmlEnabled) + if htmlEnabled { MailFormattingBar(editor: editor) } + MailBodyEditor(model: editor, initialText: draft?.body ?? "", + fontName: bodyFontName, fontSize: bodyFontSize, focusOnOpen: draft?.isReply == true, + richText: htmlEnabled) + .frame(minHeight: 180, maxHeight: .infinity).border(.quaternary) VStack(alignment: .leading, spacing: 8) { Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline) ScrollView { @@ -602,7 +500,8 @@ struct ComposeView: View { Task { do { try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc, - subject: subject, body: text, attachments: attachments) + subject: subject, body: editor.plainText, attachments: attachments, + htmlBody: htmlEnabled ? try editor.html() : nil) dismiss() } catch { self.error = error.localizedDescription } sending = false @@ -612,7 +511,8 @@ struct ComposeView: View { .disabled(MailStore.addresses(recipient + "," + cc + "," + bcc).isEmpty || accountID == nil) } } - .padding(24).frame(width: 700, height: 740) + .padding(24) + .frame(minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 600, idealHeight: 780, maxHeight: .infinity) .disabled(sending) .interactiveDismissDisabled(sending) .onAppear { @@ -621,9 +521,7 @@ struct ComposeView: View { accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id recipient = draft?.recipient ?? "" subject = draft?.subject ?? "" - text = draft?.body ?? "" attachments = draft?.attachments ?? [] - if draft?.isReply == true { bodyFocused = true } } .fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in do { addAttachments(try result.get()) } diff --git a/MikeMail/IMAPClient.swift b/MikeMail/IMAPClient.swift index 540f917..c813ee2 100644 --- a/MikeMail/IMAPClient.swift +++ b/MikeMail/IMAPClient.swift @@ -232,8 +232,39 @@ final class IMAPClient { _ = try await command("UID STORE \(uid) \(read ? "+" : "-")FLAGS.SILENT (\\Seen)") } - func move(uid: UInt64, to path: String) async throws { - _ = try await command("UID MOVE \(uid) \(Self.quote(path))") + struct AssignedUID: Equatable { + let validity: String + let uid: String + } + + /// Single-message COPYUID/APPENDUID responses identify the local destination record. + static func assignedUID(_ lines: [String], sourceUID: UInt64? = nil) -> AssignedUID? { + let code = sourceUID == nil ? "APPENDUID" : "COPYUID" + for line in lines { + guard let start = line.range(of: "[" + code + " ", options: .caseInsensitive), + let end = line[start.upperBound...].firstIndex(of: "]") else { continue } + let parts = line[start.upperBound.. 0, + let uid = UInt64(parts[parts.count - 1]), uid > 0 else { continue } + if let sourceUID, UInt64(parts[1]) != sourceUID { continue } + return AssignedUID(validity: String(validity), uid: String(uid)) + } + return nil + } + + /// Select and inspect only the requested UID, without enumerating the mailbox. + func messageInventory(folder: MailFolder, uid: UInt64) async throws -> MailboxInventory { + let selected = try await command("SELECT \(Self.quote(folder.path))") + let validity = try Self.uidValidity(from: selected.lines) + let fetched = try await command("UID FETCH \(uid) (UID FLAGS)") + return MailboxInventory(validity: validity, flags: try Self.parseFlags(fetched.lines)) + } + + @discardableResult + func move(uid: UInt64, to path: String) async throws -> AssignedUID? { + let result = try await command("UID MOVE \(uid) \(Self.quote(path))") + return Self.assignedUID(result.lines, sourceUID: uid) } /// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail. @@ -249,7 +280,8 @@ final class IMAPClient { } } - func append(_ message: Data, path: String, flags: [String]) async throws { + @discardableResult + func append(_ message: Data, path: String, flags: [String]) async throws -> AssignedUID? { let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"] let safeFlags = flags.filter { allowed.contains($0) }.joined(separator: " ") sequence += 1 @@ -258,7 +290,8 @@ final class IMAPClient { let ready = try await connection.readLine() guard ready.hasPrefix("+") else { throw MailError.message("Nachricht konnte nicht im Ziel gespeichert werden: \(ready)") } try await connection.send(message + Data("\r\n".utf8)) - _ = try await response(tag: tag) + let result = try await response(tag: tag) + return Self.assignedUID(result.lines) } static func encodeMailbox(_ value: String) -> String { diff --git a/MikeMail/MIME.swift b/MikeMail/MIME.swift index cf48262..e992d4d 100644 --- a/MikeMail/MIME.swift +++ b/MikeMail/MIME.swift @@ -93,8 +93,9 @@ enum MIME { } return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""), recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"), - date: date ?? .distantPast, body: bodies.isEmpty ? htmlBodies.joined(separator: "\n") : bodies.joined(separator: "\n"), - attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? "")) + date: date ?? .distantPast, body: bodies.isEmpty ? plainText(htmlBodies.joined(separator: "\n")) : bodies.joined(separator: "\n"), + attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? ""), + htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n")) } private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) { @@ -127,26 +128,30 @@ enum MIME { let extendedName = parameter("filename*", in: disposition).flatMap { $0.components(separatedBy: "''").last?.removingPercentEncoding } let name = extendedName ?? parameter("filename", in: disposition) ?? parameter("name", in: type) if name != nil || disposition.lowercased().hasPrefix("attachment") || !type.lowercased().hasPrefix("text/") { - attachments.append(MailAttachment(name: decodeHeader(name ?? "Anhang"), mimeType: type.components(separatedBy: ";")[0], data: decoded)) + attachments.append(MailAttachment(name: decodeHeader(name ?? "Anhang"), mimeType: type.components(separatedBy: ";")[0], data: decoded, + contentID: fields["content-id"]?.trimmingCharacters(in: CharacterSet(charactersIn: "<> ")))) } else { let value = text(decoded, charset: parameter("charset", in: type)) if type.lowercased().hasPrefix("text/html") { - // Render HTML as text: remote images and active content are never loaded. - let plain = value.replacingOccurrences(of: "(?is)<(script|style)[^>]*>.*?", with: "", options: .regularExpression) - .replacingOccurrences(of: "(?i)|

|", with: "\n", options: .regularExpression) - .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) - .replacingOccurrences(of: " ", with: " ").replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "&", with: "&") - htmlBodies.append(plain) + htmlBodies.append(value) } else { bodies.append(value) } } } + static func plainText(_ html: String) -> String { + html.replacingOccurrences(of: "(?is)<(script|style)[^>]*>.*?", with: "", options: .regularExpression) + .replacingOccurrences(of: "(?i)|

|", with: "\n", options: .regularExpression) + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .replacingOccurrences(of: " ", with: " ").replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: """, with: "\"") + .replacingOccurrences(of: "&", with: "&") + } + static func validAddress(_ value: String) -> Bool { value.range(of: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", options: .regularExpression) != nil } - static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment]) throws -> Data { + static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil) throws -> Data { guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress), !subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else { throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.") @@ -163,7 +168,15 @@ enum MIME { result += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n" result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n" result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n" - result += "--\(boundary)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n" + if let htmlBody { + let alternative = "Alternative-" + UUID().uuidString + result += "--\(boundary)\r\nContent-Type: multipart/alternative; boundary=\"\(alternative)\"\r\n\r\n" + result += "--\(alternative)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n" + result += "--\(alternative)\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(htmlBody.utf8)))\r\n" + result += "--\(alternative)--\r\n" + } else { + result += "--\(boundary)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n" + } for attachment in attachments { let safeName = attachment.name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "Anhang" result += "--\(boundary)\r\nContent-Type: application/octet-stream\r\n" diff --git a/MikeMail/MailArchive.swift b/MikeMail/MailArchive.swift index 4fe6eea..fe2cb33 100644 --- a/MikeMail/MailArchive.swift +++ b/MikeMail/MailArchive.swift @@ -172,6 +172,35 @@ final class MailArchive { } catch { context.rollback(); throw error } } + /// Apply only a confirmed server change; keep original EML objects available for backups. + func applyMessageChange(id: String, destination: String? = nil, + assigned: IMAPClient.AssignedUID? = nil, flags: [String]? = nil, + removed: Bool = true) throws { + let key = id + guard let row = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.key == key })).first else { + throw MailError.message("Lokale Nachricht nicht gefunden.") + } + do { + if let destination, let assigned { + let targetKey = destination + ":" + assigned.validity + ":" + assigned.uid + if let target = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.key == targetKey })).first { + target.objectHash = row.objectHash + target.flags = flags ?? row.flags + target.present = true + } else { + context.insert(MailArchiveSchema.ArchivedMail(key: targetKey, folderID: destination, + validity: assigned.validity, uid: assigned.uid, objectHash: row.objectHash, + flags: flags ?? row.flags)) + } + } + if removed { row.present = false } + else if let flags { row.flags = flags } + try context.save() + } catch { context.rollback(); throw error } + } + func messages(folderID: String) throws -> [MailMessage] { try rows(folderID: folderID).filter(\.present).map { row in var mail = MIME.parse(try Data(contentsOf: objectURL(row.objectHash)), id: row.key) diff --git a/MikeMail/MailBodyEditor.swift b/MikeMail/MailBodyEditor.swift new file mode 100644 index 0000000..d390465 --- /dev/null +++ b/MikeMail/MailBodyEditor.swift @@ -0,0 +1,168 @@ +import SwiftUI +import AppKit +import Observation + +@MainActor @Observable +final class MailEditorModel { + @ObservationIgnored weak var textView: NSTextView? + var fontName = "Helvetica" + var fontSize = 14.0 + var color = Color.black + var bold = false + var italic = false + var underline = false + var strikethrough = false + + var plainText: String { textView?.string ?? "" } + + func html() throws -> String { + guard let text = textView?.attributedString() else { return "" } + let data = try text.data(from: NSRange(location: 0, length: text.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue]) + return String(decoding: data, as: UTF8.self) + } + + func refreshSelection() { + guard let view = textView else { return } + let range = view.selectedRange() + let attributes = range.length > 0 && range.location < view.attributedString().length + ? view.attributedString().attributes(at: range.location, effectiveRange: nil) + : view.typingAttributes + let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: 14) + fontName = font.familyName ?? "Helvetica" + fontSize = font.pointSize + let traits = NSFontManager.shared.traits(of: font) + bold = traits.contains(.boldFontMask) + italic = traits.contains(.italicFontMask) + underline = (attributes[.underlineStyle] as? Int ?? 0) != 0 + strikethrough = (attributes[.strikethroughStyle] as? Int ?? 0) != 0 + color = Color(nsColor: attributes[.foregroundColor] as? NSColor ?? .textColor) + } + + func apply(_ transform: ([NSAttributedString.Key: Any]) -> [NSAttributedString.Key: Any]) { + guard let view = textView else { return } + let range = view.selectedRange() + if range.length > 0, let storage = view.textStorage, + view.shouldChangeText(in: range, replacementString: nil) { + let before = view.attributedString().attributedSubstring(from: range) + let changed = NSMutableAttributedString(attributedString: before) + before.enumerateAttributes(in: NSRange(location: 0, length: before.length)) { attributes, subrange, _ in + changed.setAttributes(transform(attributes), range: subrange) + } + storage.replaceCharacters(in: range, with: changed) + view.didChangeText() + view.setSelectedRange(range) + } + view.typingAttributes = transform(view.typingAttributes) + view.window?.makeFirstResponder(view) + refreshSelection() + } + + func setFont(name: String? = nil, size: Double? = nil) { + apply { attributes in + var result = attributes + let old = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: 14) + var font = name.map { NSFontManager.shared.convert(old, toFamily: $0) } ?? old + if let size { font = NSFontManager.shared.convert(font, toSize: size) } + result[.font] = font + return result + } + } + + func toggleTrait(_ trait: NSFontTraitMask, enabled: Bool) { + apply { attributes in + var result = attributes + let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: 14) + result[.font] = enabled ? NSFontManager.shared.convert(font, toHaveTrait: trait) + : NSFontManager.shared.convert(font, toNotHaveTrait: trait) + return result + } + } + + func setAttribute(_ key: NSAttributedString.Key, value: Any) { + apply { attributes in + var result = attributes + result[key] = value + return result + } + } +} + +struct MailFormattingBar: View { + @Bindable var editor: MailEditorModel + private let fonts = NSFontManager.shared.availableFontFamilies.sorted() + + var body: some View { + HStack(spacing: 8) { + Picker("Schriftart", selection: Binding(get: { editor.fontName }, set: { editor.setFont(name: $0) })) { + ForEach(fonts, id: \.self) { Text($0).tag($0) } + }.labelsHidden().frame(maxWidth: 180).help("Schriftart") + Picker("Schriftgröße", selection: Binding(get: { editor.fontSize }, set: { editor.setFont(size: $0) })) { + ForEach(Array(Set([8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72, editor.fontSize])).sorted(), id: \.self) { + Text("\($0.formatted())").tag($0) + } + }.labelsHidden().frame(width: 65).help("Schriftgröße") + ColorPicker("Textfarbe", selection: Binding(get: { editor.color }, set: { + editor.setAttribute(.foregroundColor, value: NSColor($0)) + }), supportsOpacity: false).labelsHidden().help("Textfarbe") + Button { editor.toggleTrait(.boldFontMask, enabled: !editor.bold) } label: { + Image(systemName: "bold").foregroundStyle(editor.bold ? Color.accentColor : .primary) + }.help("Fett") + Button { editor.toggleTrait(.italicFontMask, enabled: !editor.italic) } label: { + Image(systemName: "italic").foregroundStyle(editor.italic ? Color.accentColor : .primary) + }.help("Kursiv") + Button { editor.setAttribute(.underlineStyle, value: editor.underline ? 0 : NSUnderlineStyle.single.rawValue) } label: { + Image(systemName: "underline").foregroundStyle(editor.underline ? Color.accentColor : .primary) + }.help("Unterstrichen") + Button { editor.setAttribute(.strikethroughStyle, value: editor.strikethrough ? 0 : NSUnderlineStyle.single.rawValue) } label: { + Image(systemName: "strikethrough").foregroundStyle(editor.strikethrough ? Color.accentColor : .primary) + }.help("Durchgestrichen") + Spacer(minLength: 0) + } + .padding(8).background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + } +} + +struct MailBodyEditor: NSViewRepresentable { + let model: MailEditorModel + let initialText: String + let fontName: String + let fontSize: Double + let focusOnOpen: Bool + let richText: Bool + + func makeCoordinator() -> Coordinator { Coordinator(model: model) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSTextView.scrollableTextView() + guard let view = scroll.documentView as? NSTextView else { return scroll } + view.isRichText = richText + view.importsGraphics = false + view.allowsUndo = true + view.isAutomaticQuoteSubstitutionEnabled = false + view.textContainerInset = NSSize(width: 8, height: 8) + view.font = NSFont(name: fontName, size: fontSize) ?? .systemFont(ofSize: fontSize) + view.string = initialText + view.setSelectedRange(NSRange(location: 0, length: 0)) + view.setAccessibilityIdentifier("composeBody") + view.delegate = context.coordinator + model.textView = view + DispatchQueue.main.async { + model.refreshSelection() + if focusOnOpen { view.window?.makeFirstResponder(view) } + } + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + guard let view = scroll.documentView as? NSTextView else { return } + view.isRichText = richText + } + + final class Coordinator: NSObject, NSTextViewDelegate { + let model: MailEditorModel + init(model: MailEditorModel) { self.model = model } + func textViewDidChangeSelection(_ notification: Notification) { model.refreshSelection() } + } +} diff --git a/MikeMail/MailModels.swift b/MikeMail/MailModels.swift index 5cd8537..5dafdec 100644 --- a/MikeMail/MailModels.swift +++ b/MikeMail/MailModels.swift @@ -45,6 +45,7 @@ struct MailAttachment: Identifiable, Sendable { let name: String let mimeType: String let data: Data + var contentID: String? = nil } struct MailMessage: Identifiable, Sendable { @@ -58,6 +59,8 @@ struct MailMessage: Identifiable, Sendable { var isRead = false var cc = "" var replyTo = "" + var htmlBody: String? = nil + var displaySender: String { MailDisplay.sender(sender) } var attachmentCount: Int { attachments.count } } diff --git a/MikeMail/MailPresentation.swift b/MikeMail/MailPresentation.swift new file mode 100644 index 0000000..d6144f6 --- /dev/null +++ b/MikeMail/MailPresentation.swift @@ -0,0 +1,113 @@ +import SwiftUI +import AppKit +import WebKit + +enum MailDisplay { + static func sender(_ source: String) -> String { + guard let bracket = source.firstIndex(of: "<") else { return source } + let name = source[..= 2 else { return source } + let unquoted = name.dropFirst().dropLast() + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + return unquoted + " " + source[bracket...] + } + + static func date(_ value: Date, timeZone: TimeZone = .current) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "dd.MM.yyyy HH:mm" + return formatter.string(from: value) + } +} + +enum MailHTML { + static func document(_ html: String, attachments: [MailAttachment], blocked: Bool, font: String, size: Double) -> String { + var body = html + for attachment in attachments { + guard let cid = attachment.contentID, attachment.mimeType.lowercased().hasPrefix("image/"), + !attachment.mimeType.lowercased().contains("svg") else { continue } + let dataURL = "data:\(attachment.mimeType);base64,\(attachment.data.base64EncodedString())" + body = body.replacingOccurrences(of: "cid:" + cid, with: dataURL) + } + // The first CSP cannot be weakened by headers or markup in the message. + // Scripts, forms, frames, objects and automatic navigation remain disabled even after allowing images. + let remote = blocked ? "" : " https: http:" + let policy = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'\(remote); img-src data:\(remote); font-src data:\(remote); media-src 'none'; frame-src 'none'; object-src 'none'; connect-src 'none'; base-uri 'none'; form-action 'none'" + let family = font.filter { $0.isLetter || $0.isNumber || $0 == " " || $0 == "-" } + return """ + + + + + \(body) + """ + } +} + +struct HTMLMessageBody: View { + let html: String + let attachments: [MailAttachment] + @AppStorage("blockRemoteContent") private var blockRemoteContent = true + @AppStorage("bodyFontName") private var fontName = "Helvetica" + @AppStorage("bodyFontSize") private var fontSize = 14.0 + @State private var allowRemote = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label(blockRemoteContent && !allowRemote ? "Externe Inhalte blockiert" : "Externe Inhalte erlaubt", + systemImage: "hand.raised") + Spacer() + if blockRemoteContent && !allowRemote { + Button("Inhalte laden") { allowRemote = true } + } + }.font(.caption) + HTMLMailView(document: MailHTML.document(html, attachments: attachments, + blocked: blockRemoteContent && !allowRemote, font: fontName, size: fontSize)) + .frame(minHeight: 420) + } + } +} + +struct HTMLMailView: NSViewRepresentable { + let document: String + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.defaultWebpagePreferences.allowsContentJavaScript = false + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = context.coordinator + view.setValue(false, forKey: "drawsBackground") + return view + } + + func updateNSView(_ view: WKWebView, context: Context) { + guard context.coordinator.document != document else { return } + context.coordinator.document = document + view.loadHTMLString(document, baseURL: nil) + } + + final class Coordinator: NSObject, WKNavigationDelegate { + var document = "" + + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if action.navigationType == .linkActivated, let url = action.request.url, + ["https", "http", "mailto"].contains(url.scheme?.lowercased() ?? "") { + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + } else if action.targetFrame?.isMainFrame == true, + action.request.url?.absoluteString == "about:blank", + action.navigationType == .other { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + } + } + } +} diff --git a/MikeMail/MailSettings.swift b/MikeMail/MailSettings.swift index d840969..8236e91 100644 --- a/MikeMail/MailSettings.swift +++ b/MikeMail/MailSettings.swift @@ -13,7 +13,24 @@ enum MailDeleteConfirmation: String { } } +struct AutoReadRequest: Equatable { + let ids: Set + let immediately: Bool + let seconds: Int + + func wait() async throws { + if !immediately { try await Task.sleep(for: .seconds(max(1, seconds))) } + try Task.checkCancellation() + } +} + struct MailSettings: View { + @AppStorage("markReadImmediately") private var markReadImmediately = false + @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 + @AppStorage("bodyFontName") private var bodyFontName = "Helvetica" + @AppStorage("bodyFontSize") private var bodyFontSize = 14.0 + @AppStorage("blockRemoteContent") private var blockRemoteContent = true + private let fontNames = NSFontManager.shared.availableFontFamilies.sorted() @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always @AppStorage("idleEnabled") private var idleEnabled = true @AppStorage("pollMinutes") private var pollMinutes = 5 @@ -24,6 +41,15 @@ struct MailSettings: View { var body: some View { Form { + Picker("Schriftart für Mailtexte", selection: $bodyFontName) { + ForEach(fontNames, id: \.self) { Text($0).tag($0) } + } + Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48) + Text("Vorschau Mailtext").font(.custom(bodyFontName, size: bodyFontSize)) + Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent) + Text("Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten.") + .font(.caption).foregroundStyle(.secondary) + Divider() Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled) Text("Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung.").font(.caption).foregroundStyle(.secondary) Picker("Auf neue Mails prüfen", selection: $pollMinutes) { @@ -34,6 +60,17 @@ struct MailSettings: View { Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch) Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox) Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash) + Picker("Als gelesen markieren", selection: $markReadImmediately) { + Text("Sofort bei Auswahl").tag(true) + Text("Nach einer Wartezeit").tag(false) + } + if !markReadImmediately { + Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600) + TextField("Wartezeit in Sekunden", value: $markReadDelaySeconds, format: .number) + .onChange(of: markReadDelaySeconds) { _, value in + markReadDelaySeconds = min(3600, max(1, value)) + } + } Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) { Text("Immer").tag(MailDeleteConfirmation.always) Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple) diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift index 4727097..8254475 100644 --- a/MikeMail/MailStore.swift +++ b/MikeMail/MailStore.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import SwiftUI @MainActor @Observable final class MailStore { @@ -130,7 +131,7 @@ final class MailStore { items.flatMap { [$0] + allFolders($0.children ?? []) } } - func refresh(folderID: String?, accountID: UUID? = nil) async { + func refresh(folderID: String?, accountID: UUID? = nil, inboxOnly: Bool = false) async { guard !isBusy else { return } isBusy = true defer { isBusy = false } @@ -143,8 +144,8 @@ final class MailStore { defer { client.close() } status = "\(account.email): Verbindung wird aufgebaut …" try await client.login(account: account, password: PasswordStore.password(for: account.id)) - var discovered = try await client.folders(accountID: account.id) - let pending = try archive.pendingSent(accountID: account.id) + var discovered = inboxOnly ? (folders[account.id] ?? []) : try await client.folders(accountID: account.id) + let pending = inboxOnly ? [] : try archive.pendingSent(accountID: account.id) if !pending.isEmpty { var sent = allFolders(discovered).first { $0.specialUse?.contains("\\sent") == true } ?? allFolders(discovered).first { ["sent", "gesendet", "sent messages", "sent items"].contains($0.name.lowercased()) } @@ -175,7 +176,7 @@ final class MailStore { try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:])) } folders = updated - for folder in allFolders(discovered) where folder.selectable { + for folder in allFolders(discovered) where folder.selectable && (!inboxOnly || folder.isInbox) { do { status = "\(folder.name): Änderungen prüfen …" let inventory = try await client.inventory(folder: folder) @@ -229,7 +230,7 @@ final class MailStore { } catch { self.error = error.localizedDescription } } - func send(accountID: UUID?, recipients: String, cc: String = "", bcc: String = "", subject: String, body: String, attachments: [MailAttachment]) async throws { + func send(accountID: UUID?, recipients: String, cc: String = "", bcc: String = "", subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil) async throws { guard let account = accounts.first(where: { $0.id == accountID }) else { throw MailError.message("Bitte ein Absenderkonto auswählen.") } @@ -243,7 +244,7 @@ final class MailStore { let copies = Self.addresses(cc) let blindCopies = Self.addresses(bcc) let envelope = Array(Set(addresses + copies + blindCopies)).sorted() - let data = try MIME.compose(from: account.email, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments) + let data = try MIME.compose(from: account.email, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments, htmlBody: htmlBody) let localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien") let localID = UUID().uuidString // Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message. @@ -274,8 +275,24 @@ final class MailStore { } } + var composeDrafts: [UUID: ComposeDraft] = [:] + + func prepareCompose(_ draft: ComposeDraft) -> UUID { + composeDrafts[draft.id] = draft + return draft.id + } + + func moveAccounts(from offsets: IndexSet, to destination: Int) { + var updated = accounts + updated.move(fromOffsets: offsets, toOffset: destination) + do { + try storage().saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory)) + accounts = updated + } catch { self.error = error.localizedDescription } + } + func reorderAccount(_ id: UUID, before target: UUID) { - guard !isBusy, id != target, + guard id != target, let source = accounts.first(where: { $0.id == id }) else { return } var updated = accounts.filter { $0.id != id } guard let index = updated.firstIndex(where: { $0.id == target }) else { return } @@ -299,20 +316,26 @@ final class MailStore { } catch { client.close(); throw error } } - /// Serializes user mutations with synchronization. Refresh also reconciles partial successes. - private func mutate(_ operation: () async throws -> Void) async { + /// Message mutations commit their confirmed changes locally; folder operations rediscover the tree. + private func mutate(refreshAfter: Bool = true, _ operation: () async throws -> Void) async { while isBusy { if Task.isCancelled { return }; try? await Task.sleep(for: .milliseconds(150)) } + guard !Task.isCancelled else { return } isBusy = true var failure: String? do { try await operation() } catch { failure = error.localizedDescription } isBusy = false - await refresh(folderID: selectedFolderID) + if refreshAfter { await refresh(folderID: selectedFolderID) } + else { + do { try updateLocalPresentation() } + catch { self.error = error.localizedDescription } + status = failure == nil ? "Änderung gespeichert." : "Änderung nicht vollständig ausgeführt." + } if let failure { error = failure } } func moveMessages(ids: [String], to destination: MailFolder) async { - await mutate { + await mutate(refreshAfter: false) { guard destination.selectable else { throw MailError.message("Bitte einen beschreibbaren Zielordner wählen.") } for id in Set(ids) { try await self.moveOne(id: id, to: destination) @@ -328,13 +351,16 @@ final class MailStore { } let sourceClient = try await client(for: source.accountID) defer { sourceClient.close() } - let inventory = try await sourceClient.inventory(folder: source, writable: true) + let inventory = try await sourceClient.messageInventory(folder: source, uid: uid) guard inventory.validity == record.validity, inventory.flags[uid] != nil else { throw MailError.message("Der Serverbestand hat sich verändert. Bitte erneut abrufen.") } let capabilities = try await sourceClient.capabilities() if source.accountID == destination.accountID, capabilities.contains("MOVE") { - try await sourceClient.move(uid: uid, to: destination.path) + let assigned = try await sourceClient.move(uid: uid, to: destination.path) + try storage().applyMessageChange(id: id, destination: destination.id, assigned: assigned, flags: inventory.flags[uid]) + try updateLocalPresentation() + if assigned == nil { try await reconcileDestination(destination) } return } guard capabilities.contains("UIDPLUS") else { @@ -343,25 +369,56 @@ final class MailStore { let raw = try await sourceClient.rawMessage(uid: uid) let targetClient = try await client(for: destination.accountID) defer { targetClient.close() } - try await targetClient.append(raw, path: destination.path, flags: inventory.flags[uid] ?? []) + let assigned = try await targetClient.append(raw, path: destination.path, flags: inventory.flags[uid] ?? []) // A positive tagged APPEND completion precedes any source deletion. + // Record the confirmed copy even if removing the source later fails. + if let assigned { + try storage().store(raw: raw, folderID: destination.id, validity: assigned.validity, + uid: assigned.uid, flags: inventory.flags[uid] ?? []) + } else { try await reconcileDestination(destination) } do { try await sourceClient.remove(uids: [uid]) } catch { throw MailError.message("Die Nachricht wurde ins Ziel kopiert, konnte aber nicht aus der Quelle entfernt werden. Beide Kopien bleiben erhalten. \(error.localizedDescription)") } + try storage().applyMessageChange(id: id) + try updateLocalPresentation() + } + + private func updateLocalPresentation() throws { + unreadCounts = try storage().unreadCounts() + archiveRevision += 1 + if let selectedFolderID { try display(folderID: selectedFolderID) } + } + + /// Servers without COPYUID/APPENDUID need only the destination folder reconciled. + private func reconcileDestination(_ folder: MailFolder) async throws { + let archive = try storage() + let client = try await client(for: folder.accountID) + defer { client.close() } + let inventory = try await client.inventory(folder: folder) + let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity) + for uid in Set(inventory.flags.keys).subtracting(known) { + let raw = try await client.rawMessage(uid: uid) + try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity, + uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false) + } + try archive.reconcile(folderID: folder.id, inventory: inventory) + try updateLocalPresentation() } func deleteMessages(ids: [String]) async { - await mutate { + 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), let uid = UInt64(record.uid) else { continue } if source.isTrash { let client = try await self.client(for: source.accountID) defer { client.close() } - let inventory = try await client.inventory(folder: source, writable: true) + let inventory = try await client.messageInventory(folder: source, uid: uid) guard inventory.validity == record.validity else { throw MailError.message("Ordnerbestand geändert. Bitte erneut abrufen.") } try await client.remove(uids: inventory.flags[uid] == nil ? [] : [uid]) + try self.storage().applyMessageChange(id: id) + try self.updateLocalPresentation() } else { guard let trash = self.allFolders(self.folders[source.accountID] ?? []).first(where: \.isTrash) else { throw MailError.message("Für dieses Konto wurde kein Papierkorb gefunden. Bitte zuerst einen Ordner „Trash“ erstellen.") @@ -456,17 +513,21 @@ final class MailStore { } func setRead(ids: [String], read: Bool) async { - await mutate { + await mutate(refreshAfter: false) { for id in Set(ids) { let record = try self.storage().record(id: id) guard let folder = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue } let client = try await self.client(for: folder.accountID) defer { client.close() } - let inventory = try await client.inventory(folder: folder, writable: true) + let inventory = try await client.messageInventory(folder: folder, uid: uid) guard inventory.validity == record.validity, inventory.flags[uid] != nil else { throw MailError.message("Nachricht wurde auf dem Server verändert. Bitte erneut abrufen.") } try await client.setSeen(uid: uid, read: read) + var flags = (inventory.flags[uid] ?? []).filter { $0.lowercased() != "\\seen" } + if read { flags.append("\\Seen") } + try self.storage().applyMessageChange(id: id, flags: flags, removed: false) + try self.updateLocalPresentation() } } } @@ -517,7 +578,7 @@ final class MailStore { guard let id = self.pendingIdleAccounts.first else { return } self.pendingIdleAccounts.remove(id) guard self.accounts.contains(where: { $0.id == id }) else { continue } - await self.refresh(folderID: self.selectedFolderID, accountID: id) + await self.refresh(folderID: self.selectedFolderID, accountID: id, inboxOnly: true) } } } diff --git a/MikeMail/MessageActions.swift b/MikeMail/MessageActions.swift index 95508dd..1af3f44 100644 --- a/MikeMail/MessageActions.swift +++ b/MikeMail/MessageActions.swift @@ -40,9 +40,11 @@ struct MessageActionButtons: View { struct MessageDetailWindow: View { let store: MailStore let messageID: String? - @State private var draft: ComposeDraft? + @Environment(\.openWindow) private var openWindow @State private var confirmingDelete = false @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always + @AppStorage("markReadImmediately") private var markReadImmediately = false + @AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1 private var message: MailMessage? { _ = store.archiveRevision @@ -57,15 +59,15 @@ struct MessageDetailWindow: View { guard let message else { return } switch command { case .reply, .forward: - draft = .from(message, accountID: store.accountID(for: message.id), forwarding: command == .forward) + let draft = ComposeDraft.from(message, accountID: store.accountID(for: message.id), forwarding: command == .forward) + openWindow(id: "compose", value: store.prepareCompose(draft)) case .delete: if deleteConfirmation.isRequired(count: 1) { confirmingDelete = true } else { Task { await store.deleteMessages(ids: [message.id]) } } default: break } - }.disabled(message == nil || store.isBusy) + }.disabled(message == nil) } - .sheet(item: $draft) { ComposeView(store: store, draft: $0) } .alert("Nachricht löschen?", isPresented: $confirmingDelete) { Button("Abbrechen", role: .cancel) {} Button("Löschen", role: .destructive) { @@ -76,8 +78,12 @@ struct MessageDetailWindow: View { ? "Die Nachricht wird auch auf dem Server endgültig gelöscht." : "Die Nachricht wird auch auf dem Server in den Papierkorb verschoben.") } - .task(id: messageID) { - if let message, !message.isRead { await store.setRead(ids: [message.id], read: true) } + .task(id: AutoReadRequest(ids: Set([messageID].compactMap { $0 }), immediately: markReadImmediately, seconds: markReadDelaySeconds)) { + guard let message, !message.isRead else { return } + do { + try await AutoReadRequest(ids: [message.id], immediately: markReadImmediately, seconds: markReadDelaySeconds).wait() + await store.setRead(ids: [message.id], read: true) + } catch { /* The message window closed before the read delay elapsed. */ } } .alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) { Button("OK") { store.error = nil } diff --git a/MikeMail/MikeMailApp.swift b/MikeMail/MikeMailApp.swift index 13f547b..65205b0 100644 --- a/MikeMail/MikeMailApp.swift +++ b/MikeMail/MikeMailApp.swift @@ -17,6 +17,13 @@ struct MikeMailApp: App { WindowGroup("Nachricht", id: "message", for: String.self) { $id in MessageDetailWindow(store: store, messageID: id) }.defaultSize(width: 800, height: 650) + .windowResizability(.contentMinSize) + WindowGroup("Neue Nachricht", id: "compose", for: UUID.self) { $id in + ComposeView(store: store, draft: id.flatMap { store.composeDrafts[$0] }) + .onDisappear { if let id { store.composeDrafts.removeValue(forKey: id) } } + } + .defaultSize(width: 760, height: 780) + .windowResizability(.contentMinSize) Settings { MailSettings() } } } diff --git a/MikeMail/UITestFixture.swift b/MikeMail/UITestFixture.swift index 8da73fa..1ff66be 100644 --- a/MikeMail/UITestFixture.swift +++ b/MikeMail/UITestFixture.swift @@ -9,14 +9,17 @@ extension MailStore { let archive = try MailArchive(root: root) let account = MailAccount(name: "UI-Prüfung", email: "fixture@example.com", username: "fixture", host: "example.invalid") let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang") - try archive.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [inbox]], recipients: [])) + let second = MailAccount(name: "Zweites Testkonto", email: "second@example.com", username: "second", host: "example.invalid") + try archive.saveConfiguration(ArchiveConfiguration(accounts: [account, second], folders: [account.id: [inbox], second.id: []], recipients: [])) for index in 1...2 { let raw = try MIME.compose(from: "sender@example.com", to: [account.email], subject: "UI-Nachricht \(index)", body: "Nachrichtentext für die Fensterprüfung.", attachments: [MailAttachment(name: "Beispiel.txt", mimeType: "text/plain", data: Data("Anhang".utf8))]) try archive.store(raw: raw, folderID: inbox.id, validity: "1", uid: "\(index)", flags: ["\\Seen"]) } - return MailStore(archive: archive) + let store = MailStore(archive: archive) + if ProcessInfo.processInfo.arguments.contains("--ui-busy") { store.isBusy = true } + return store } catch { fatalError("UI fixture: \(error)") } } } diff --git a/MikeMailTests/MailArchiveTests.swift b/MikeMailTests/MailArchiveTests.swift index e3c0371..8540329 100644 --- a/MikeMailTests/MailArchiveTests.swift +++ b/MikeMailTests/MailArchiveTests.swift @@ -4,6 +4,51 @@ import Testing @MainActor struct MailArchiveTests { + @Test func confirmedMessageChangesPreserveOtherMailAndBackup() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let archive = try MailArchive(root: root.appending(path: "Archive")) + let account = MailAccount(name: "Fixture", email: "fixture@example.com", username: "fixture", host: "example.invalid") + let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang") + let trash = MailFolder(accountID: account.id, path: "Trash", name: "Papierkorb") + try archive.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [inbox, trash]], recipients: [])) + let raw = Data("Subject: Move\r\n\r\nBody".utf8) + for uid in ["1", "2"] { try archive.store(raw: raw, folderID: inbox.id, validity: "10", uid: uid, flags: []) } + let id = inbox.id + ":10:1" + try archive.applyMessageChange(id: id, flags: ["\\Seen"], removed: false) + #expect(try archive.message(id: id).isRead) + try archive.applyMessageChange(id: id, destination: trash.id, assigned: .init(validity: "20", uid: "9"), flags: ["\\Seen"]) + #expect(try archive.messages(folderID: inbox.id).map(\.id) == [inbox.id + ":10:2"]) + #expect(try archive.message(id: trash.id + ":20:9").body == "Body") + #expect(try archive.unreadCounts()[inbox.id] == 1) + try archive.applyMessageChange(id: trash.id + ":20:9") + #expect(try archive.messages(folderID: trash.id).isEmpty) + let backup = root.appending(path: "Backup") + try archive.exportSnapshot(to: backup) + let restored = try MailArchive.restoreSnapshot(from: backup, to: root.appending(path: "Restored")) + #expect(try restored.messages(folderID: trash.id).isEmpty) + #expect(try restored.messages(folderID: inbox.id).count == 1) + } + + @Test func singleMessageServerUIDMapping() { + #expect(IMAPClient.assignedUID(["M1 OK [COPYUID 123 5 17] Done"], sourceUID: 5) == .init(validity: "123", uid: "17")) + #expect(IMAPClient.assignedUID(["* OK [COPYUID 123 5 17] Done"], sourceUID: 6) == nil) + #expect(IMAPClient.assignedUID(["M2 OK [APPENDUID 123 18] Done"]) == .init(validity: "123", uid: "18")) + #expect(IMAPClient.assignedUID(["M2 OK Done"]) == nil) + #expect(IMAPClient.assignedUID(["M2 OK [APPENDUID 0 18] Done"]) == nil) + #expect(IMAPClient.assignedUID(["M2 OK [COPYUID 123 5:6 17:18] Done"], sourceUID: 5) == nil) + } + + @Test func automaticReadDelayIsCancellable() async throws { + try await AutoReadRequest(ids: ["mail"], immediately: true, seconds: 3600).wait() + let delayed = Task { try await AutoReadRequest(ids: ["mail"], immediately: false, seconds: 3600).wait() } + delayed.cancel() + do { + try await delayed.value + Issue.record("Cancelled selection must not be marked as read") + } catch is CancellationError { } + } + @Test func diskPersistenceIncrementalReconciliationAndRestore() throws { let base = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: base) } diff --git a/MikeMailTests/MailPresentationTests.swift b/MikeMailTests/MailPresentationTests.swift new file mode 100644 index 0000000..1edbaa6 --- /dev/null +++ b/MikeMailTests/MailPresentationTests.swift @@ -0,0 +1,104 @@ +import Foundation +import AppKit +import WebKit +import Testing +@testable import MikeMail + +@MainActor +struct MailPresentationTests { + @Test func htmlAlternativeAndAttachmentsSurviveArchive() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let html = "

Grüße Welt

" + let bytes = Data([0, 1, 254, 255]) + let raw = try MIME.compose(from: "from@example.com", to: ["to@example.com"], subject: "HTML", + body: "Grüße Welt", attachments: [MailAttachment(name: "Probe.bin", mimeType: "application/octet-stream", data: bytes)], + htmlBody: html) + let archive = try MailArchive(root: root) + try archive.store(raw: raw, folderID: "inbox", validity: "1", uid: "1", flags: []) + let message = try #require(archive.messages(folderID: "inbox").first) + #expect(message.body == "Grüße Welt") + #expect(message.htmlBody == html) + #expect(message.attachments.first?.data == bytes) + let htmlOnly = MIME.parse(Data("Content-Type: text/html; charset=utf-8\r\n\r\n

Hallo Welt

".utf8), id: "html") + #expect(htmlOnly.htmlBody?.contains("Hallo") == true) + #expect(htmlOnly.body.contains("Hallo Welt")) + } + + @Test func selectedFormattingExportsHTMLWithoutChangingOtherText() throws { + let editor = MailEditorModel() + let view = NSTextView() + view.string = "Hallo Welt" + view.font = NSFont(name: "Helvetica", size: 14) + editor.textView = view + view.setSelectedRange(NSRange(location: 0, length: 5)) + editor.toggleTrait(.boldFontMask, enabled: true) + editor.setAttribute(.foregroundColor, value: NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1)) + editor.setFont(size: 24) + let selected = try #require(view.textStorage?.attribute(.font, at: 0, effectiveRange: nil) as? NSFont) + let remaining = try #require(view.textStorage?.attribute(.font, at: 6, effectiveRange: nil) as? NSFont) + #expect(NSFontManager.shared.traits(of: selected).contains(.boldFontMask)) + #expect(selected.pointSize == 24) + #expect(remaining.pointSize == 14) + let html = try editor.html() + #expect(html.contains("24")) + // Cocoa's HTML writer converts color profiles, so compare the exported color with tolerance. + let colorRange = try #require(html.range(of: "#[0-9a-fA-F]{6}", options: .regularExpression)) + let rgb = try #require(UInt32(html[colorRange].dropFirst(), radix: 16)) + #expect((rgb >> 16) > 230 && ((rgb >> 8) & 255) < 25 && (rgb & 255) < 25) + #expect(editor.plainText == "Hallo Welt") + } + + @Test func accountOrderPersistsInBothDirections() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let archive = try MailArchive(root: root) + let accounts = ["A", "B", "C"].map { MailAccount(name: $0) } + try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: [:], recipients: [])) + let store = MailStore(archive: archive) + store.moveAccounts(from: IndexSet(integer: 0), to: 3) + #expect(store.accounts.map(\.name) == ["B", "C", "A"]) + #expect(try MailArchive(root: root).configuration()?.accounts.map(\.name) == ["B", "C", "A"]) + store.moveAccounts(from: IndexSet(integer: 2), to: 0) + #expect(store.accounts == accounts) + #expect(try archive.configuration()?.accounts == accounts) + } + + @Test func senderAndDatePresentation() throws { + #expect(MailDisplay.sender("\"Max Muster\" ") == "Max Muster ") + #expect(MailDisplay.sender("max@example.com") == "max@example.com") + let date = try #require(ISO8601DateFormatter().date(from: "2026-11-07T15:04:00Z")) + #expect(MailDisplay.date(date, timeZone: try #require(TimeZone(secondsFromGMT: 0))) == "07.11.2026 15:04") + } + + @Test func htmlRendererBlocksRemoteResourcesAndScripts() async throws { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.defaultWebpagePreferences.allowsContentJavaScript = false + // An application-owned script records CSP violations without allowing mail scripts. + config.userContentController.addUserScript(WKUserScript(source: """ + window.blockedResources = []; + document.addEventListener('securitypolicyviolation', e => window.blockedResources.push(e.blockedURI)); + """, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + let view = WKWebView(frame: NSRect(x: 0, y: 0, width: 600, height: 400), configuration: config) + let html = """ +

HTML

+ + + + """ + view.loadHTMLString(MailHTML.document(html, attachments: [], blocked: true, font: "Helvetica", size: 14), baseURL: nil) + var violations: [String] = [] + for _ in 0..<100 { + try await Task.sleep(for: .milliseconds(50)) + violations = (try? await view.evaluateJavaScript("window.blockedResources")) as? [String] ?? [] + if violations.contains(where: { $0.contains("tracker.png") }) { break } + } + #expect(violations.contains { $0.contains("tracker.png") }) + #expect(violations.contains { $0.contains("background.png") }) + let style = try await view.evaluateJavaScript("getComputedStyle(document.getElementById('styled')).color") as? String + #expect(style == "rgb(255, 0, 0)") + let scriptRan = try await view.evaluateJavaScript("window.mailScriptRan === true") as? Bool + #expect(scriptRan == false) + } +} diff --git a/MikeMailUITests/MikeMailUITests.swift b/MikeMailUITests/MikeMailUITests.swift index 45b660e..2e7d687 100644 --- a/MikeMailUITests/MikeMailUITests.swift +++ b/MikeMailUITests/MikeMailUITests.swift @@ -49,7 +49,7 @@ final class MikeMailUITests: XCTestCase { app.menuItems["New"].hover() app.menuItems["New Window"].click() } - let table = app.outlines.element(boundBy: 1) + let table = app.outlines.firstMatch XCTAssertTrue(table.waitForExistence(timeout: 5), app.debugDescription) let row = table.descendants(matching: .outlineRow).firstMatch XCTAssertTrue(row.waitForExistence(timeout: 5), app.debugDescription) @@ -62,6 +62,83 @@ final class MikeMailUITests: XCTestCase { XCTAssertGreaterThanOrEqual(app.windows.count, 2, app.debugDescription) reply.click() XCTAssertTrue(app.textFields["composeCC"].waitForExistence(timeout: 5)) - app.buttons["Abbrechen"].click() + let compose = app.windows["Neue Nachricht"].firstMatch + XCTAssertTrue(compose.waitForExistence(timeout: 5)) + XCTAssertGreaterThanOrEqual(app.windows.count, 3) + let body = compose.textViews["composeBody"] + XCTAssertTrue(body.waitForExistence(timeout: 5)) + app.typeText("Antwort am Anfang ") + XCTAssertTrue((body.value as? String ?? "").hasPrefix("Antwort am Anfang ")) + let oldFrame = compose.frame + resizeWindow(compose, app: app) + XCTAssertNotEqual(compose.frame.size, oldFrame.size) + compose.buttons["Abbrechen"].click() + let oldDetail = detail.frame + detail.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.01)).click() + resizeWindow(detail, app: app) + XCTAssertNotEqual(detail.frame.size, oldDetail.size) + } + + @MainActor + func testDeleteConfirmation() { + let app = XCUIApplication() + app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "--ui-fixture", "-checkOnLaunch", "NO", + "-idleEnabled", "NO", "-pollMinutes", "0", "-selectedFolder", "unified:inbox", "-mailSearch", "", + "-mailDeleteConfirmation", "always", "--ui-busy"] + app.launch() + app.activate() + if app.windows.count == 0 { + app.menuBars.menuBarItems["File"].click() + app.menuItems["New"].hover() + app.menuItems["New Window"].click() + } + let table = app.outlines.firstMatch + XCTAssertTrue(table.waitForExistence(timeout: 5)) + table.staticTexts["UI-Nachricht 1"].firstMatch.click() + app.buttons["Löschen…"].firstMatch.click() + XCTAssertTrue(app.buttons["Abbrechen"].waitForExistence(timeout: 3), app.debugDescription) + if app.buttons["Abbrechen"].exists { app.buttons["Abbrechen"].click() } + } + + @MainActor + func testAccountReorderingAndDisclosure() { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "--ui-fixture", "-checkOnLaunch", "NO", + "-idleEnabled", "NO", "-pollMinutes", "0", "-selectedFolder", "unified:inbox", "-mailSearch", ""] + app.launch() + app.activate() + if app.windows.count == 0 { + app.menuBars.menuBarItems["File"].click() + app.menuItems["New"].hover() + app.menuItems["New Window"].click() + } + app.activate() + let sidebar = app.tables["accountSidebar"] + XCTAssertTrue(sidebar.waitForExistence(timeout: 5)) + let rows = sidebar.tableRows + XCTAssertTrue(rows.element(boundBy: 4).waitForExistence(timeout: 5)) + let source = rows.element(boundBy: 4) + let target = rows.element(boundBy: 2) + let disclosure = target.buttons.firstMatch + XCTAssertTrue(disclosure.exists) + XCTAssertEqual(disclosure.frame.midY, target.frame.midY, accuracy: 2) + source.staticTexts["Zweites Testkonto"].click(forDuration: 1, thenDragTo: target.staticTexts["UI-Prüfung"]) + XCTAssertTrue(rows.element(boundBy: 2).debugDescription.contains("second@example.com"), sidebar.debugDescription) + } + + @MainActor + private func resizeWindow(_ window: XCUIElement, app: XCUIApplication) { + let original = window.frame.size + app.menuBars.menuBarItems["Window"].click() + app.menuItems["Fill"].click() + if window.frame.size == original { + app.menuBars.menuBarItems["Window"].click() + app.menuItems["Move & Resize"].hover() + app.menuItems["Left"].click() + } + let changed = NSPredicate { _, _ in window.frame.size != original } + expectation(for: changed, evaluatedWith: window) + waitForExpectations(timeout: 3) } }