This commit is contained in:
Dr. Michael Kübler
2026-11-07 23:24:14 +01:00
committed by Dr. Michael Kübler
parent 8e95cc3acd
commit c5e46946ac
16 changed files with 1073 additions and 197 deletions
+279
View File
@@ -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
}
}
}
}
+50 -152
View File
@@ -17,12 +17,12 @@ struct ContentView: View {
@State private var selectedFolder: String?
@State private var selectedMessages: Set<String> = []
@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<String>) {
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<String?>,
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<String>
@@ -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()) }
+37 -4
View File
@@ -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..<end].split(separator: " ").map(String.init)
guard parts.count == (sourceUID == nil ? 2 : 3),
let validity = UInt64(parts[0]), validity > 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 {
+23 -10
View File
@@ -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)
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)<br\\s*/?>|</p>|</div>", with: "\n", options: .regularExpression)
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.replacingOccurrences(of: "&nbsp;", with: " ").replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">").replacingOccurrences(of: "&amp;", with: "&")
htmlBodies.append(plain)
} else { bodies.append(value) }
}
.replacingOccurrences(of: "&gt;", with: ">").replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&amp;", 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"
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"
+29
View File
@@ -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<MailArchiveSchema.ArchivedMail>(
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<MailArchiveSchema.ArchivedMail>(
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)
+168
View File
@@ -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() }
}
}
+3
View File
@@ -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 }
}
+113
View File
@@ -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[..<bracket].trimmingCharacters(in: .whitespaces)
guard name.hasPrefix("\""), name.hasSuffix("\""), name.count >= 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 """
<!doctype html><html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="\(policy)">
<meta name="referrer" content="no-referrer">
<style>body { font-family: '\(family)', sans-serif; font-size: \(min(48, max(8, size)))px; overflow-wrap: anywhere; } img { max-width: 100%; height: auto; }</style>
</head><body>\(body)</body></html>
"""
}
}
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)
}
}
}
}
+37
View File
@@ -13,7 +13,24 @@ enum MailDeleteConfirmation: String {
}
}
struct AutoReadRequest: Equatable {
let ids: Set<String>
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)
+80 -19
View File
@@ -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)
}
}
}
+12 -6
View File
@@ -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 }
+7
View File
@@ -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() }
}
}
+5 -2
View File
@@ -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)") }
}
}
+45
View File
@@ -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) }
+104
View File
@@ -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 = "<p style=\"font-family: Georgia; color: #ff0000; font-size: 24px\"><b>Grüße</b> <i>Welt</i></p>"
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<p><b>Hallo</b> Welt</p>".utf8), id: "html")
#expect(htmlOnly.htmlBody?.contains("<b>Hallo</b>") == 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@example.com>") == "Max Muster <max@example.com>")
#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 = """
<p id="styled" style="color: rgb(255, 0, 0); font-size:24px"><b>HTML</b></p>
<img src="https://example.invalid/tracker.png">
<style>body { background-image:url(https://example.invalid/background.png) }</style>
<script>window.mailScriptRan = true;</script>
"""
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)
}
}
+79 -2
View File
@@ -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)
}
}