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 selectedFolder: String?
@State private var selectedMessages: Set<String> = [] @State private var selectedMessages: Set<String> = []
@State private var showingAccounts = false @State private var showingAccounts = false
@State private var composing = false
@State private var draft: ComposeDraft?
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@AppStorage("idleEnabled") private var idleEnabled = true @AppStorage("idleEnabled") private var idleEnabled = true
@AppStorage("pollMinutes") private var pollMinutes = 5 @AppStorage("pollMinutes") private var pollMinutes = 5
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always @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 pendingDeletion: [String] = []
@State private var confirmingDelete = false @State private var confirmingDelete = false
@State private var restored = false @State private var restored = false
@@ -71,7 +71,6 @@ struct ContentView: View {
} }
.frame(height: 26) .frame(height: 26)
.padding(8) .padding(8)
.disabled(store.isBusy)
VSplitView { VSplitView {
MessageTable(messages: store.messages.filter(searchFilter.matches), selection: $selectedMessages, busy: store.isBusy, action: perform) MessageTable(messages: store.messages.filter(searchFilter.matches), selection: $selectedMessages, busy: store.isBusy, action: perform)
.onDeleteCommand { deleteSelection() } .onDeleteCommand { deleteSelection() }
@@ -116,17 +115,15 @@ struct ContentView: View {
} }
.accessibilityIdentifier("Abrufen") .accessibilityIdentifier("Abrufen")
.disabled(store.isBusy) .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) .keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
Button("Löschen", systemImage: "trash") { deleteSelection() } Button("Löschen", systemImage: "trash") { deleteSelection() }
.disabled(selectedMessages.isEmpty || store.isBusy) .disabled(selectedMessages.isEmpty)
Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true } Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true }
SettingsLink { Label("Einstellungen", systemImage: "gearshape") } SettingsLink { Label("Einstellungen", systemImage: "gearshape") }
} }
} }
.sheet(isPresented: $showingAccounts) { AccountsView(store: store) } .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 .sheet(item: Binding(get: { store.authenticationAccount }, set: { value in
if value == nil { store.dismissPasswordRequest() } if value == nil { store.dismissPasswordRequest() }
})) { account in })) { account in
@@ -136,11 +133,13 @@ struct ContentView: View {
store.configureIdle(enabled: idleEnabled) store.configureIdle(enabled: idleEnabled)
} }
.task(id: pollMinutes) { await store.poll(intervalMinutes: pollMinutes) } .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, guard selectedMessages.count == 1, let id = selectedMessages.first,
let message = store.message(id: id), !message.isRead else { return } let message = store.message(id: id), !message.isRead else { return }
try? await Task.sleep(for: .milliseconds(700)) do {
if !Task.isCancelled { await store.setRead(ids: [id], read: true) } 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) { .alert(pendingDeletion.count == 1 ? "Nachricht löschen?" : "\(pendingDeletion.count) Nachrichten löschen?", isPresented: $confirmingDelete) {
Button("Abbrechen", role: .cancel) {} Button("Abbrechen", role: .cancel) {}
@@ -179,6 +178,10 @@ struct ContentView: View {
.frame(minWidth: 850, minHeight: 550) .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>) { private func perform(_ command: MessageCommand, _ ids: Set<String>) {
guard !ids.isEmpty else { return } guard !ids.isEmpty else { return }
switch command { switch command {
@@ -186,7 +189,7 @@ struct ContentView: View {
if ids.count == 1, let id = ids.first { openWindow(id: "message", value: id) } if ids.count == 1, let id = ids.first { openWindow(id: "message", value: id) }
case .reply, .forward: case .reply, .forward:
if ids.count == 1, let id = ids.first, let message = store.message(id: id) { 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: case .delete:
requestDeletion(Array(ids)) requestDeletion(Array(ids))
@@ -196,12 +199,12 @@ struct ContentView: View {
} }
private func deleteSelection() { private func deleteSelection() {
guard !store.isBusy, !selectedMessages.isEmpty else { return } guard !selectedMessages.isEmpty else { return }
requestDeletion(Array(selectedMessages)) requestDeletion(Array(selectedMessages))
} }
private func requestDeletion(_ ids: [String]) { private func requestDeletion(_ ids: [String]) {
guard !store.isBusy, !ids.isEmpty else { return } guard !ids.isEmpty else { return }
if deleteConfirmation.isRequired(count: ids.count) { if deleteConfirmation.isRequired(count: ids.count) {
pendingDeletion = ids pendingDeletion = ids
confirmingDelete = true confirmingDelete = true
@@ -258,22 +261,10 @@ struct AccountSidebar: View {
@State private var confirmingTrash = false @State private var confirmingTrash = false
var body: some View { var body: some View {
List(selection: $selection) { MailSidebarList(store: store, selection: $selection, showInbox: showInbox, showTrash: showTrash,
if showInbox { create: { beginCreate($0, parent: $1) },
HStack { Label("Gemeinsamer Posteingang", systemImage: "tray.2"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:inbox")) }.tag("unified:inbox") delete: { deleting = $0; confirmingFolder = true },
} empty: confirmTrash)
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)
.onChange(of: showInbox) { _, visible in if !visible && selection == "unified:inbox" { selection = nil } } .onChange(of: showInbox) { _, visible in if !visible && selection == "unified:inbox" { selection = nil } }
.onChange(of: showTrash) { _, visible in if !visible && selection == "unified:trash" { selection = nil } } .onChange(of: showTrash) { _, visible in if !visible && selection == "unified:trash" { selection = nil } }
.alert("Neuen Ordner erstellen", isPresented: $creating) { .alert("Neuen Ordner erstellen", isPresented: $creating) {
@@ -308,113 +299,6 @@ struct AccountSidebar: View {
private func confirmTrash(_ id: String) { trashID = id; confirmingTrash = true } 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 { struct MessageTable: View {
let messages: [MailMessage] let messages: [MailMessage]
@Binding var selection: Set<String> @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") Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
} }
}.width(55) }.width(55)
TableColumn("Absender", value: \.sender) { message in TableColumn("Absender", value: \.displaySender) { message in
Text(message.sender).fontWeight(message.isRead ? .regular : .bold) Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold)
}.width(min: 110, ideal: 160) }.width(min: 110, ideal: 160)
TableColumn("Datum", value: \.date) { message in TableColumn("Datum", value: \.date) { message in
if message.date == .distantPast { Text("—") } 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) }.width(min: 120, ideal: 150)
TableColumn("Betreff", value: \.subject) { message in TableColumn("Betreff", value: \.subject) { message in
Text(message.subject).fontWeight(message.isRead ? .regular : .bold) Text(message.subject).fontWeight(message.isRead ? .regular : .bold)
@@ -449,9 +333,9 @@ struct MessageTable: View {
.contextMenu(forSelectionType: String.self) { ids in .contextMenu(forSelectionType: String.self) { ids in
if ids.count == 1 { if ids.count == 1 {
Button("In neuem Fenster öffnen") { action(.open, ids) } Button("In neuem Fenster öffnen") { action(.open, ids) }
MessageActionButtons { action($0, ids) }.disabled(busy) MessageActionButtons { action($0, ids) }
} else if !ids.isEmpty { } else if !ids.isEmpty {
Button("Nachrichten löschen…") { action(.delete, ids) }.disabled(busy) Button("Nachrichten löschen…") { action(.delete, ids) }
} }
if !ids.isEmpty { if !ids.isEmpty {
Divider() Divider()
@@ -462,7 +346,7 @@ struct MessageTable: View {
.onAppear { .onAppear {
let order: SortOrder = savedAscending ? .forward : .reverse let order: SortOrder = savedAscending ? .forward : .reverse
switch savedColumn { 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 "subject": sortOrder = [KeyPathComparator(\MailMessage.subject, order: order)]
case "attachments": sortOrder = [KeyPathComparator(\MailMessage.attachmentCount, order: order)] case "attachments": sortOrder = [KeyPathComparator(\MailMessage.attachmentCount, order: order)]
default: sortOrder = [KeyPathComparator(\MailMessage.date, order: order)] default: sortOrder = [KeyPathComparator(\MailMessage.date, order: order)]
@@ -472,7 +356,7 @@ struct MessageTable: View {
guard let first = value.first else { return } guard let first = value.first else { return }
savedAscending = first.order == .forward savedAscending = first.order == .forward
switch first.keyPath { switch first.keyPath {
case \MailMessage.sender: savedColumn = "sender" case \MailMessage.displaySender: savedColumn = "sender"
case \MailMessage.subject: savedColumn = "subject" case \MailMessage.subject: savedColumn = "subject"
case \MailMessage.attachmentCount: savedColumn = "attachments" case \MailMessage.attachmentCount: savedColumn = "attachments"
default: savedColumn = "date" default: savedColumn = "date"
@@ -502,6 +386,8 @@ enum MailDrag {
struct MessageReader: View { struct MessageReader: View {
let message: MailMessage? let message: MailMessage?
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
@AppStorage("bodyFontSize") private var bodyFontSize = 14.0
var body: some View { var body: some View {
ScrollView { ScrollView {
if let message { if let message {
@@ -516,7 +402,13 @@ struct MessageReader: View {
} }
}.textSelection(.enabled) }.textSelection(.enabled)
Divider() 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 { if !message.attachments.isEmpty {
AttachmentList(attachments: message.attachments) AttachmentList(attachments: message.attachments)
.padding(16) .padding(16)
@@ -544,13 +436,15 @@ struct ComposeView: View {
@State private var cc = "" @State private var cc = ""
@State private var bcc = "" @State private var bcc = ""
@State private var subject = "" @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 attachments: [MailAttachment] = []
@State private var importing = false @State private var importing = false
@State private var sending = false @State private var sending = false
@State private var initialized = false @State private var initialized = false
@State private var dropTargeted = false @State private var dropTargeted = false
@FocusState private var bodyFocused: Bool
@State private var error: String? @State private var error: String?
var body: some View { var body: some View {
@@ -565,8 +459,12 @@ struct ComposeView: View {
TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC") TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC")
TextField("Betreff", text: $subject) TextField("Betreff", text: $subject)
} }
TextEditor(text: $text).font(.body).frame(minHeight: 180).border(.quaternary) Toggle("HTML-Nachricht", isOn: $htmlEnabled)
.focused($bodyFocused) 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) { VStack(alignment: .leading, spacing: 8) {
Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline) Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline)
ScrollView { ScrollView {
@@ -602,7 +500,8 @@ struct ComposeView: View {
Task { Task {
do { do {
try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc, 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() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }
sending = false sending = false
@@ -612,7 +511,8 @@ struct ComposeView: View {
.disabled(MailStore.addresses(recipient + "," + cc + "," + bcc).isEmpty || accountID == nil) .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) .disabled(sending)
.interactiveDismissDisabled(sending) .interactiveDismissDisabled(sending)
.onAppear { .onAppear {
@@ -621,9 +521,7 @@ struct ComposeView: View {
accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id
recipient = draft?.recipient ?? "" recipient = draft?.recipient ?? ""
subject = draft?.subject ?? "" subject = draft?.subject ?? ""
text = draft?.body ?? ""
attachments = draft?.attachments ?? [] attachments = draft?.attachments ?? []
if draft?.isReply == true { bodyFocused = true }
} }
.fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in .fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in
do { addAttachments(try result.get()) } 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)") _ = try await command("UID STORE \(uid) \(read ? "+" : "-")FLAGS.SILENT (\\Seen)")
} }
func move(uid: UInt64, to path: String) async throws { struct AssignedUID: Equatable {
_ = try await command("UID MOVE \(uid) \(Self.quote(path))") 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. /// 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 allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"]
let safeFlags = flags.filter { allowed.contains($0) }.joined(separator: " ") let safeFlags = flags.filter { allowed.contains($0) }.joined(separator: " ")
sequence += 1 sequence += 1
@@ -258,7 +290,8 @@ final class IMAPClient {
let ready = try await connection.readLine() let ready = try await connection.readLine()
guard ready.hasPrefix("+") else { throw MailError.message("Nachricht konnte nicht im Ziel gespeichert werden: \(ready)") } 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 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 { static func encodeMailbox(_ value: String) -> String {
+25 -12
View File
@@ -93,8 +93,9 @@ enum MIME {
} }
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""), return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"), recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"),
date: date ?? .distantPast, body: bodies.isEmpty ? htmlBodies.joined(separator: "\n") : bodies.joined(separator: "\n"), 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"] ?? "")) 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]) { 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 extendedName = parameter("filename*", in: disposition).flatMap { $0.components(separatedBy: "''").last?.removingPercentEncoding }
let name = extendedName ?? parameter("filename", in: disposition) ?? parameter("name", in: type) let name = extendedName ?? parameter("filename", in: disposition) ?? parameter("name", in: type)
if name != nil || disposition.lowercased().hasPrefix("attachment") || !type.lowercased().hasPrefix("text/") { 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 { } else {
let value = text(decoded, charset: parameter("charset", in: type)) let value = text(decoded, charset: parameter("charset", in: type))
if type.lowercased().hasPrefix("text/html") { if type.lowercased().hasPrefix("text/html") {
// Render HTML as text: remote images and active content are never loaded. htmlBodies.append(value)
let plain = value.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) } } 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: "&quot;", with: "\"")
.replacingOccurrences(of: "&amp;", with: "&")
}
static func validAddress(_ value: String) -> Bool { 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 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), guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress),
!subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else { !subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.") 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 += "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 += "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 += "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 { for attachment in attachments {
let safeName = attachment.name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "Anhang" let safeName = attachment.name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "Anhang"
result += "--\(boundary)\r\nContent-Type: application/octet-stream\r\n" 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 } } 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] { func messages(folderID: String) throws -> [MailMessage] {
try rows(folderID: folderID).filter(\.present).map { row in try rows(folderID: folderID).filter(\.present).map { row in
var mail = MIME.parse(try Data(contentsOf: objectURL(row.objectHash)), id: row.key) 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 name: String
let mimeType: String let mimeType: String
let data: Data let data: Data
var contentID: String? = nil
} }
struct MailMessage: Identifiable, Sendable { struct MailMessage: Identifiable, Sendable {
@@ -58,6 +59,8 @@ struct MailMessage: Identifiable, Sendable {
var isRead = false var isRead = false
var cc = "" var cc = ""
var replyTo = "" var replyTo = ""
var htmlBody: String? = nil
var displaySender: String { MailDisplay.sender(sender) }
var attachmentCount: Int { attachments.count } 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 { 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("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
@AppStorage("idleEnabled") private var idleEnabled = true @AppStorage("idleEnabled") private var idleEnabled = true
@AppStorage("pollMinutes") private var pollMinutes = 5 @AppStorage("pollMinutes") private var pollMinutes = 5
@@ -24,6 +41,15 @@ struct MailSettings: View {
var body: some View { var body: some View {
Form { 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) 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) 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) { 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("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch)
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox) Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash) 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) { Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
Text("Immer").tag(MailDeleteConfirmation.always) Text("Immer").tag(MailDeleteConfirmation.always)
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple) Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
+80 -19
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import SwiftUI
@MainActor @Observable @MainActor @Observable
final class MailStore { final class MailStore {
@@ -130,7 +131,7 @@ final class MailStore {
items.flatMap { [$0] + allFolders($0.children ?? []) } 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 } guard !isBusy else { return }
isBusy = true isBusy = true
defer { isBusy = false } defer { isBusy = false }
@@ -143,8 +144,8 @@ final class MailStore {
defer { client.close() } defer { client.close() }
status = "\(account.email): Verbindung wird aufgebaut …" status = "\(account.email): Verbindung wird aufgebaut …"
try await client.login(account: account, password: PasswordStore.password(for: account.id)) try await client.login(account: account, password: PasswordStore.password(for: account.id))
var discovered = try await client.folders(accountID: account.id) var discovered = inboxOnly ? (folders[account.id] ?? []) : try await client.folders(accountID: account.id)
let pending = try archive.pendingSent(accountID: account.id) let pending = inboxOnly ? [] : try archive.pendingSent(accountID: account.id)
if !pending.isEmpty { if !pending.isEmpty {
var sent = allFolders(discovered).first { $0.specialUse?.contains("\\sent") == true } var sent = allFolders(discovered).first { $0.specialUse?.contains("\\sent") == true }
?? allFolders(discovered).first { ["sent", "gesendet", "sent messages", "sent items"].contains($0.name.lowercased()) } ?? 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: [:])) try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:]))
} }
folders = updated folders = updated
for folder in allFolders(discovered) where folder.selectable { for folder in allFolders(discovered) where folder.selectable && (!inboxOnly || folder.isInbox) {
do { do {
status = "\(folder.name): Änderungen prüfen …" status = "\(folder.name): Änderungen prüfen …"
let inventory = try await client.inventory(folder: folder) let inventory = try await client.inventory(folder: folder)
@@ -229,7 +230,7 @@ final class MailStore {
} catch { self.error = error.localizedDescription } } 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 { guard let account = accounts.first(where: { $0.id == accountID }) else {
throw MailError.message("Bitte ein Absenderkonto auswählen.") throw MailError.message("Bitte ein Absenderkonto auswählen.")
} }
@@ -243,7 +244,7 @@ final class MailStore {
let copies = Self.addresses(cc) let copies = Self.addresses(cc)
let blindCopies = Self.addresses(bcc) let blindCopies = Self.addresses(bcc)
let envelope = Array(Set(addresses + copies + blindCopies)).sorted() 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 localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien")
let localID = UUID().uuidString let localID = UUID().uuidString
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message. // 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) { func reorderAccount(_ id: UUID, before target: UUID) {
guard !isBusy, id != target, guard id != target,
let source = accounts.first(where: { $0.id == id }) else { return } let source = accounts.first(where: { $0.id == id }) else { return }
var updated = accounts.filter { $0.id != id } var updated = accounts.filter { $0.id != id }
guard let index = updated.firstIndex(where: { $0.id == target }) else { return } guard let index = updated.firstIndex(where: { $0.id == target }) else { return }
@@ -299,20 +316,26 @@ final class MailStore {
} catch { client.close(); throw error } } catch { client.close(); throw error }
} }
/// Serializes user mutations with synchronization. Refresh also reconciles partial successes. /// Message mutations commit their confirmed changes locally; folder operations rediscover the tree.
private func mutate(_ operation: () async throws -> Void) async { private func mutate(refreshAfter: Bool = true, _ operation: () async throws -> Void) async {
while isBusy { if Task.isCancelled { return }; try? await Task.sleep(for: .milliseconds(150)) } while isBusy { if Task.isCancelled { return }; try? await Task.sleep(for: .milliseconds(150)) }
guard !Task.isCancelled else { return }
isBusy = true isBusy = true
var failure: String? var failure: String?
do { try await operation() } do { try await operation() }
catch { failure = error.localizedDescription } catch { failure = error.localizedDescription }
isBusy = false 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 } if let failure { error = failure }
} }
func moveMessages(ids: [String], to destination: MailFolder) async { 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.") } guard destination.selectable else { throw MailError.message("Bitte einen beschreibbaren Zielordner wählen.") }
for id in Set(ids) { for id in Set(ids) {
try await self.moveOne(id: id, to: destination) try await self.moveOne(id: id, to: destination)
@@ -328,13 +351,16 @@ final class MailStore {
} }
let sourceClient = try await client(for: source.accountID) let sourceClient = try await client(for: source.accountID)
defer { sourceClient.close() } 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 { guard inventory.validity == record.validity, inventory.flags[uid] != nil else {
throw MailError.message("Der Serverbestand hat sich verändert. Bitte erneut abrufen.") throw MailError.message("Der Serverbestand hat sich verändert. Bitte erneut abrufen.")
} }
let capabilities = try await sourceClient.capabilities() let capabilities = try await sourceClient.capabilities()
if source.accountID == destination.accountID, capabilities.contains("MOVE") { 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 return
} }
guard capabilities.contains("UIDPLUS") else { guard capabilities.contains("UIDPLUS") else {
@@ -343,25 +369,56 @@ final class MailStore {
let raw = try await sourceClient.rawMessage(uid: uid) let raw = try await sourceClient.rawMessage(uid: uid)
let targetClient = try await client(for: destination.accountID) let targetClient = try await client(for: destination.accountID)
defer { targetClient.close() } 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. // 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]) } do { try await sourceClient.remove(uids: [uid]) }
catch { catch {
throw MailError.message("Die Nachricht wurde ins Ziel kopiert, konnte aber nicht aus der Quelle entfernt werden. Beide Kopien bleiben erhalten. \(error.localizedDescription)") 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 { func deleteMessages(ids: [String]) async {
await mutate { await mutate(refreshAfter: false) {
for id in Set(ids) { for id in Set(ids) {
let record = try self.storage().record(id: id) let record = try self.storage().record(id: id)
guard let source = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue } guard let source = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue }
if source.isTrash { if source.isTrash {
let client = try await self.client(for: source.accountID) let client = try await self.client(for: source.accountID)
defer { client.close() } 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.") } 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 await client.remove(uids: inventory.flags[uid] == nil ? [] : [uid])
try self.storage().applyMessageChange(id: id)
try self.updateLocalPresentation()
} else { } else {
guard let trash = self.allFolders(self.folders[source.accountID] ?? []).first(where: \.isTrash) 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.") 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 { func setRead(ids: [String], read: Bool) async {
await mutate { await mutate(refreshAfter: false) {
for id in Set(ids) { for id in Set(ids) {
let record = try self.storage().record(id: id) let record = try self.storage().record(id: id)
guard let folder = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue } guard let folder = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue }
let client = try await self.client(for: folder.accountID) let client = try await self.client(for: folder.accountID)
defer { client.close() } 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 { guard inventory.validity == record.validity, inventory.flags[uid] != nil else {
throw MailError.message("Nachricht wurde auf dem Server verändert. Bitte erneut abrufen.") throw MailError.message("Nachricht wurde auf dem Server verändert. Bitte erneut abrufen.")
} }
try await client.setSeen(uid: uid, read: read) 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 } guard let id = self.pendingIdleAccounts.first else { return }
self.pendingIdleAccounts.remove(id) self.pendingIdleAccounts.remove(id)
guard self.accounts.contains(where: { $0.id == id }) else { continue } 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 { struct MessageDetailWindow: View {
let store: MailStore let store: MailStore
let messageID: String? let messageID: String?
@State private var draft: ComposeDraft? @Environment(\.openWindow) private var openWindow
@State private var confirmingDelete = false @State private var confirmingDelete = false
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always @AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
@AppStorage("markReadImmediately") private var markReadImmediately = false
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
private var message: MailMessage? { private var message: MailMessage? {
_ = store.archiveRevision _ = store.archiveRevision
@@ -57,15 +59,15 @@ struct MessageDetailWindow: View {
guard let message else { return } guard let message else { return }
switch command { switch command {
case .reply, .forward: 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: case .delete:
if deleteConfirmation.isRequired(count: 1) { confirmingDelete = true } if deleteConfirmation.isRequired(count: 1) { confirmingDelete = true }
else { Task { await store.deleteMessages(ids: [message.id]) } } else { Task { await store.deleteMessages(ids: [message.id]) } }
default: break default: break
} }
}.disabled(message == nil || store.isBusy) }.disabled(message == nil)
} }
.sheet(item: $draft) { ComposeView(store: store, draft: $0) }
.alert("Nachricht löschen?", isPresented: $confirmingDelete) { .alert("Nachricht löschen?", isPresented: $confirmingDelete) {
Button("Abbrechen", role: .cancel) {} Button("Abbrechen", role: .cancel) {}
Button("Löschen", role: .destructive) { 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 endgültig gelöscht."
: "Die Nachricht wird auch auf dem Server in den Papierkorb verschoben.") : "Die Nachricht wird auch auf dem Server in den Papierkorb verschoben.")
} }
.task(id: messageID) { .task(id: AutoReadRequest(ids: Set([messageID].compactMap { $0 }), immediately: markReadImmediately, seconds: markReadDelaySeconds)) {
if let message, !message.isRead { await store.setRead(ids: [message.id], read: true) } 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 } })) { .alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
Button("OK") { 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 WindowGroup("Nachricht", id: "message", for: String.self) { $id in
MessageDetailWindow(store: store, messageID: id) MessageDetailWindow(store: store, messageID: id)
}.defaultSize(width: 800, height: 650) }.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() } Settings { MailSettings() }
} }
} }
+5 -2
View File
@@ -9,14 +9,17 @@ extension MailStore {
let archive = try MailArchive(root: root) let archive = try MailArchive(root: root)
let account = MailAccount(name: "UI-Prüfung", email: "fixture@example.com", username: "fixture", host: "example.invalid") 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") 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 { for index in 1...2 {
let raw = try MIME.compose(from: "sender@example.com", to: [account.email], let raw = try MIME.compose(from: "sender@example.com", to: [account.email],
subject: "UI-Nachricht \(index)", body: "Nachrichtentext für die Fensterprüfung.", subject: "UI-Nachricht \(index)", body: "Nachrichtentext für die Fensterprüfung.",
attachments: [MailAttachment(name: "Beispiel.txt", mimeType: "text/plain", data: Data("Anhang".utf8))]) 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"]) 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)") } } catch { fatalError("UI fixture: \(error)") }
} }
} }
+45
View File
@@ -4,6 +4,51 @@ import Testing
@MainActor @MainActor
struct MailArchiveTests { 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 { @Test func diskPersistenceIncrementalReconciliationAndRestore() throws {
let base = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) let base = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: base) } 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"].hover()
app.menuItems["New Window"].click() app.menuItems["New Window"].click()
} }
let table = app.outlines.element(boundBy: 1) let table = app.outlines.firstMatch
XCTAssertTrue(table.waitForExistence(timeout: 5), app.debugDescription) XCTAssertTrue(table.waitForExistence(timeout: 5), app.debugDescription)
let row = table.descendants(matching: .outlineRow).firstMatch let row = table.descendants(matching: .outlineRow).firstMatch
XCTAssertTrue(row.waitForExistence(timeout: 5), app.debugDescription) XCTAssertTrue(row.waitForExistence(timeout: 5), app.debugDescription)
@@ -62,6 +62,83 @@ final class MikeMailUITests: XCTestCase {
XCTAssertGreaterThanOrEqual(app.windows.count, 2, app.debugDescription) XCTAssertGreaterThanOrEqual(app.windows.count, 2, app.debugDescription)
reply.click() reply.click()
XCTAssertTrue(app.textFields["composeCC"].waitForExistence(timeout: 5)) 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)
} }
} }