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)[^>]*>.*?\\1>", 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)[^>]*>.*?\\1>", 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\nHallo 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)
}
}