Alpha 3
This commit is contained in:
committed by
Dr. Michael Kübler
parent
8e95cc3acd
commit
c5e46946ac
@@ -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
@@ -17,12 +17,12 @@ struct ContentView: View {
|
||||
@State private var selectedFolder: String?
|
||||
@State private var selectedMessages: Set<String> = []
|
||||
@State private var showingAccounts = false
|
||||
@State private var composing = false
|
||||
@State private var draft: ComposeDraft?
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@AppStorage("idleEnabled") private var idleEnabled = true
|
||||
@AppStorage("pollMinutes") private var pollMinutes = 5
|
||||
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
|
||||
@AppStorage("markReadImmediately") private var markReadImmediately = false
|
||||
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
|
||||
@State private var pendingDeletion: [String] = []
|
||||
@State private var confirmingDelete = false
|
||||
@State private var restored = false
|
||||
@@ -71,7 +71,6 @@ struct ContentView: View {
|
||||
}
|
||||
.frame(height: 26)
|
||||
.padding(8)
|
||||
.disabled(store.isBusy)
|
||||
VSplitView {
|
||||
MessageTable(messages: store.messages.filter(searchFilter.matches), selection: $selectedMessages, busy: store.isBusy, action: perform)
|
||||
.onDeleteCommand { deleteSelection() }
|
||||
@@ -116,17 +115,15 @@ struct ContentView: View {
|
||||
}
|
||||
.accessibilityIdentifier("Abrufen")
|
||||
.disabled(store.isBusy)
|
||||
Button("Neue Nachricht", systemImage: "square.and.pencil") { composing = true }
|
||||
Button("Neue Nachricht", systemImage: "square.and.pencil") { openComposer(ComposeDraft(accountID: composeAccountID)) }
|
||||
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
|
||||
Button("Löschen", systemImage: "trash") { deleteSelection() }
|
||||
.disabled(selectedMessages.isEmpty || store.isBusy)
|
||||
.disabled(selectedMessages.isEmpty)
|
||||
Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true }
|
||||
SettingsLink { Label("Einstellungen", systemImage: "gearshape") }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAccounts) { AccountsView(store: store) }
|
||||
.sheet(isPresented: $composing) { ComposeView(store: store, defaultAccountID: composeAccountID) }
|
||||
.sheet(item: $draft) { ComposeView(store: store, draft: $0) }
|
||||
.sheet(item: Binding(get: { store.authenticationAccount }, set: { value in
|
||||
if value == nil { store.dismissPasswordRequest() }
|
||||
})) { account in
|
||||
@@ -136,11 +133,13 @@ struct ContentView: View {
|
||||
store.configureIdle(enabled: idleEnabled)
|
||||
}
|
||||
.task(id: pollMinutes) { await store.poll(intervalMinutes: pollMinutes) }
|
||||
.task(id: selectedMessages) {
|
||||
.task(id: AutoReadRequest(ids: selectedMessages, immediately: markReadImmediately, seconds: markReadDelaySeconds)) {
|
||||
guard selectedMessages.count == 1, let id = selectedMessages.first,
|
||||
let message = store.message(id: id), !message.isRead else { return }
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
if !Task.isCancelled { await store.setRead(ids: [id], read: true) }
|
||||
do {
|
||||
try await AutoReadRequest(ids: [id], immediately: markReadImmediately, seconds: markReadDelaySeconds).wait()
|
||||
await store.setRead(ids: [id], read: true)
|
||||
} catch { /* Selection changed before the read delay elapsed. */ }
|
||||
}
|
||||
.alert(pendingDeletion.count == 1 ? "Nachricht löschen?" : "\(pendingDeletion.count) Nachrichten löschen?", isPresented: $confirmingDelete) {
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
@@ -179,6 +178,10 @@ struct ContentView: View {
|
||||
.frame(minWidth: 850, minHeight: 550)
|
||||
}
|
||||
|
||||
private func openComposer(_ draft: ComposeDraft) {
|
||||
openWindow(id: "compose", value: store.prepareCompose(draft))
|
||||
}
|
||||
|
||||
private func perform(_ command: MessageCommand, _ ids: Set<String>) {
|
||||
guard !ids.isEmpty else { return }
|
||||
switch command {
|
||||
@@ -186,7 +189,7 @@ struct ContentView: View {
|
||||
if ids.count == 1, let id = ids.first { openWindow(id: "message", value: id) }
|
||||
case .reply, .forward:
|
||||
if ids.count == 1, let id = ids.first, let message = store.message(id: id) {
|
||||
draft = .from(message, accountID: store.accountID(for: id), forwarding: command == .forward)
|
||||
openComposer(.from(message, accountID: store.accountID(for: id), forwarding: command == .forward))
|
||||
}
|
||||
case .delete:
|
||||
requestDeletion(Array(ids))
|
||||
@@ -196,12 +199,12 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func deleteSelection() {
|
||||
guard !store.isBusy, !selectedMessages.isEmpty else { return }
|
||||
guard !selectedMessages.isEmpty else { return }
|
||||
requestDeletion(Array(selectedMessages))
|
||||
}
|
||||
|
||||
private func requestDeletion(_ ids: [String]) {
|
||||
guard !store.isBusy, !ids.isEmpty else { return }
|
||||
guard !ids.isEmpty else { return }
|
||||
if deleteConfirmation.isRequired(count: ids.count) {
|
||||
pendingDeletion = ids
|
||||
confirmingDelete = true
|
||||
@@ -258,22 +261,10 @@ struct AccountSidebar: View {
|
||||
@State private var confirmingTrash = false
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
if showInbox {
|
||||
HStack { Label("Gemeinsamer Posteingang", systemImage: "tray.2"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:inbox")) }.tag("unified:inbox")
|
||||
}
|
||||
if showTrash {
|
||||
HStack { Label("Gemeinsamer Papierkorb", systemImage: "trash"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:trash")) }.tag("unified:trash")
|
||||
.contextMenu { Button("Papierkorb leeren…") { confirmTrash("unified:trash") }.disabled(store.isBusy) }
|
||||
}
|
||||
ForEach(store.accounts) { account in
|
||||
AccountTree(store: store, account: account, selection: $selection,
|
||||
create: { parent in beginCreate(account.id, parent: parent) },
|
||||
delete: { folder in deleting = folder; confirmingFolder = true },
|
||||
empty: { confirmTrash($0.id) })
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
MailSidebarList(store: store, selection: $selection, showInbox: showInbox, showTrash: showTrash,
|
||||
create: { beginCreate($0, parent: $1) },
|
||||
delete: { deleting = $0; confirmingFolder = true },
|
||||
empty: confirmTrash)
|
||||
.onChange(of: showInbox) { _, visible in if !visible && selection == "unified:inbox" { selection = nil } }
|
||||
.onChange(of: showTrash) { _, visible in if !visible && selection == "unified:trash" { selection = nil } }
|
||||
.alert("Neuen Ordner erstellen", isPresented: $creating) {
|
||||
@@ -308,113 +299,6 @@ struct AccountSidebar: View {
|
||||
private func confirmTrash(_ id: String) { trashID = id; confirmingTrash = true }
|
||||
}
|
||||
|
||||
struct AccountTree: View {
|
||||
let store: MailStore
|
||||
let account: MailAccount
|
||||
@Binding var selection: String?
|
||||
let create: (MailFolder?) -> Void
|
||||
let delete: (MailFolder) -> Void
|
||||
let empty: (MailFolder) -> Void
|
||||
@AppStorage private var expanded: Bool
|
||||
|
||||
init(store: MailStore, account: MailAccount, selection: Binding<String?>,
|
||||
create: @escaping (MailFolder?) -> Void, delete: @escaping (MailFolder) -> Void,
|
||||
empty: @escaping (MailFolder) -> Void) {
|
||||
self.store = store; self.account = account; _selection = selection
|
||||
self.create = create; self.delete = delete; self.empty = empty
|
||||
_expanded = AppStorage(wrappedValue: true, "accountExpanded.\(account.id)")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup(isExpanded: $expanded) {
|
||||
ForEach(store.folders[account.id] ?? []) { folder in
|
||||
FolderTree(store: store, folder: folder, create: create, delete: delete, empty: empty)
|
||||
}
|
||||
if store.folders[account.id] == nil {
|
||||
Text("Zum Laden „Abrufen“ wählen").font(.caption).foregroundStyle(.secondary).selectionDisabled()
|
||||
}
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading) {
|
||||
Text(account.name.isEmpty ? account.email : account.name)
|
||||
Text(account.email).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
}
|
||||
.draggable("mikemail-account:" + account.id.uuidString)
|
||||
.dropDestination(for: String.self) { values, _ in
|
||||
guard !store.isBusy, let value = values.first, value.hasPrefix("mikemail-account:"),
|
||||
let id = UUID(uuidString: String(value.dropFirst("mikemail-account:".count))) else { return false }
|
||||
store.reorderAccount(id, before: account.id)
|
||||
return true
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Neuen Ordner erstellen…") { create(nil) }
|
||||
if let index = store.accounts.firstIndex(where: { $0.id == account.id }) {
|
||||
Button("Konto nach oben") {
|
||||
store.reorderAccount(account.id, before: store.accounts[index - 1].id)
|
||||
}.disabled(index == 0)
|
||||
Button("Konto nach unten") {
|
||||
store.reorderAccount(store.accounts[index + 1].id, before: account.id)
|
||||
}.disabled(index + 1 == store.accounts.count)
|
||||
}
|
||||
}
|
||||
.disabled(store.isBusy)
|
||||
}
|
||||
.tag("account:" + account.id.uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
struct FolderTree: View {
|
||||
let store: MailStore
|
||||
let folder: MailFolder
|
||||
let create: (MailFolder?) -> Void
|
||||
let delete: (MailFolder) -> Void
|
||||
let empty: (MailFolder) -> Void
|
||||
@AppStorage private var expanded: Bool
|
||||
|
||||
init(store: MailStore, folder: MailFolder, create: @escaping (MailFolder?) -> Void,
|
||||
delete: @escaping (MailFolder) -> Void, empty: @escaping (MailFolder) -> Void) {
|
||||
self.store = store; self.folder = folder
|
||||
self.create = create; self.delete = delete; self.empty = empty
|
||||
_expanded = AppStorage(wrappedValue: true, "folderExpanded.\(folder.id)")
|
||||
}
|
||||
var body: some View {
|
||||
Group {
|
||||
if let children = folder.children, !children.isEmpty {
|
||||
DisclosureGroup(isExpanded: $expanded) {
|
||||
ForEach(children) { child in
|
||||
FolderTree(store: store, folder: child, create: create, delete: delete, empty: empty)
|
||||
}
|
||||
} label: { label }
|
||||
} else { label }
|
||||
}
|
||||
.tag(folder.id).selectionDisabled(!folder.selectable)
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
HStack {
|
||||
Label(folder.name, systemImage: folder.isTrash ? "trash" : folder.icon)
|
||||
Spacer()
|
||||
UnreadBadge(count: store.unreadCount(folderID: folder.id))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.dropDestination(for: String.self) { values, _ in
|
||||
guard !store.isBusy, folder.selectable else { return false }
|
||||
let ids = values.flatMap(MailDrag.decode)
|
||||
guard !ids.isEmpty else { return false }
|
||||
Task { await store.moveMessages(ids: ids, to: folder) }
|
||||
return true
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Unterordner erstellen…") { create(folder) }
|
||||
Button("Ordner löschen…", role: .destructive) { delete(folder) }.disabled(folder.isInbox)
|
||||
if folder.isTrash { Button("Papierkorb leeren…", role: .destructive) { empty(folder) } }
|
||||
}.disabled(store.isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageTable: View {
|
||||
let messages: [MailMessage]
|
||||
@Binding var selection: Set<String>
|
||||
@@ -431,12 +315,12 @@ struct MessageTable: View {
|
||||
Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
|
||||
}
|
||||
}.width(55)
|
||||
TableColumn("Absender", value: \.sender) { message in
|
||||
Text(message.sender).fontWeight(message.isRead ? .regular : .bold)
|
||||
TableColumn("Absender", value: \.displaySender) { message in
|
||||
Text(message.displaySender).fontWeight(message.isRead ? .regular : .bold)
|
||||
}.width(min: 110, ideal: 160)
|
||||
TableColumn("Datum", value: \.date) { message in
|
||||
if message.date == .distantPast { Text("—") }
|
||||
else { Text(message.date, format: .dateTime.day().month().year().hour().minute()).fontWeight(message.isRead ? .regular : .bold) }
|
||||
else { Text(MailDisplay.date(message.date)).monospacedDigit().fontWeight(message.isRead ? .regular : .bold) }
|
||||
}.width(min: 120, ideal: 150)
|
||||
TableColumn("Betreff", value: \.subject) { message in
|
||||
Text(message.subject).fontWeight(message.isRead ? .regular : .bold)
|
||||
@@ -449,9 +333,9 @@ struct MessageTable: View {
|
||||
.contextMenu(forSelectionType: String.self) { ids in
|
||||
if ids.count == 1 {
|
||||
Button("In neuem Fenster öffnen") { action(.open, ids) }
|
||||
MessageActionButtons { action($0, ids) }.disabled(busy)
|
||||
MessageActionButtons { action($0, ids) }
|
||||
} else if !ids.isEmpty {
|
||||
Button("Nachrichten löschen…") { action(.delete, ids) }.disabled(busy)
|
||||
Button("Nachrichten löschen…") { action(.delete, ids) }
|
||||
}
|
||||
if !ids.isEmpty {
|
||||
Divider()
|
||||
@@ -462,7 +346,7 @@ struct MessageTable: View {
|
||||
.onAppear {
|
||||
let order: SortOrder = savedAscending ? .forward : .reverse
|
||||
switch savedColumn {
|
||||
case "sender": sortOrder = [KeyPathComparator(\MailMessage.sender, order: order)]
|
||||
case "sender": sortOrder = [KeyPathComparator(\MailMessage.displaySender, order: order)]
|
||||
case "subject": sortOrder = [KeyPathComparator(\MailMessage.subject, order: order)]
|
||||
case "attachments": sortOrder = [KeyPathComparator(\MailMessage.attachmentCount, order: order)]
|
||||
default: sortOrder = [KeyPathComparator(\MailMessage.date, order: order)]
|
||||
@@ -472,7 +356,7 @@ struct MessageTable: View {
|
||||
guard let first = value.first else { return }
|
||||
savedAscending = first.order == .forward
|
||||
switch first.keyPath {
|
||||
case \MailMessage.sender: savedColumn = "sender"
|
||||
case \MailMessage.displaySender: savedColumn = "sender"
|
||||
case \MailMessage.subject: savedColumn = "subject"
|
||||
case \MailMessage.attachmentCount: savedColumn = "attachments"
|
||||
default: savedColumn = "date"
|
||||
@@ -502,6 +386,8 @@ enum MailDrag {
|
||||
|
||||
struct MessageReader: View {
|
||||
let message: MailMessage?
|
||||
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
|
||||
@AppStorage("bodyFontSize") private var bodyFontSize = 14.0
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if let message {
|
||||
@@ -516,7 +402,13 @@ struct MessageReader: View {
|
||||
}
|
||||
}.textSelection(.enabled)
|
||||
Divider()
|
||||
Text(message.body).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let html = message.htmlBody {
|
||||
HTMLMessageBody(html: html, attachments: message.attachments)
|
||||
.id(message.id)
|
||||
} else {
|
||||
Text(message.body).font(.custom(bodyFontName, size: bodyFontSize))
|
||||
.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if !message.attachments.isEmpty {
|
||||
AttachmentList(attachments: message.attachments)
|
||||
.padding(16)
|
||||
@@ -544,13 +436,15 @@ struct ComposeView: View {
|
||||
@State private var cc = ""
|
||||
@State private var bcc = ""
|
||||
@State private var subject = ""
|
||||
@State private var text = ""
|
||||
@State private var editor = MailEditorModel()
|
||||
@State private var htmlEnabled = true
|
||||
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
|
||||
@AppStorage("bodyFontSize") private var bodyFontSize = 14.0
|
||||
@State private var attachments: [MailAttachment] = []
|
||||
@State private var importing = false
|
||||
@State private var sending = false
|
||||
@State private var initialized = false
|
||||
@State private var dropTargeted = false
|
||||
@FocusState private var bodyFocused: Bool
|
||||
@State private var error: String?
|
||||
|
||||
var body: some View {
|
||||
@@ -565,8 +459,12 @@ struct ComposeView: View {
|
||||
TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC")
|
||||
TextField("Betreff", text: $subject)
|
||||
}
|
||||
TextEditor(text: $text).font(.body).frame(minHeight: 180).border(.quaternary)
|
||||
.focused($bodyFocused)
|
||||
Toggle("HTML-Nachricht", isOn: $htmlEnabled)
|
||||
if htmlEnabled { MailFormattingBar(editor: editor) }
|
||||
MailBodyEditor(model: editor, initialText: draft?.body ?? "",
|
||||
fontName: bodyFontName, fontSize: bodyFontSize, focusOnOpen: draft?.isReply == true,
|
||||
richText: htmlEnabled)
|
||||
.frame(minHeight: 180, maxHeight: .infinity).border(.quaternary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline)
|
||||
ScrollView {
|
||||
@@ -602,7 +500,8 @@ struct ComposeView: View {
|
||||
Task {
|
||||
do {
|
||||
try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc,
|
||||
subject: subject, body: text, attachments: attachments)
|
||||
subject: subject, body: editor.plainText, attachments: attachments,
|
||||
htmlBody: htmlEnabled ? try editor.html() : nil)
|
||||
dismiss()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
sending = false
|
||||
@@ -612,7 +511,8 @@ struct ComposeView: View {
|
||||
.disabled(MailStore.addresses(recipient + "," + cc + "," + bcc).isEmpty || accountID == nil)
|
||||
}
|
||||
}
|
||||
.padding(24).frame(width: 700, height: 740)
|
||||
.padding(24)
|
||||
.frame(minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 600, idealHeight: 780, maxHeight: .infinity)
|
||||
.disabled(sending)
|
||||
.interactiveDismissDisabled(sending)
|
||||
.onAppear {
|
||||
@@ -621,9 +521,7 @@ struct ComposeView: View {
|
||||
accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id
|
||||
recipient = draft?.recipient ?? ""
|
||||
subject = draft?.subject ?? ""
|
||||
text = draft?.body ?? ""
|
||||
attachments = draft?.attachments ?? []
|
||||
if draft?.isReply == true { bodyFocused = true }
|
||||
}
|
||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in
|
||||
do { addAttachments(try result.get()) }
|
||||
|
||||
@@ -232,8 +232,39 @@ final class IMAPClient {
|
||||
_ = try await command("UID STORE \(uid) \(read ? "+" : "-")FLAGS.SILENT (\\Seen)")
|
||||
}
|
||||
|
||||
func move(uid: UInt64, to path: String) async throws {
|
||||
_ = try await command("UID MOVE \(uid) \(Self.quote(path))")
|
||||
struct AssignedUID: Equatable {
|
||||
let validity: String
|
||||
let uid: String
|
||||
}
|
||||
|
||||
/// Single-message COPYUID/APPENDUID responses identify the local destination record.
|
||||
static func assignedUID(_ lines: [String], sourceUID: UInt64? = nil) -> AssignedUID? {
|
||||
let code = sourceUID == nil ? "APPENDUID" : "COPYUID"
|
||||
for line in lines {
|
||||
guard let start = line.range(of: "[" + code + " ", options: .caseInsensitive),
|
||||
let end = line[start.upperBound...].firstIndex(of: "]") else { continue }
|
||||
let parts = line[start.upperBound..<end].split(separator: " ").map(String.init)
|
||||
guard parts.count == (sourceUID == nil ? 2 : 3),
|
||||
let validity = UInt64(parts[0]), validity > 0,
|
||||
let uid = UInt64(parts[parts.count - 1]), uid > 0 else { continue }
|
||||
if let sourceUID, UInt64(parts[1]) != sourceUID { continue }
|
||||
return AssignedUID(validity: String(validity), uid: String(uid))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Select and inspect only the requested UID, without enumerating the mailbox.
|
||||
func messageInventory(folder: MailFolder, uid: UInt64) async throws -> MailboxInventory {
|
||||
let selected = try await command("SELECT \(Self.quote(folder.path))")
|
||||
let validity = try Self.uidValidity(from: selected.lines)
|
||||
let fetched = try await command("UID FETCH \(uid) (UID FLAGS)")
|
||||
return MailboxInventory(validity: validity, flags: try Self.parseFlags(fetched.lines))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func move(uid: UInt64, to path: String) async throws -> AssignedUID? {
|
||||
let result = try await command("UID MOVE \(uid) \(Self.quote(path))")
|
||||
return Self.assignedUID(result.lines, sourceUID: uid)
|
||||
}
|
||||
|
||||
/// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail.
|
||||
@@ -249,7 +280,8 @@ final class IMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
func append(_ message: Data, path: String, flags: [String]) async throws {
|
||||
@discardableResult
|
||||
func append(_ message: Data, path: String, flags: [String]) async throws -> AssignedUID? {
|
||||
let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"]
|
||||
let safeFlags = flags.filter { allowed.contains($0) }.joined(separator: " ")
|
||||
sequence += 1
|
||||
@@ -258,7 +290,8 @@ final class IMAPClient {
|
||||
let ready = try await connection.readLine()
|
||||
guard ready.hasPrefix("+") else { throw MailError.message("Nachricht konnte nicht im Ziel gespeichert werden: \(ready)") }
|
||||
try await connection.send(message + Data("\r\n".utf8))
|
||||
_ = try await response(tag: tag)
|
||||
let result = try await response(tag: tag)
|
||||
return Self.assignedUID(result.lines)
|
||||
}
|
||||
|
||||
static func encodeMailbox(_ value: String) -> String {
|
||||
|
||||
+25
-12
@@ -93,8 +93,9 @@ enum MIME {
|
||||
}
|
||||
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
|
||||
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"),
|
||||
date: date ?? .distantPast, body: bodies.isEmpty ? htmlBodies.joined(separator: "\n") : bodies.joined(separator: "\n"),
|
||||
attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? ""))
|
||||
date: date ?? .distantPast, body: bodies.isEmpty ? plainText(htmlBodies.joined(separator: "\n")) : bodies.joined(separator: "\n"),
|
||||
attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? ""),
|
||||
htmlBody: htmlBodies.isEmpty ? nil : htmlBodies.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) {
|
||||
@@ -127,26 +128,30 @@ enum MIME {
|
||||
let extendedName = parameter("filename*", in: disposition).flatMap { $0.components(separatedBy: "''").last?.removingPercentEncoding }
|
||||
let name = extendedName ?? parameter("filename", in: disposition) ?? parameter("name", in: type)
|
||||
if name != nil || disposition.lowercased().hasPrefix("attachment") || !type.lowercased().hasPrefix("text/") {
|
||||
attachments.append(MailAttachment(name: decodeHeader(name ?? "Anhang"), mimeType: type.components(separatedBy: ";")[0], data: decoded))
|
||||
attachments.append(MailAttachment(name: decodeHeader(name ?? "Anhang"), mimeType: type.components(separatedBy: ";")[0], data: decoded,
|
||||
contentID: fields["content-id"]?.trimmingCharacters(in: CharacterSet(charactersIn: "<> "))))
|
||||
} else {
|
||||
let value = text(decoded, charset: parameter("charset", in: type))
|
||||
if type.lowercased().hasPrefix("text/html") {
|
||||
// Render HTML as text: remote images and active content are never loaded.
|
||||
let plain = value.replacingOccurrences(of: "(?is)<(script|style)[^>]*>.*?</\\1>", with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: "(?i)<br\\s*/?>|</p>|</div>", with: "\n", options: .regularExpression)
|
||||
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: " ", with: " ").replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "&", with: "&")
|
||||
htmlBodies.append(plain)
|
||||
htmlBodies.append(value)
|
||||
} else { bodies.append(value) }
|
||||
}
|
||||
}
|
||||
|
||||
static func plainText(_ html: String) -> String {
|
||||
html.replacingOccurrences(of: "(?is)<(script|style)[^>]*>.*?</\\1>", with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: "(?i)<br\\s*/?>|</p>|</div>", with: "\n", options: .regularExpression)
|
||||
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||
.replacingOccurrences(of: " ", with: " ").replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
}
|
||||
|
||||
static func validAddress(_ value: String) -> Bool {
|
||||
value.range(of: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment]) throws -> Data {
|
||||
static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil) throws -> Data {
|
||||
guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress),
|
||||
!subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
|
||||
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.")
|
||||
@@ -163,7 +168,15 @@ enum MIME {
|
||||
result += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n"
|
||||
result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n"
|
||||
result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
|
||||
result += "--\(boundary)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n"
|
||||
if let htmlBody {
|
||||
let alternative = "Alternative-" + UUID().uuidString
|
||||
result += "--\(boundary)\r\nContent-Type: multipart/alternative; boundary=\"\(alternative)\"\r\n\r\n"
|
||||
result += "--\(alternative)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n"
|
||||
result += "--\(alternative)\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(htmlBody.utf8)))\r\n"
|
||||
result += "--\(alternative)--\r\n"
|
||||
} else {
|
||||
result += "--\(boundary)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n"
|
||||
}
|
||||
for attachment in attachments {
|
||||
let safeName = attachment.name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "Anhang"
|
||||
result += "--\(boundary)\r\nContent-Type: application/octet-stream\r\n"
|
||||
|
||||
@@ -172,6 +172,35 @@ final class MailArchive {
|
||||
} catch { context.rollback(); throw error }
|
||||
}
|
||||
|
||||
/// Apply only a confirmed server change; keep original EML objects available for backups.
|
||||
func applyMessageChange(id: String, destination: String? = nil,
|
||||
assigned: IMAPClient.AssignedUID? = nil, flags: [String]? = nil,
|
||||
removed: Bool = true) throws {
|
||||
let key = id
|
||||
guard let row = try context.fetch(FetchDescriptor<MailArchiveSchema.ArchivedMail>(
|
||||
predicate: #Predicate { $0.key == key })).first else {
|
||||
throw MailError.message("Lokale Nachricht nicht gefunden.")
|
||||
}
|
||||
do {
|
||||
if let destination, let assigned {
|
||||
let targetKey = destination + ":" + assigned.validity + ":" + assigned.uid
|
||||
if let target = try context.fetch(FetchDescriptor<MailArchiveSchema.ArchivedMail>(
|
||||
predicate: #Predicate { $0.key == targetKey })).first {
|
||||
target.objectHash = row.objectHash
|
||||
target.flags = flags ?? row.flags
|
||||
target.present = true
|
||||
} else {
|
||||
context.insert(MailArchiveSchema.ArchivedMail(key: targetKey, folderID: destination,
|
||||
validity: assigned.validity, uid: assigned.uid, objectHash: row.objectHash,
|
||||
flags: flags ?? row.flags))
|
||||
}
|
||||
}
|
||||
if removed { row.present = false }
|
||||
else if let flags { row.flags = flags }
|
||||
try context.save()
|
||||
} catch { context.rollback(); throw error }
|
||||
}
|
||||
|
||||
func messages(folderID: String) throws -> [MailMessage] {
|
||||
try rows(folderID: folderID).filter(\.present).map { row in
|
||||
var mail = MIME.parse(try Data(contentsOf: objectURL(row.objectHash)), id: row.key)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ struct MailAttachment: Identifiable, Sendable {
|
||||
let name: String
|
||||
let mimeType: String
|
||||
let data: Data
|
||||
var contentID: String? = nil
|
||||
}
|
||||
|
||||
struct MailMessage: Identifiable, Sendable {
|
||||
@@ -58,6 +59,8 @@ struct MailMessage: Identifiable, Sendable {
|
||||
var isRead = false
|
||||
var cc = ""
|
||||
var replyTo = ""
|
||||
var htmlBody: String? = nil
|
||||
var displaySender: String { MailDisplay.sender(sender) }
|
||||
var attachmentCount: Int { attachments.count }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,24 @@ enum MailDeleteConfirmation: String {
|
||||
}
|
||||
}
|
||||
|
||||
struct AutoReadRequest: Equatable {
|
||||
let ids: Set<String>
|
||||
let immediately: Bool
|
||||
let seconds: Int
|
||||
|
||||
func wait() async throws {
|
||||
if !immediately { try await Task.sleep(for: .seconds(max(1, seconds))) }
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
}
|
||||
|
||||
struct MailSettings: View {
|
||||
@AppStorage("markReadImmediately") private var markReadImmediately = false
|
||||
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
|
||||
@AppStorage("bodyFontName") private var bodyFontName = "Helvetica"
|
||||
@AppStorage("bodyFontSize") private var bodyFontSize = 14.0
|
||||
@AppStorage("blockRemoteContent") private var blockRemoteContent = true
|
||||
private let fontNames = NSFontManager.shared.availableFontFamilies.sorted()
|
||||
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
|
||||
@AppStorage("idleEnabled") private var idleEnabled = true
|
||||
@AppStorage("pollMinutes") private var pollMinutes = 5
|
||||
@@ -24,6 +41,15 @@ struct MailSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Picker("Schriftart für Mailtexte", selection: $bodyFontName) {
|
||||
ForEach(fontNames, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Stepper("Schriftgröße: \(Int(bodyFontSize)) pt", value: $bodyFontSize, in: 8...48)
|
||||
Text("Vorschau Mailtext").font(.custom(bodyFontName, size: bodyFontSize))
|
||||
Toggle("Externe Inhalte in HTML-Mails blockieren", isOn: $blockRemoteContent)
|
||||
Text("Gilt beim Öffnen einer Mail. Inhalte können für eine einzelne Mail freigegeben werden. Eigene HTML-Schriftformate bleiben erhalten.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Divider()
|
||||
Toggle("Sofortempfang mit IMAP IDLE", isOn: $idleEnabled)
|
||||
Text("Neue Mails werden bei laufender App sofort gemeldet. Das Prüfintervall dient zusätzlich als Absicherung.").font(.caption).foregroundStyle(.secondary)
|
||||
Picker("Auf neue Mails prüfen", selection: $pollMinutes) {
|
||||
@@ -34,6 +60,17 @@ struct MailSettings: View {
|
||||
Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch)
|
||||
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
|
||||
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
|
||||
Picker("Als gelesen markieren", selection: $markReadImmediately) {
|
||||
Text("Sofort bei Auswahl").tag(true)
|
||||
Text("Nach einer Wartezeit").tag(false)
|
||||
}
|
||||
if !markReadImmediately {
|
||||
Stepper("Nach \(markReadDelaySeconds) Sekunden", value: $markReadDelaySeconds, in: 1...3600)
|
||||
TextField("Wartezeit in Sekunden", value: $markReadDelaySeconds, format: .number)
|
||||
.onChange(of: markReadDelaySeconds) { _, value in
|
||||
markReadDelaySeconds = min(3600, max(1, value))
|
||||
}
|
||||
}
|
||||
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
|
||||
Text("Immer").tag(MailDeleteConfirmation.always)
|
||||
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
|
||||
|
||||
+80
-19
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor @Observable
|
||||
final class MailStore {
|
||||
@@ -130,7 +131,7 @@ final class MailStore {
|
||||
items.flatMap { [$0] + allFolders($0.children ?? []) }
|
||||
}
|
||||
|
||||
func refresh(folderID: String?, accountID: UUID? = nil) async {
|
||||
func refresh(folderID: String?, accountID: UUID? = nil, inboxOnly: Bool = false) async {
|
||||
guard !isBusy else { return }
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
@@ -143,8 +144,8 @@ final class MailStore {
|
||||
defer { client.close() }
|
||||
status = "\(account.email): Verbindung wird aufgebaut …"
|
||||
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
||||
var discovered = try await client.folders(accountID: account.id)
|
||||
let pending = try archive.pendingSent(accountID: account.id)
|
||||
var discovered = inboxOnly ? (folders[account.id] ?? []) : try await client.folders(accountID: account.id)
|
||||
let pending = inboxOnly ? [] : try archive.pendingSent(accountID: account.id)
|
||||
if !pending.isEmpty {
|
||||
var sent = allFolders(discovered).first { $0.specialUse?.contains("\\sent") == true }
|
||||
?? allFolders(discovered).first { ["sent", "gesendet", "sent messages", "sent items"].contains($0.name.lowercased()) }
|
||||
@@ -175,7 +176,7 @@ final class MailStore {
|
||||
try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:]))
|
||||
}
|
||||
folders = updated
|
||||
for folder in allFolders(discovered) where folder.selectable {
|
||||
for folder in allFolders(discovered) where folder.selectable && (!inboxOnly || folder.isInbox) {
|
||||
do {
|
||||
status = "\(folder.name): Änderungen prüfen …"
|
||||
let inventory = try await client.inventory(folder: folder)
|
||||
@@ -229,7 +230,7 @@ final class MailStore {
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
func send(accountID: UUID?, recipients: String, cc: String = "", bcc: String = "", subject: String, body: String, attachments: [MailAttachment]) async throws {
|
||||
func send(accountID: UUID?, recipients: String, cc: String = "", bcc: String = "", subject: String, body: String, attachments: [MailAttachment], htmlBody: String? = nil) async throws {
|
||||
guard let account = accounts.first(where: { $0.id == accountID }) else {
|
||||
throw MailError.message("Bitte ein Absenderkonto auswählen.")
|
||||
}
|
||||
@@ -243,7 +244,7 @@ final class MailStore {
|
||||
let copies = Self.addresses(cc)
|
||||
let blindCopies = Self.addresses(bcc)
|
||||
let envelope = Array(Set(addresses + copies + blindCopies)).sorted()
|
||||
let data = try MIME.compose(from: account.email, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments)
|
||||
let data = try MIME.compose(from: account.email, to: addresses, cc: copies, bcc: blindCopies, subject: subject, body: body, attachments: attachments, htmlBody: htmlBody)
|
||||
let localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien")
|
||||
let localID = UUID().uuidString
|
||||
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message.
|
||||
@@ -274,8 +275,24 @@ final class MailStore {
|
||||
}
|
||||
}
|
||||
|
||||
var composeDrafts: [UUID: ComposeDraft] = [:]
|
||||
|
||||
func prepareCompose(_ draft: ComposeDraft) -> UUID {
|
||||
composeDrafts[draft.id] = draft
|
||||
return draft.id
|
||||
}
|
||||
|
||||
func moveAccounts(from offsets: IndexSet, to destination: Int) {
|
||||
var updated = accounts
|
||||
updated.move(fromOffsets: offsets, toOffset: destination)
|
||||
do {
|
||||
try storage().saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory))
|
||||
accounts = updated
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
func reorderAccount(_ id: UUID, before target: UUID) {
|
||||
guard !isBusy, id != target,
|
||||
guard id != target,
|
||||
let source = accounts.first(where: { $0.id == id }) else { return }
|
||||
var updated = accounts.filter { $0.id != id }
|
||||
guard let index = updated.firstIndex(where: { $0.id == target }) else { return }
|
||||
@@ -299,20 +316,26 @@ final class MailStore {
|
||||
} catch { client.close(); throw error }
|
||||
}
|
||||
|
||||
/// Serializes user mutations with synchronization. Refresh also reconciles partial successes.
|
||||
private func mutate(_ operation: () async throws -> Void) async {
|
||||
/// Message mutations commit their confirmed changes locally; folder operations rediscover the tree.
|
||||
private func mutate(refreshAfter: Bool = true, _ operation: () async throws -> Void) async {
|
||||
while isBusy { if Task.isCancelled { return }; try? await Task.sleep(for: .milliseconds(150)) }
|
||||
guard !Task.isCancelled else { return }
|
||||
isBusy = true
|
||||
var failure: String?
|
||||
do { try await operation() }
|
||||
catch { failure = error.localizedDescription }
|
||||
isBusy = false
|
||||
await refresh(folderID: selectedFolderID)
|
||||
if refreshAfter { await refresh(folderID: selectedFolderID) }
|
||||
else {
|
||||
do { try updateLocalPresentation() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
status = failure == nil ? "Änderung gespeichert." : "Änderung nicht vollständig ausgeführt."
|
||||
}
|
||||
if let failure { error = failure }
|
||||
}
|
||||
|
||||
func moveMessages(ids: [String], to destination: MailFolder) async {
|
||||
await mutate {
|
||||
await mutate(refreshAfter: false) {
|
||||
guard destination.selectable else { throw MailError.message("Bitte einen beschreibbaren Zielordner wählen.") }
|
||||
for id in Set(ids) {
|
||||
try await self.moveOne(id: id, to: destination)
|
||||
@@ -328,13 +351,16 @@ final class MailStore {
|
||||
}
|
||||
let sourceClient = try await client(for: source.accountID)
|
||||
defer { sourceClient.close() }
|
||||
let inventory = try await sourceClient.inventory(folder: source, writable: true)
|
||||
let inventory = try await sourceClient.messageInventory(folder: source, uid: uid)
|
||||
guard inventory.validity == record.validity, inventory.flags[uid] != nil else {
|
||||
throw MailError.message("Der Serverbestand hat sich verändert. Bitte erneut abrufen.")
|
||||
}
|
||||
let capabilities = try await sourceClient.capabilities()
|
||||
if source.accountID == destination.accountID, capabilities.contains("MOVE") {
|
||||
try await sourceClient.move(uid: uid, to: destination.path)
|
||||
let assigned = try await sourceClient.move(uid: uid, to: destination.path)
|
||||
try storage().applyMessageChange(id: id, destination: destination.id, assigned: assigned, flags: inventory.flags[uid])
|
||||
try updateLocalPresentation()
|
||||
if assigned == nil { try await reconcileDestination(destination) }
|
||||
return
|
||||
}
|
||||
guard capabilities.contains("UIDPLUS") else {
|
||||
@@ -343,25 +369,56 @@ final class MailStore {
|
||||
let raw = try await sourceClient.rawMessage(uid: uid)
|
||||
let targetClient = try await client(for: destination.accountID)
|
||||
defer { targetClient.close() }
|
||||
try await targetClient.append(raw, path: destination.path, flags: inventory.flags[uid] ?? [])
|
||||
let assigned = try await targetClient.append(raw, path: destination.path, flags: inventory.flags[uid] ?? [])
|
||||
// A positive tagged APPEND completion precedes any source deletion.
|
||||
// Record the confirmed copy even if removing the source later fails.
|
||||
if let assigned {
|
||||
try storage().store(raw: raw, folderID: destination.id, validity: assigned.validity,
|
||||
uid: assigned.uid, flags: inventory.flags[uid] ?? [])
|
||||
} else { try await reconcileDestination(destination) }
|
||||
do { try await sourceClient.remove(uids: [uid]) }
|
||||
catch {
|
||||
throw MailError.message("Die Nachricht wurde ins Ziel kopiert, konnte aber nicht aus der Quelle entfernt werden. Beide Kopien bleiben erhalten. \(error.localizedDescription)")
|
||||
}
|
||||
try storage().applyMessageChange(id: id)
|
||||
try updateLocalPresentation()
|
||||
}
|
||||
|
||||
private func updateLocalPresentation() throws {
|
||||
unreadCounts = try storage().unreadCounts()
|
||||
archiveRevision += 1
|
||||
if let selectedFolderID { try display(folderID: selectedFolderID) }
|
||||
}
|
||||
|
||||
/// Servers without COPYUID/APPENDUID need only the destination folder reconciled.
|
||||
private func reconcileDestination(_ folder: MailFolder) async throws {
|
||||
let archive = try storage()
|
||||
let client = try await client(for: folder.accountID)
|
||||
defer { client.close() }
|
||||
let inventory = try await client.inventory(folder: folder)
|
||||
let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity)
|
||||
for uid in Set(inventory.flags.keys).subtracting(known) {
|
||||
let raw = try await client.rawMessage(uid: uid)
|
||||
try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity,
|
||||
uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false)
|
||||
}
|
||||
try archive.reconcile(folderID: folder.id, inventory: inventory)
|
||||
try updateLocalPresentation()
|
||||
}
|
||||
|
||||
func deleteMessages(ids: [String]) async {
|
||||
await mutate {
|
||||
await mutate(refreshAfter: false) {
|
||||
for id in Set(ids) {
|
||||
let record = try self.storage().record(id: id)
|
||||
guard let source = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue }
|
||||
if source.isTrash {
|
||||
let client = try await self.client(for: source.accountID)
|
||||
defer { client.close() }
|
||||
let inventory = try await client.inventory(folder: source, writable: true)
|
||||
let inventory = try await client.messageInventory(folder: source, uid: uid)
|
||||
guard inventory.validity == record.validity else { throw MailError.message("Ordnerbestand geändert. Bitte erneut abrufen.") }
|
||||
try await client.remove(uids: inventory.flags[uid] == nil ? [] : [uid])
|
||||
try self.storage().applyMessageChange(id: id)
|
||||
try self.updateLocalPresentation()
|
||||
} else {
|
||||
guard let trash = self.allFolders(self.folders[source.accountID] ?? []).first(where: \.isTrash) else {
|
||||
throw MailError.message("Für dieses Konto wurde kein Papierkorb gefunden. Bitte zuerst einen Ordner „Trash“ erstellen.")
|
||||
@@ -456,17 +513,21 @@ final class MailStore {
|
||||
}
|
||||
|
||||
func setRead(ids: [String], read: Bool) async {
|
||||
await mutate {
|
||||
await mutate(refreshAfter: false) {
|
||||
for id in Set(ids) {
|
||||
let record = try self.storage().record(id: id)
|
||||
guard let folder = self.folder(id: record.folderID), let uid = UInt64(record.uid) else { continue }
|
||||
let client = try await self.client(for: folder.accountID)
|
||||
defer { client.close() }
|
||||
let inventory = try await client.inventory(folder: folder, writable: true)
|
||||
let inventory = try await client.messageInventory(folder: folder, uid: uid)
|
||||
guard inventory.validity == record.validity, inventory.flags[uid] != nil else {
|
||||
throw MailError.message("Nachricht wurde auf dem Server verändert. Bitte erneut abrufen.")
|
||||
}
|
||||
try await client.setSeen(uid: uid, read: read)
|
||||
var flags = (inventory.flags[uid] ?? []).filter { $0.lowercased() != "\\seen" }
|
||||
if read { flags.append("\\Seen") }
|
||||
try self.storage().applyMessageChange(id: id, flags: flags, removed: false)
|
||||
try self.updateLocalPresentation()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,7 +578,7 @@ final class MailStore {
|
||||
guard let id = self.pendingIdleAccounts.first else { return }
|
||||
self.pendingIdleAccounts.remove(id)
|
||||
guard self.accounts.contains(where: { $0.id == id }) else { continue }
|
||||
await self.refresh(folderID: self.selectedFolderID, accountID: id)
|
||||
await self.refresh(folderID: self.selectedFolderID, accountID: id, inboxOnly: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ struct MessageActionButtons: View {
|
||||
struct MessageDetailWindow: View {
|
||||
let store: MailStore
|
||||
let messageID: String?
|
||||
@State private var draft: ComposeDraft?
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var confirmingDelete = false
|
||||
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
|
||||
@AppStorage("markReadImmediately") private var markReadImmediately = false
|
||||
@AppStorage("markReadDelaySeconds") private var markReadDelaySeconds = 1
|
||||
|
||||
private var message: MailMessage? {
|
||||
_ = store.archiveRevision
|
||||
@@ -57,15 +59,15 @@ struct MessageDetailWindow: View {
|
||||
guard let message else { return }
|
||||
switch command {
|
||||
case .reply, .forward:
|
||||
draft = .from(message, accountID: store.accountID(for: message.id), forwarding: command == .forward)
|
||||
let draft = ComposeDraft.from(message, accountID: store.accountID(for: message.id), forwarding: command == .forward)
|
||||
openWindow(id: "compose", value: store.prepareCompose(draft))
|
||||
case .delete:
|
||||
if deleteConfirmation.isRequired(count: 1) { confirmingDelete = true }
|
||||
else { Task { await store.deleteMessages(ids: [message.id]) } }
|
||||
default: break
|
||||
}
|
||||
}.disabled(message == nil || store.isBusy)
|
||||
}.disabled(message == nil)
|
||||
}
|
||||
.sheet(item: $draft) { ComposeView(store: store, draft: $0) }
|
||||
.alert("Nachricht löschen?", isPresented: $confirmingDelete) {
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
Button("Löschen", role: .destructive) {
|
||||
@@ -76,8 +78,12 @@ struct MessageDetailWindow: View {
|
||||
? "Die Nachricht wird auch auf dem Server endgültig gelöscht."
|
||||
: "Die Nachricht wird auch auf dem Server in den Papierkorb verschoben.")
|
||||
}
|
||||
.task(id: messageID) {
|
||||
if let message, !message.isRead { await store.setRead(ids: [message.id], read: true) }
|
||||
.task(id: AutoReadRequest(ids: Set([messageID].compactMap { $0 }), immediately: markReadImmediately, seconds: markReadDelaySeconds)) {
|
||||
guard let message, !message.isRead else { return }
|
||||
do {
|
||||
try await AutoReadRequest(ids: [message.id], immediately: markReadImmediately, seconds: markReadDelaySeconds).wait()
|
||||
await store.setRead(ids: [message.id], read: true)
|
||||
} catch { /* The message window closed before the read delay elapsed. */ }
|
||||
}
|
||||
.alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
|
||||
Button("OK") { store.error = nil }
|
||||
|
||||
@@ -17,6 +17,13 @@ struct MikeMailApp: App {
|
||||
WindowGroup("Nachricht", id: "message", for: String.self) { $id in
|
||||
MessageDetailWindow(store: store, messageID: id)
|
||||
}.defaultSize(width: 800, height: 650)
|
||||
.windowResizability(.contentMinSize)
|
||||
WindowGroup("Neue Nachricht", id: "compose", for: UUID.self) { $id in
|
||||
ComposeView(store: store, draft: id.flatMap { store.composeDrafts[$0] })
|
||||
.onDisappear { if let id { store.composeDrafts.removeValue(forKey: id) } }
|
||||
}
|
||||
.defaultSize(width: 760, height: 780)
|
||||
.windowResizability(.contentMinSize)
|
||||
Settings { MailSettings() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ extension MailStore {
|
||||
let archive = try MailArchive(root: root)
|
||||
let account = MailAccount(name: "UI-Prüfung", email: "fixture@example.com", username: "fixture", host: "example.invalid")
|
||||
let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang")
|
||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [inbox]], recipients: []))
|
||||
let second = MailAccount(name: "Zweites Testkonto", email: "second@example.com", username: "second", host: "example.invalid")
|
||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: [account, second], folders: [account.id: [inbox], second.id: []], recipients: []))
|
||||
for index in 1...2 {
|
||||
let raw = try MIME.compose(from: "sender@example.com", to: [account.email],
|
||||
subject: "UI-Nachricht \(index)", body: "Nachrichtentext für die Fensterprüfung.",
|
||||
attachments: [MailAttachment(name: "Beispiel.txt", mimeType: "text/plain", data: Data("Anhang".utf8))])
|
||||
try archive.store(raw: raw, folderID: inbox.id, validity: "1", uid: "\(index)", flags: ["\\Seen"])
|
||||
}
|
||||
return MailStore(archive: archive)
|
||||
let store = MailStore(archive: archive)
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-busy") { store.isBusy = true }
|
||||
return store
|
||||
} catch { fatalError("UI fixture: \(error)") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,51 @@ import Testing
|
||||
|
||||
@MainActor
|
||||
struct MailArchiveTests {
|
||||
@Test func confirmedMessageChangesPreserveOtherMailAndBackup() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let archive = try MailArchive(root: root.appending(path: "Archive"))
|
||||
let account = MailAccount(name: "Fixture", email: "fixture@example.com", username: "fixture", host: "example.invalid")
|
||||
let inbox = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang")
|
||||
let trash = MailFolder(accountID: account.id, path: "Trash", name: "Papierkorb")
|
||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [inbox, trash]], recipients: []))
|
||||
let raw = Data("Subject: Move\r\n\r\nBody".utf8)
|
||||
for uid in ["1", "2"] { try archive.store(raw: raw, folderID: inbox.id, validity: "10", uid: uid, flags: []) }
|
||||
let id = inbox.id + ":10:1"
|
||||
try archive.applyMessageChange(id: id, flags: ["\\Seen"], removed: false)
|
||||
#expect(try archive.message(id: id).isRead)
|
||||
try archive.applyMessageChange(id: id, destination: trash.id, assigned: .init(validity: "20", uid: "9"), flags: ["\\Seen"])
|
||||
#expect(try archive.messages(folderID: inbox.id).map(\.id) == [inbox.id + ":10:2"])
|
||||
#expect(try archive.message(id: trash.id + ":20:9").body == "Body")
|
||||
#expect(try archive.unreadCounts()[inbox.id] == 1)
|
||||
try archive.applyMessageChange(id: trash.id + ":20:9")
|
||||
#expect(try archive.messages(folderID: trash.id).isEmpty)
|
||||
let backup = root.appending(path: "Backup")
|
||||
try archive.exportSnapshot(to: backup)
|
||||
let restored = try MailArchive.restoreSnapshot(from: backup, to: root.appending(path: "Restored"))
|
||||
#expect(try restored.messages(folderID: trash.id).isEmpty)
|
||||
#expect(try restored.messages(folderID: inbox.id).count == 1)
|
||||
}
|
||||
|
||||
@Test func singleMessageServerUIDMapping() {
|
||||
#expect(IMAPClient.assignedUID(["M1 OK [COPYUID 123 5 17] Done"], sourceUID: 5) == .init(validity: "123", uid: "17"))
|
||||
#expect(IMAPClient.assignedUID(["* OK [COPYUID 123 5 17] Done"], sourceUID: 6) == nil)
|
||||
#expect(IMAPClient.assignedUID(["M2 OK [APPENDUID 123 18] Done"]) == .init(validity: "123", uid: "18"))
|
||||
#expect(IMAPClient.assignedUID(["M2 OK Done"]) == nil)
|
||||
#expect(IMAPClient.assignedUID(["M2 OK [APPENDUID 0 18] Done"]) == nil)
|
||||
#expect(IMAPClient.assignedUID(["M2 OK [COPYUID 123 5:6 17:18] Done"], sourceUID: 5) == nil)
|
||||
}
|
||||
|
||||
@Test func automaticReadDelayIsCancellable() async throws {
|
||||
try await AutoReadRequest(ids: ["mail"], immediately: true, seconds: 3600).wait()
|
||||
let delayed = Task { try await AutoReadRequest(ids: ["mail"], immediately: false, seconds: 3600).wait() }
|
||||
delayed.cancel()
|
||||
do {
|
||||
try await delayed.value
|
||||
Issue.record("Cancelled selection must not be marked as read")
|
||||
} catch is CancellationError { }
|
||||
}
|
||||
|
||||
@Test func diskPersistenceIncrementalReconciliationAndRestore() throws {
|
||||
let base = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ final class MikeMailUITests: XCTestCase {
|
||||
app.menuItems["New"].hover()
|
||||
app.menuItems["New Window"].click()
|
||||
}
|
||||
let table = app.outlines.element(boundBy: 1)
|
||||
let table = app.outlines.firstMatch
|
||||
XCTAssertTrue(table.waitForExistence(timeout: 5), app.debugDescription)
|
||||
let row = table.descendants(matching: .outlineRow).firstMatch
|
||||
XCTAssertTrue(row.waitForExistence(timeout: 5), app.debugDescription)
|
||||
@@ -62,6 +62,83 @@ final class MikeMailUITests: XCTestCase {
|
||||
XCTAssertGreaterThanOrEqual(app.windows.count, 2, app.debugDescription)
|
||||
reply.click()
|
||||
XCTAssertTrue(app.textFields["composeCC"].waitForExistence(timeout: 5))
|
||||
app.buttons["Abbrechen"].click()
|
||||
let compose = app.windows["Neue Nachricht"].firstMatch
|
||||
XCTAssertTrue(compose.waitForExistence(timeout: 5))
|
||||
XCTAssertGreaterThanOrEqual(app.windows.count, 3)
|
||||
let body = compose.textViews["composeBody"]
|
||||
XCTAssertTrue(body.waitForExistence(timeout: 5))
|
||||
app.typeText("Antwort am Anfang ")
|
||||
XCTAssertTrue((body.value as? String ?? "").hasPrefix("Antwort am Anfang "))
|
||||
let oldFrame = compose.frame
|
||||
resizeWindow(compose, app: app)
|
||||
XCTAssertNotEqual(compose.frame.size, oldFrame.size)
|
||||
compose.buttons["Abbrechen"].click()
|
||||
let oldDetail = detail.frame
|
||||
detail.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.01)).click()
|
||||
resizeWindow(detail, app: app)
|
||||
XCTAssertNotEqual(detail.frame.size, oldDetail.size)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDeleteConfirmation() {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "--ui-fixture", "-checkOnLaunch", "NO",
|
||||
"-idleEnabled", "NO", "-pollMinutes", "0", "-selectedFolder", "unified:inbox", "-mailSearch", "",
|
||||
"-mailDeleteConfirmation", "always", "--ui-busy"]
|
||||
app.launch()
|
||||
app.activate()
|
||||
if app.windows.count == 0 {
|
||||
app.menuBars.menuBarItems["File"].click()
|
||||
app.menuItems["New"].hover()
|
||||
app.menuItems["New Window"].click()
|
||||
}
|
||||
let table = app.outlines.firstMatch
|
||||
XCTAssertTrue(table.waitForExistence(timeout: 5))
|
||||
table.staticTexts["UI-Nachricht 1"].firstMatch.click()
|
||||
app.buttons["Löschen…"].firstMatch.click()
|
||||
XCTAssertTrue(app.buttons["Abbrechen"].waitForExistence(timeout: 3), app.debugDescription)
|
||||
if app.buttons["Abbrechen"].exists { app.buttons["Abbrechen"].click() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testAccountReorderingAndDisclosure() {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "--ui-fixture", "-checkOnLaunch", "NO",
|
||||
"-idleEnabled", "NO", "-pollMinutes", "0", "-selectedFolder", "unified:inbox", "-mailSearch", ""]
|
||||
app.launch()
|
||||
app.activate()
|
||||
if app.windows.count == 0 {
|
||||
app.menuBars.menuBarItems["File"].click()
|
||||
app.menuItems["New"].hover()
|
||||
app.menuItems["New Window"].click()
|
||||
}
|
||||
app.activate()
|
||||
let sidebar = app.tables["accountSidebar"]
|
||||
XCTAssertTrue(sidebar.waitForExistence(timeout: 5))
|
||||
let rows = sidebar.tableRows
|
||||
XCTAssertTrue(rows.element(boundBy: 4).waitForExistence(timeout: 5))
|
||||
let source = rows.element(boundBy: 4)
|
||||
let target = rows.element(boundBy: 2)
|
||||
let disclosure = target.buttons.firstMatch
|
||||
XCTAssertTrue(disclosure.exists)
|
||||
XCTAssertEqual(disclosure.frame.midY, target.frame.midY, accuracy: 2)
|
||||
source.staticTexts["Zweites Testkonto"].click(forDuration: 1, thenDragTo: target.staticTexts["UI-Prüfung"])
|
||||
XCTAssertTrue(rows.element(boundBy: 2).debugDescription.contains("second@example.com"), sidebar.debugDescription)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resizeWindow(_ window: XCUIElement, app: XCUIApplication) {
|
||||
let original = window.frame.size
|
||||
app.menuBars.menuBarItems["Window"].click()
|
||||
app.menuItems["Fill"].click()
|
||||
if window.frame.size == original {
|
||||
app.menuBars.menuBarItems["Window"].click()
|
||||
app.menuItems["Move & Resize"].hover()
|
||||
app.menuItems["Left"].click()
|
||||
}
|
||||
let changed = NSPredicate { _, _ in window.frame.size != original }
|
||||
expectation(for: changed, evaluatedWith: window)
|
||||
waitForExpectations(timeout: 3)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user