Alpha 2 Update

This commit is contained in:
Dr. Michael Kübler
2026-11-07 12:24:11 +01:00
committed by Dr. Michael Kübler
parent 223bf746ef
commit 8e95cc3acd
22 changed files with 1935 additions and 144 deletions
+82 -3
View File
@@ -9,6 +9,15 @@ struct AccountsView: View {
@State private var newAccount: MailAccount?
@State private var error: String?
@State private var confirmingDelete = false
@State private var resetAccount: MailAccount?
@State private var confirmingReset = false
@State private var resetResult: String?
@State private var testing = false
@State private var testedAccount: MailAccount?
@State private var testedPassword = ""
@State private var testMailAccount: MailAccount?
@State private var askingTestMail = false
@AppStorage("offerTestMail") private var offerTestMail = true
var body: some View {
VStack(spacing: 0) {
@@ -43,12 +52,26 @@ struct AccountsView: View {
ContentUnavailableView("Kein Konto ausgewählt", systemImage: "person.crop.circle",
description: Text("Füge ein Konto hinzu oder wähle links ein Konto aus."))
}
if let saved = store.accounts.first(where: { $0.id == selection }) {
Button("Lokale Mails löschen …", systemImage: "trash", role: .destructive) {
resetAccount = saved
confirmingReset = true
}
.disabled(store.isBusy || store.sendingAccountIDs.contains(saved.id))
.accessibilityIdentifier("clearLocalMessages")
}
if let resetResult { Text(resetResult).font(.caption).foregroundStyle(.secondary) }
if let error { Text(error).foregroundStyle(.red) }
Spacer()
HStack {
Spacer()
if testing { ProgressView().controlSize(.small) }
Button("Verbindung testen") { testConnection() }
.disabled(selection == nil || testing)
Button("Speichern") { save() }
.disabled(selection == nil)
.disabled(selection == nil || testing || store.isBusy ||
(newAccount?.id == selection && (testedAccount != selection.flatMap { drafts[$0] } ||
testedPassword != (selection.flatMap { passwords[$0] } ?? ""))))
.keyboardShortcut(.defaultAction)
}
}
@@ -72,12 +95,43 @@ struct AccountsView: View {
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
}.padding(16)
}
.frame(width: 850, height: 480)
.frame(width: 850, height: 520)
.interactiveDismissDisabled(testing)
.disabled(testing)
.alert("Testmail senden?", isPresented: $askingTestMail) {
Button("Nein", role: .cancel) {}
Button("Testmail senden") {
if let account = testMailAccount {
Task {
do {
try await store.send(accountID: account.id, recipients: account.email,
subject: "MikeMail – Testnachricht", body: "Versand und Empfang mit MikeMail sind eingerichtet.", attachments: [])
} catch { self.error = error.localizedDescription }
}
}
}
} message: { Text("Eine Testnachricht an \(testMailAccount?.email ?? "") senden?") }
.onAppear {
drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) })
selection = store.accounts.first?.id
}
.onChange(of: selection) { _, _ in error = nil }
.onChange(of: selection) { _, _ in error = nil; resetResult = nil }
.confirmationDialog("Alle lokalen Mails dieses Kontos löschen?", isPresented: $confirmingReset, titleVisibility: .visible) {
Button("Lokale Mails endgültig löschen", role: .destructive) {
guard let account = resetAccount else { return }
do {
let failures = try store.clearLocalMessages(accountID: account.id)
error = nil
resetResult = failures == 0
? "Lokale Mails gelöscht. „Abrufen“ lädt die vorhandenen Servermails vollständig neu."
: "Lokaler Bestand zurückgesetzt. \(failures) nicht mehr verwendete Dateien konnten nicht entfernt werden. Beim nächsten Abrufen werden trotzdem alle Servermails neu geladen."
} catch { self.error = error.localizedDescription }
resetAccount = nil
}
Button("Abbrechen", role: .cancel) { resetAccount = nil }
} message: {
Text("Konto: \(resetAccount?.email ?? "")\n\nAlle lokal gespeicherten Mails und Anhänge dieses Kontos werden entfernt, auch archivierte Nachrichten und lokale Versandkopien. Nur lokal vorhandene Mails können nicht vom Server wiederhergestellt werden.\n\nMails auf dem Server, Kontoeinstellungen und Passwort bleiben unverändert. Beim nächsten „Abrufen“ werden alle vorhandenen Servermails neu heruntergeladen.")
}
.confirmationDialog("Konto aus MikeMail entfernen?", isPresented: $confirmingDelete) {
Button("Konto löschen", role: .destructive) { delete() }
Button("Abbrechen", role: .cancel) {}
@@ -89,13 +143,38 @@ struct AccountsView: View {
private func save() {
guard let id = selection, let account = drafts[id] else { return }
do {
let isNew = !store.accounts.contains { $0.id == id }
try store.save(account: account, password: passwords[id] ?? "")
passwords[id] = nil
if newAccount?.id == id { newAccount = nil }
if isNew {
Task {
while store.isBusy { try? await Task.sleep(for: .milliseconds(150)) }
await store.refresh(folderID: nil, accountID: id)
if offerTestMail { testMailAccount = account; askingTestMail = true }
}
}
error = nil
} catch { self.error = error.localizedDescription }
}
private func testConnection() {
guard let id = selection, let account = drafts[id] else { return }
let password = passwords[id] ?? ""
testing = true
error = nil
testedAccount = nil
Task {
defer { testing = false }
do {
try await store.testConnection(account: account, password: password)
testedAccount = account
testedPassword = password
resetResult = "IMAP- und SMTP-Verbindung erfolgreich geprüft."
} catch { self.error = error.localizedDescription }
}
}
private func delete() {
guard let id = selection else { return }
do {
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
struct AttachmentList: View {
let attachments: [MailAttachment]
@State private var error: String?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(attachments.count == 1 ? "Ein Anhang" : "\(attachments.count) Anhänge").font(.headline)
ForEach(attachments) { attachment in
HStack(spacing: 10) {
Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension:
(attachment.name as NSString).pathExtension) ?? .data))
.resizable().frame(width: 32, height: 32)
VStack(alignment: .leading) {
Text(attachment.name).lineLimit(2)
Text(attachment.data.count.formatted(.byteCount(style: .file)))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
.padding(8).background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 8))
.contentShape(Rectangle())
.onTapGesture(count: 2) { open(attachment) }
.accessibilityAddTraits(.isButton)
.accessibilityAction(named: "Öffnen") { open(attachment) }
.contextMenu {
Button("Öffnen") { open(attachment) }
Button("Datei speichern unter…") { Task { await save(attachment) } }
if attachments.count > 1 {
Button("Alle Anhänge speichern unter…") { Task { await saveAll() } }
}
}
.help("Doppelklick zum Öffnen")
}
if attachments.count > 1 {
Button("Alle Anhänge speichern unter…") { Task { await saveAll() } }
}
}
.alert("Anhang", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { error = nil }
} message: { Text(error ?? "") }
}
static func safeName(_ name: String) -> String {
let value = (name.replacingOccurrences(of: "\\", with: "/") as NSString).lastPathComponent
return value.isEmpty || value == "." || value == ".." ? "Anhang" : value
}
private func open(_ attachment: MailAttachment) {
do {
let directory = FileManager.default.temporaryDirectory.appending(path: "MikeMailAttachments/\(UUID())")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appending(path: Self.safeName(attachment.name))
try attachment.data.write(to: url, options: .atomic)
guard NSWorkspace.shared.open(url) else { throw MailError.message("Für diese Datei konnte kein Programm geöffnet werden.") }
} catch { self.error = error.localizedDescription }
}
private func save(_ attachment: MailAttachment) async {
let panel = NSSavePanel()
panel.nameFieldStringValue = Self.safeName(attachment.name)
guard await panel.begin() == .OK, let url = panel.url else { return }
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
do { try attachment.data.write(to: url, options: .atomic) }
catch { self.error = error.localizedDescription }
}
private func saveAll() async {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.prompt = "Anhänge speichern"
guard await panel.begin() == .OK, let directory = panel.url else { return }
let access = directory.startAccessingSecurityScopedResource()
defer { if access { directory.stopAccessingSecurityScopedResource() } }
do {
for attachment in attachments {
let name = Self.safeName(attachment.name)
let base = (name as NSString).deletingPathExtension
let ext = (name as NSString).pathExtension
var url = directory.appending(path: name)
var suffix = 2
while FileManager.default.fileExists(atPath: url.path) {
url = directory.appending(path: "\(base) (\(suffix))" + (ext.isEmpty ? "" : "." + ext))
suffix += 1
}
try attachment.data.write(to: url, options: .withoutOverwriting)
}
} catch { self.error = error.localizedDescription }
}
}
+509 -109
View File
@@ -1,144 +1,507 @@
import SwiftUI
import AppKit
import UniformTypeIdentifiers
struct ContentView: View {
let store: MailStore
@AppStorage("selectedFolder") private var savedFolder = ""
@AppStorage("selectedMessage") private var savedMessage = ""
@AppStorage("selectedMessages") private var savedMessages = Data()
@AppStorage("sidebarVisible") private var sidebarVisible = true
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@AppStorage("mailSearch") private var search = ""
@AppStorage("searchSubject") private var searchSubject = true
@AppStorage("searchSender") private var searchSender = true
@AppStorage("searchBody") private var searchBody = false
@AppStorage("searchRegex") private var searchRegex = false
@State private var selectedFolder: String?
@State private var selectedMessage: String?
@State private var selectedMessages: Set<String> = []
@State private var showingAccounts = false
@State private var composing = false
@State private var search = ""
@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
@State private var pendingDeletion: [String] = []
@State private var confirmingDelete = false
@State private var restored = false
private var composeAccountID: UUID? {
if let selectedFolder {
if let folder = store.folder(id: selectedFolder) { return folder.accountID }
if selectedFolder.hasPrefix("account:"),
let id = UUID(uuidString: String(selectedFolder.dropFirst("account:".count))),
store.accounts.contains(where: { $0.id == id }) { return id }
}
if selectedMessages.count == 1, let id = selectedMessages.first {
return store.accountID(for: id)
}
return store.accounts.first?.id
}
private var searchFilter: MailSearch {
MailSearch(text: search, subject: searchSubject, sender: searchSender, body: searchBody, regularExpression: searchRegex)
}
var body: some View {
NavigationSplitView {
AccountSidebar(accounts: store.accounts, folders: store.folders, selection: $selectedFolder)
NavigationSplitView(columnVisibility: $columnVisibility) {
AccountSidebar(store: store, selection: $selectedFolder)
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 380)
} detail: {
VSplitView {
MessageTable(messages: store.messages.filter {
search.isEmpty || $0.subject.localizedCaseInsensitiveContains(search) || $0.sender.localizedCaseInsensitiveContains(search)
}, selection: $selectedMessage)
.frame(minHeight: 160, idealHeight: 300)
MessageReader(message: store.messages.first { $0.id == selectedMessage })
.frame(minHeight: 220, maxHeight: .infinity)
VStack(spacing: 0) {
if !search.isEmpty {
HStack {
Text("Suchen in:")
Toggle("Betreff", isOn: $searchSubject)
Toggle("Absender", isOn: $searchSender)
Toggle("Inhalt", isOn: $searchBody)
Spacer()
Toggle("Regulärer Ausdruck", isOn: $searchRegex)
}.toggleStyle(.checkbox).padding(10)
if let error = searchFilter.error {
Text(error).foregroundStyle(.red).font(.caption).padding(.horizontal)
}
}
HStack {
if selectedMessages.count == 1 {
MessageActionButtons { perform($0, selectedMessages) }
}
Spacer()
}
.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() }
.frame(minHeight: 160, idealHeight: 300)
.background(WindowPersistence())
MessageReader(message: store.messages.first { selectedMessages.contains($0.id) })
.frame(minHeight: 220, maxHeight: .infinity)
}
}
.safeAreaInset(edge: .bottom) {
HStack {
if store.isBusy { ProgressView().controlSize(.small) }
if idleEnabled {
Image(systemName: "bolt.horizontal.circle")
.foregroundStyle(store.idleStatuses.values.contains("Sofortempfang aktiv") ? .green : .secondary)
.help(store.accounts.map { "\($0.email): \(store.idleStatuses[$0.id] ?? "Verbindung wird vorbereitet")" }.joined(separator: "\n"))
.accessibilityLabel("Sofortempfang")
}
Text(store.status).font(.caption).foregroundStyle(.secondary)
Spacer()
if store.messages.count < store.totalMessages {
Button("Weitere laden") {
Task { await store.load(folderID: selectedFolder, more: true) }
}.disabled(store.isBusy)
}
Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
}
.padding(8)
.background(.bar)
}.padding(8).background(.bar)
}
}
.navigationTitle("MikeMail")
.searchable(text: $search, prompt: "Absender oder Betreff suchen")
.searchable(text: $search, prompt: "Nachrichten durchsuchen")
.toolbar {
ToolbarItemGroup {
Button("Abrufen", systemImage: "arrow.clockwise") {
Menu {
Button("Alle Konten") { Task { await store.refresh(folderID: selectedFolder) } }
Divider()
ForEach(store.accounts) { account in
Button(account.name.isEmpty ? account.email : "\(account.name) – \(account.email)") {
Task { await store.refresh(folderID: selectedFolder, accountID: account.id) }
}
}
} label: {
Label("Mails abrufen", systemImage: "arrow.clockwise")
} primaryAction: {
Task { await store.refresh(folderID: selectedFolder) }
}
.accessibilityIdentifier("Abrufen")
.disabled(store.isBusy)
Button("Neue Nachricht", systemImage: "square.and.pencil") { composing = true }
.keyboardShortcut("n", modifiers: .command)
.disabled(store.accounts.isEmpty)
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
Button("Löschen", systemImage: "trash") { deleteSelection() }
.disabled(selectedMessages.isEmpty || store.isBusy)
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) }
.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
PasswordRecoveryView(store: store, account: account)
}
.task(id: IdleConfiguration(enabled: idleEnabled, accounts: store.accounts)) {
store.configureIdle(enabled: idleEnabled)
}
.task(id: pollMinutes) { await store.poll(intervalMinutes: pollMinutes) }
.task(id: selectedMessages) {
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) }
}
.alert(pendingDeletion.count == 1 ? "Nachricht löschen?" : "\(pendingDeletion.count) Nachrichten löschen?", isPresented: $confirmingDelete) {
Button("Abbrechen", role: .cancel) {}
Button("Löschen", role: .destructive) {
let ids = pendingDeletion
Task { await store.deleteMessages(ids: ids) }
}
} message: {
Text(store.selectionContainsTrash(pendingDeletion)
? "Ausgewählte Nachrichten im Papierkorb werden auch auf dem Server endgültig gelöscht. Andere ausgewählte Nachrichten werden in den Papierkorb verschoben."
: "Die ausgewählten Nachrichten werden auch auf dem Server in den Papierkorb verschoben.")
}
.alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
Button("OK") { store.error = nil }
} message: { Text(store.error ?? "") }
.onChange(of: selectedFolder) { _, folder in
selectedMessage = nil
savedFolder = folder ?? ""
selectedMessages = []
Task { await store.load(folderID: folder) }
}
.onChange(of: selectedMessages) { _, ids in
savedMessage = ids.sorted().first ?? ""
savedMessages = (try? JSONEncoder().encode(ids)) ?? Data()
}
.onChange(of: columnVisibility) { _, visibility in sidebarVisible = visibility != .detailOnly }
.task {
guard !restored else { return }
restored = true
let message = savedMessage
let selection = (try? JSONDecoder().decode(Set<String>.self, from: savedMessages)) ?? Set([message])
columnVisibility = sidebarVisible ? .all : .detailOnly
selectedFolder = savedFolder.isEmpty ? nil : savedFolder
await store.start(folderID: selectedFolder)
selectedMessages = selection.intersection(Set(store.messages.map(\.id)))
}
.frame(minWidth: 850, minHeight: 550)
}
private func perform(_ command: MessageCommand, _ ids: Set<String>) {
guard !ids.isEmpty else { return }
switch command {
case .open:
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)
}
case .delete:
requestDeletion(Array(ids))
case .read, .unread:
Task { await store.setRead(ids: Array(ids), read: command == .read) }
}
}
private func deleteSelection() {
guard !store.isBusy, !selectedMessages.isEmpty else { return }
requestDeletion(Array(selectedMessages))
}
private func requestDeletion(_ ids: [String]) {
guard !store.isBusy, !ids.isEmpty else { return }
if deleteConfirmation.isRequired(count: ids.count) {
pendingDeletion = ids
confirmingDelete = true
} else { Task { await store.deleteMessages(ids: ids) } }
}
}
struct MailSearch {
let text: String
let subject: Bool
let sender: Bool
let body: Bool
let regularExpression: Bool
private let expression: NSRegularExpression?
let error: String?
init(text: String, subject: Bool, sender: Bool, body: Bool, regularExpression: Bool) {
self.text = text; self.subject = subject; self.sender = sender
self.body = body; self.regularExpression = regularExpression
if regularExpression && !text.isEmpty {
do {
expression = try NSRegularExpression(pattern: text, options: [.caseInsensitive])
error = nil
} catch {
expression = nil
self.error = "Ungültiger regulärer Ausdruck: \(error.localizedDescription)"
}
} else { expression = nil; error = nil }
}
func matches(_ message: MailMessage) -> Bool {
guard !text.isEmpty else { return true }
let fields = [(subject, message.subject), (sender, message.sender), (body, message.body)]
.filter(\.0).map(\.1)
if regularExpression {
guard let expression else { return false }
return fields.contains { expression.firstMatch(in: $0, range: NSRange($0.startIndex..., in: $0)) != nil }
}
return fields.contains { $0.localizedCaseInsensitiveContains(text) }
}
}
struct AccountSidebar: View {
let accounts: [MailAccount]
let folders: [UUID: [MailFolder]]
let store: MailStore
@Binding var selection: String?
@AppStorage("showUnifiedInbox") private var showInbox = true
@AppStorage("showUnifiedTrash") private var showTrash = true
@State private var createAccount: UUID?
@State private var createParent: MailFolder?
@State private var newName = ""
@State private var creating = false
@State private var deleting: MailFolder?
@State private var confirmingFolder = false
@State private var trashID = ""
@State private var confirmingTrash = false
var body: some View {
List(selection: $selection) {
ForEach(accounts) { account in
DisclosureGroup {
OutlineGroup(folders[account.id] ?? [], children: \.children) { folder in
Label(folder.name, systemImage: folder.icon)
.tag(folder.id)
.selectionDisabled(!folder.selectable)
}
if folders[account.id] == nil {
Text("Zum Laden „Abrufen“ wählen").font(.caption).foregroundStyle(.secondary)
.selectionDisabled()
}
} label: {
VStack(alignment: .leading) {
Label(account.name.isEmpty ? account.email : account.name, systemImage: "person.crop.circle")
Text(account.email).font(.caption).foregroundStyle(.secondary)
}
}
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)
.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) {
TextField("Ordnername", text: $newName)
Button("Abbrechen", role: .cancel) {}
Button("Erstellen") {
if let account = createAccount {
let parent = createParent
let name = newName
Task { await store.createFolder(accountID: account, parent: parent, name: name) }
}
}
} message: { Text("Der Ordner wird auf dem Mailserver erstellt.") }
.alert("Ordner löschen?", isPresented: $confirmingFolder) {
Button("Abbrechen", role: .cancel) {}
Button("Ordner löschen", role: .destructive) {
if let folder = deleting { Task { await store.deleteFolder(folder) } }
}
} message: { Text("„\(deleting?.name ?? "")“ und alle Unterordner samt Nachrichten werden auf dem Server endgültig gelöscht.") }
.alert("Papierkorb leeren?", isPresented: $confirmingTrash) {
Button("Abbrechen", role: .cancel) {}
Button("Papierkorb leeren", role: .destructive) {
let id = trashID
Task { await store.emptyTrash(folderID: id) }
}
} message: { Text("Alle Nachrichten in diesem Papierkorb werden auch auf dem Server endgültig gelöscht. Beim gemeinsamen Papierkorb betrifft dies alle Konten.") }
}
private func beginCreate(_ account: UUID, parent: MailFolder?) {
createAccount = account; createParent = parent; newName = ""; creating = 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 {
let messages: [MailMessage]
@Binding var selection: String?
@Binding var selection: Set<String>
var busy = false
var action: (MessageCommand, Set<String>) -> Void = { _, _ in }
@AppStorage("mailSortColumn") private var savedColumn = "date"
@AppStorage("mailSortAscending") private var savedAscending = false
@State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)]
var body: some View {
Table(messages.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) {
TableColumn("Anhang") { message in
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder) {
TableColumn("Anhang", value: \.attachmentCount) { message in
if !message.attachments.isEmpty {
Image(systemName: "paperclip")
.accessibilityLabel("\(message.attachments.count) Anhänge")
Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
}
}.width(55)
TableColumn("Absender", value: \.sender).width(min: 110, ideal: 160)
TableColumn("Absender", value: \.sender) { message in
Text(message.sender).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()) }
else { Text(message.date, format: .dateTime.day().month().year().hour().minute()).fontWeight(message.isRead ? .regular : .bold) }
}.width(min: 120, ideal: 150)
TableColumn("Betreff", value: \.subject).width(min: 160, ideal: 280)
TableColumn("Betreff", value: \.subject) { message in
Text(message.subject).fontWeight(message.isRead ? .regular : .bold)
}.width(min: 160, ideal: 280)
} rows: {
ForEach(messages.sorted(using: sortOrder)) { message in
TableRow(message).draggable(MailDrag.encode(selection.contains(message.id) ? selection : [message.id]))
}
}
.contextMenu(forSelectionType: String.self) { ids in
if ids.count == 1 {
Button("In neuem Fenster öffnen") { action(.open, ids) }
MessageActionButtons { action($0, ids) }.disabled(busy)
} else if !ids.isEmpty {
Button("Nachrichten löschen…") { action(.delete, ids) }.disabled(busy)
}
if !ids.isEmpty {
Divider()
Button("Als gelesen markieren") { action(.read, ids) }.disabled(busy)
Button("Als ungelesen markieren") { action(.unread, ids) }.disabled(busy)
}
} primaryAction: { ids in action(.open, ids) }
.onAppear {
let order: SortOrder = savedAscending ? .forward : .reverse
switch savedColumn {
case "sender": sortOrder = [KeyPathComparator(\MailMessage.sender, 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)]
}
}
.onChange(of: sortOrder) { _, value in
guard let first = value.first else { return }
savedAscending = first.order == .forward
switch first.keyPath {
case \MailMessage.sender: savedColumn = "sender"
case \MailMessage.subject: savedColumn = "subject"
case \MailMessage.attachmentCount: savedColumn = "attachments"
default: savedColumn = "date"
}
}
.overlay {
if messages.isEmpty {
ContentUnavailableView("Keine Nachrichten", systemImage: "tray",
description: Text("Wähle einen Ordner und rufe deine E-Mails ab."))
description: Text("Wähle einen Ordner oder ändere die Suche."))
}
}
}
}
struct AttachmentDocument: FileDocument {
static var readableContentTypes: [UTType] { [.data] }
var data: Data
init(data: Data) { self.data = data }
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
enum MailDrag {
static func encode(_ ids: Set<String>) -> String {
let data = (try? JSONEncoder().encode(ids.sorted())) ?? Data()
return "mikemail-messages:" + data.base64EncodedString()
}
static func decode(_ value: String) -> [String] {
guard value.hasPrefix("mikemail-messages:"),
let data = Data(base64Encoded: String(value.dropFirst("mikemail-messages:".count))) else { return [] }
return (try? JSONDecoder().decode([String].self, from: data)) ?? []
}
}
struct MessageReader: View {
let message: MailMessage?
@State private var exportDocument: AttachmentDocument?
@State private var exportName = "Anhang"
@State private var exporting = false
@State private var error: String?
var body: some View {
ScrollView {
if let message {
@@ -147,52 +510,47 @@ struct MessageReader: View {
VStack(alignment: .leading, spacing: 5) {
Text("Von: \(message.sender)")
Text("An: \(message.recipient)").foregroundStyle(.secondary)
if !message.cc.isEmpty { Text("CC: \(message.cc)").foregroundStyle(.secondary) }
if message.date != .distantPast {
Text(message.date, format: .dateTime.day().month(.wide).year().hour().minute()).foregroundStyle(.secondary)
}
}.textSelection(.enabled)
if !message.attachments.isEmpty {
ForEach(message.attachments) { attachment in
Button {
exportDocument = AttachmentDocument(data: attachment.data)
exportName = (attachment.name as NSString).lastPathComponent
exporting = true
} label: {
Label("\(attachment.name) · \(attachment.data.count.formatted(.byteCount(style: .file)))", systemImage: "paperclip")
}
.help("Anhang speichern")
}
}
Divider()
Text(message.body).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
if !message.attachments.isEmpty {
AttachmentList(attachments: message.attachments)
.padding(16)
.background(Color.accentColor.opacity(0.06), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary))
.padding(.top, 16)
}
}.padding(24).frame(maxWidth: .infinity, alignment: .leading)
} else {
ContentUnavailableView("Nachricht auswählen", systemImage: "envelope.open",
description: Text("Der Inhalt und die Anhänge erscheinen hier."))
.frame(maxWidth: .infinity, minHeight: 220)
}
}
.fileExporter(isPresented: $exporting, document: exportDocument, contentType: .data, defaultFilename: exportName) { result in
if case .failure(let failure) = result { error = failure.localizedDescription }
}
.alert("Anhang speichern", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { error = nil }
} message: { Text(error ?? "") }
}
}
struct ComposeView: View {
let store: MailStore
var draft: ComposeDraft? = nil
var defaultAccountID: UUID? = nil
@Environment(\.dismiss) private var dismiss
@State private var accountID: UUID?
@State private var recipient = ""
@State private var cc = ""
@State private var bcc = ""
@State private var subject = ""
@State private var text = ""
@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 {
@@ -203,17 +561,35 @@ struct ComposeView: View {
ForEach(store.accounts) { account in Text(account.email).tag(Optional(account.id)) }
}
RecipientField(recipient: $recipient, history: store.recipientHistory)
TextField("CC", text: $cc).accessibilityIdentifier("composeCC")
TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC")
TextField("Betreff", text: $subject)
}
TextEditor(text: $text).font(.body).frame(minHeight: 230).border(.quaternary)
ForEach(attachments) { attachment in
HStack {
Label(attachment.name, systemImage: "paperclip")
Spacer()
Button("Entfernen", systemImage: "xmark.circle") { attachments.removeAll { $0.id == attachment.id } }
.labelStyle(.iconOnly)
}
TextEditor(text: $text).font(.body).frame(minHeight: 180).border(.quaternary)
.focused($bodyFocused)
VStack(alignment: .leading, spacing: 8) {
Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline)
ScrollView {
ForEach(attachments) { attachment in
HStack {
Label(attachment.name, systemImage: "doc")
Spacer()
Text(attachment.data.count.formatted(.byteCount(style: .file))).foregroundStyle(.secondary)
Button("Entfernen", systemImage: "xmark.circle") { attachments.removeAll { $0.id == attachment.id } }
.labelStyle(.iconOnly)
}
}
}.frame(height: attachments.isEmpty ? 16 : 80)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(dropTargeted ? 0.18 : 0.06), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(dropTargeted ? Color.accentColor : Color.secondary.opacity(0.3)))
.dropDestination(for: URL.self) { urls, _ in
guard !sending, urls.allSatisfy(\.isFileURL) else { return false }
addAttachments(urls)
return true
} isTargeted: { dropTargeted = $0 }
if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
HStack {
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
@@ -225,33 +601,57 @@ struct ComposeView: View {
error = nil
Task {
do {
try await store.send(accountID: accountID, recipients: recipient, subject: subject, body: text, attachments: attachments)
try await store.send(accountID: accountID, recipients: recipient, cc: cc, bcc: bcc,
subject: subject, body: text, attachments: attachments)
dismiss()
} catch { self.error = error.localizedDescription }
sending = false
}
}
.buttonStyle(.borderedProminent)
.disabled(recipient.trimmingCharacters(in: .whitespaces).isEmpty || accountID == nil)
.disabled(MailStore.addresses(recipient + "," + cc + "," + bcc).isEmpty || accountID == nil)
}
}
.padding(24).frame(width: 680, height: 620)
.padding(24).frame(width: 700, height: 740)
.disabled(sending)
.interactiveDismissDisabled(sending)
.onAppear { accountID = store.accounts.first?.id }
.fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in
do {
for url in try result.get() {
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
guard size + attachments.reduce(0, { $0 + $1.data.count }) <= 20_000_000 else {
throw MailError.message("Anhänge dürfen zusammen höchstens 20 MB groß sein.")
}
attachments.append(MailAttachment(name: url.lastPathComponent, mimeType: "application/octet-stream", data: try Data(contentsOf: url)))
}
} catch { self.error = error.localizedDescription }
.onAppear {
guard !initialized else { return }
initialized = true
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()) }
catch { self.error = error.localizedDescription }
}
}
private func addAttachments(_ urls: [URL]) {
do {
var added: [MailAttachment] = []
var total = attachments.reduce(0) { $0 + $1.data.count }
for url in urls {
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true else { throw MailError.message("Bitte einzelne Dateien auswählen.") }
guard (values.fileSize ?? 0) + total <= 20_000_000 else {
throw MailError.message("Anhänge dürfen zusammen höchstens 20 MB groß sein.")
}
let data = try Data(contentsOf: url)
total += data.count
guard total <= 20_000_000 else { throw MailError.message("Anhänge dürfen zusammen höchstens 20 MB groß sein.") }
added.append(MailAttachment(name: url.lastPathComponent,
mimeType: UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream", data: data))
}
attachments += added
error = nil
} catch { self.error = error.localizedDescription }
}
}
+137 -6
View File
@@ -10,6 +10,7 @@ final class IMAPClient {
}
func close() { connection.close() }
nonisolated func cancel() { connection.cancel() }
func login(account: MailAccount, password: String) async throws {
try await connection.open()
@@ -44,8 +45,10 @@ final class IMAPClient {
let line = try await connection.readLine()
if line.hasPrefix(tag + " ") {
guard line.uppercased().hasPrefix(tag + " OK") else {
if Self.isAuthenticationFailure(line) { throw MailError.authentication("Die Anmeldung wurde vom Mailserver abgelehnt.") }
throw MailError.message("IMAP: \(line)")
}
result.lines.append(line)
return result
}
if line.uppercased().hasPrefix("* BYE") { throw MailError.message("IMAP: Verbindung vom Server beendet.") }
@@ -60,7 +63,7 @@ final class IMAPClient {
func folders(accountID: UUID) async throws -> [MailFolder] {
let result = try await command("LIST \"\" \"*\"")
var entries: [(path: String, delimiter: String, selectable: Bool)] = []
var entries: [(path: String, delimiter: String, selectable: Bool, flags: [String])] = []
var literalIndex = 0
for line in result.lines where line.uppercased().hasPrefix("* LIST ") {
guard let close = line.firstIndex(of: ")") else { continue }
@@ -73,9 +76,9 @@ final class IMAPClient {
path = String(decoding: result.literals[literalIndex], as: UTF8.self)
literalIndex += 1
}
entries.append((path, tokens[0].uppercased() == "NIL" ? "" : tokens[0], !flags.contains("\\noselect")))
entries.append((path, tokens[0].uppercased() == "NIL" ? "" : tokens[0], !flags.contains("\\noselect"), flags.split(separator: " ").map { String($0).replacingOccurrences(of: "(", with: "") }.filter { $0.hasPrefix("\\") }))
}
func build(prefix: String?, entries: [(path: String, delimiter: String, selectable: Bool)]) -> [MailFolder] {
func build(prefix: String?, entries: [(path: String, delimiter: String, selectable: Bool, flags: [String])]) -> [MailFolder] {
var roots: [String: MailFolder] = [:]
for entry in entries {
let remainder: String
@@ -89,7 +92,7 @@ final class IMAPClient {
let exact = entries.first { $0.path == path }
roots[path] = MailFolder(accountID: accountID, path: path,
name: path.uppercased() == "INBOX" ? "Posteingang" : Self.decodeMailbox(component),
selectable: exact?.selectable ?? false)
selectable: exact?.selectable ?? false, delimiter: entry.delimiter, specialUse: exact?.flags)
}
return roots.values.map { folder in
var folder = folder
@@ -169,8 +172,8 @@ final class IMAPClient {
return result
}
func inventory(folder: MailFolder) async throws -> MailboxInventory {
let examined = try await command("EXAMINE \(Self.quote(folder.path))")
func inventory(folder: MailFolder, writable: Bool = false) async throws -> MailboxInventory {
let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))")
let validity = try Self.uidValidity(from: examined.lines)
let search = try await command("UID SEARCH ALL")
guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else {
@@ -206,4 +209,132 @@ final class IMAPClient {
try await connection.send(message + Data("\r\n".utf8))
_ = try await response(tag: tag)
}
func capabilities() async throws -> Set<String> {
let result = try await command("CAPABILITY")
return Set(result.lines.filter { $0.uppercased().hasPrefix("* CAPABILITY ") }
.flatMap { $0.uppercased().split(separator: " ").dropFirst(2).map(String.init) })
}
func createFolder(path: String) async throws { _ = try await command("CREATE \(Self.quote(path))") }
func deleteFolder(path: String) async throws { _ = try await command("DELETE \(Self.quote(path))") }
func containsMessage(id: String, folder: MailFolder) async throws -> Bool {
_ = try await command("EXAMINE \(Self.quote(folder.path))")
let result = try await command("UID SEARCH HEADER Message-ID \(Self.quote(id))")
guard let search = result.lines.first(where: { $0.hasPrefix("* SEARCH") }) else {
throw MailError.message("Gesendet-Kopie konnte nicht geprüft werden.")
}
return search.split(separator: " ").count > 2
}
func setSeen(uid: UInt64, read: Bool) async throws {
_ = 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))")
}
/// UID EXPUNGE must never be replaced by EXPUNGE: other clients may have marked unrelated mail.
func remove(uids: [UInt64]) async throws {
guard !uids.isEmpty else { return }
guard try await capabilities().contains("UIDPLUS") else {
throw MailError.message("Der Server unterstützt kein gezieltes Löschen (UIDPLUS). Es wurden keine Mails gelöscht.")
}
for start in stride(from: 0, to: uids.count, by: 200) {
let batch = uids[start..<min(start + 200, uids.count)].map(String.init).joined(separator: ",")
_ = try await command("UID STORE \(batch) +FLAGS.SILENT (\\Deleted)")
_ = try await command("UID EXPUNGE \(batch)")
}
}
func append(_ message: Data, path: String, flags: [String]) async throws {
let allowed = ["\\Seen", "\\Answered", "\\Flagged", "\\Draft"]
let safeFlags = flags.filter { allowed.contains($0) }.joined(separator: " ")
sequence += 1
let tag = "M\(sequence)"
try await connection.line("\(tag) APPEND \(Self.quote(path)) (\(safeFlags)) {\(message.count)}")
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)
}
static func encodeMailbox(_ value: String) -> String {
var result = ""
var run = ""
func flush() {
if !run.isEmpty {
let data = run.data(using: .utf16BigEndian) ?? Data()
result += "&" + data.base64EncodedString().replacingOccurrences(of: "/", with: ",").replacingOccurrences(of: "=", with: "") + "-"
run = ""
}
}
for scalar in value.unicodeScalars {
if (0x20...0x7e).contains(scalar.value) {
flush()
result += scalar == "&" ? "&-" : String(scalar)
} else { run += String(scalar) }
}
flush()
return result
}
static func isAuthenticationFailure(_ line: String) -> Bool {
let value = line.uppercased()
return value.contains("[AUTHENTICATIONFAILED]") || value.contains("AUTHENTICATION FAILED")
|| value.contains("AUTHENTICATION FAILURE") || value.contains("LOGIN FAILED")
|| value.contains("INVALID CREDENTIALS")
}
static func isMailboxChange(_ line: String) -> Bool {
let parts = line.uppercased().split(separator: " ")
guard parts.count >= 3, parts[0] == "*", UInt64(parts[1]) != nil else { return false }
return ["EXISTS", "EXPUNGE", "FETCH"].contains(String(parts[2]))
}
/// This connection is reserved for IDLE; normal commands use separate sessions.
/// Renew before the RFC 2177 inactivity limit, with a bounded DONE response wait.
func idleInbox(renewAfterSeconds: Int = 25 * 60,
onReady: () -> Void, onChange: () -> Void) async throws {
_ = try await command("EXAMINE \"INBOX\"")
sequence += 1
let tag = "M\(sequence)"
try await connection.line("\(tag) IDLE")
while true {
let line = try await connection.readLine()
if line.hasPrefix("+") { break }
guard line.hasPrefix("*"), !line.uppercased().hasPrefix("* BYE") else {
throw MailError.message("IMAP IDLE wurde nicht akzeptiert: \(line)")
}
}
onReady()
let renewal = Task {
do {
try await Task.sleep(for: .seconds(renewAfterSeconds))
try Task.checkCancellation()
try await connection.line("DONE")
try await Task.sleep(for: .seconds(30))
connection.cancel()
} catch {
if !Task.isCancelled { connection.cancel() }
}
}
defer { renewal.cancel() }
while true {
try Task.checkCancellation()
let line = try await connection.readLine(timeoutSeconds: nil)
if line.hasPrefix(tag + " ") {
guard line.uppercased().hasPrefix(tag + " OK") else {
throw MailError.message("IMAP IDLE: \(line)")
}
return
}
if line.uppercased().hasPrefix("* BYE") {
throw MailError.message("IMAP IDLE: Verbindung beendet.")
}
if Self.isMailboxChange(line) { onChange() }
}
}
}
+4 -3
View File
@@ -94,7 +94,7 @@ 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)
attachments: attachments, cc: decodeHeader(fields["cc"] ?? ""), replyTo: decodeHeader(fields["reply-to"] ?? ""))
}
private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) {
@@ -146,8 +146,8 @@ enum MIME {
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], subject: String, body: String, attachments: [MailAttachment]) throws -> Data {
guard validAddress(from), !to.isEmpty, to.allSatisfy(validAddress),
static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment]) 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.")
}
@@ -159,6 +159,7 @@ enum MIME {
data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed])
}
var result = "From: \(from)\r\nTo: \(to.joined(separator: ", "))\r\n"
if !cc.isEmpty { result += "Cc: \(cc.joined(separator: ", "))\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 += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
+59
View File
@@ -132,6 +132,34 @@ final class MailArchive {
} catch { context.rollback(); throw error }
}
/// Deletes only this account's local records. Shared EML objects remain while
/// another account references them. No IMAP operations are performed.
/// Returns file-cleanup failures after the database reset has committed.
func clearLocalMessages(accountID: UUID) throws -> Int {
let prefix = accountID.uuidString + ":"
let allRows = try context.fetch(FetchDescriptor<MailArchiveSchema.ArchivedMail>())
let removed = allRows.filter { $0.folderID.hasPrefix(prefix) }
let retainedHashes = Set(allRows.filter { !$0.folderID.hasPrefix(prefix) }.map(\.objectHash))
let removableHashes = Set(removed.map(\.objectHash)).subtracting(retainedHashes)
do {
for row in removed { context.delete(row) }
try context.save()
} catch {
context.rollback()
throw error
}
// Delete files only after the database commit, so a failed save cannot
// leave valid database references pointing to missing message files.
var failures = 0
for hash in removableHashes {
let url = objectURL(hash)
guard FileManager.default.fileExists(atPath: url.path) else { continue }
do { try FileManager.default.removeItem(at: url) }
catch { failures += 1 }
}
return failures
}
/// Reconcile only after a complete server inventory. Keep removed objects for future backups.
func reconcile(folderID: String, inventory: MailboxInventory) throws {
do {
@@ -152,6 +180,37 @@ final class MailArchive {
}.sorted { $0.date > $1.date }
}
func record(id: String) throws -> ArchiveMailRecord {
let key = id
guard let row = try context.fetch(FetchDescriptor<MailArchiveSchema.ArchivedMail>(
predicate: #Predicate { $0.key == key })).first, row.present else {
throw MailError.message("Nachricht ist nicht mehr verfügbar. Bitte erneut abrufen.")
}
return ArchiveMailRecord(key: row.key, folderID: row.folderID, validity: row.validity,
uid: row.uid, objectHash: row.objectHash, flags: row.flags, present: row.present)
}
func message(id: String) throws -> MailMessage {
let record = try record(id: id)
var message = MIME.parse(try Data(contentsOf: objectURL(record.objectHash)), id: id)
message.isRead = record.flags.contains("\\Seen")
return message
}
func pendingSent(accountID: UUID) throws -> [(uid: String, raw: Data)] {
try rows(folderID: accountID.uuidString + ":__local_outgoing__")
.filter { $0.flags.contains("SentCopyPending") || $0.flags.contains("Sent") }
.map { ($0.uid, try Data(contentsOf: objectURL($0.objectHash))) }
}
func unreadCounts() throws -> [String: Int] {
let rows = try context.fetch(FetchDescriptor<MailArchiveSchema.ArchivedMail>())
return rows.filter { $0.present && !$0.flags.contains("\\Seen") }
.reduce(into: [:]) { $0[$1.folderID, default: 0] += 1 }
}
private func objectURL(_ hash: String) -> URL { root.appending(path: "Objects/\(hash).eml") }
static func digest(_ data: Data) -> String {
+107
View File
@@ -0,0 +1,107 @@
import Foundation
import Network
import AppKit
@MainActor
final class MailIdleMonitor {
private var tasks: [UUID: Task<Void, Never>] = [:]
private var accounts: [UUID: MailAccount] = [:]
private var pathMonitor: NWPathMonitor?
private var wakeTask: Task<Void, Never>?
private var changed: ((UUID) -> Void)?
private var authenticationFailed: ((UUID) -> Void)?
private var report: ((UUID, String) -> Void)?
func configure(accounts values: [MailAccount], enabled: Bool,
changed: @escaping (UUID) -> Void, report: @escaping (UUID, String) -> Void,
authenticationFailed: @escaping (UUID) -> Void) {
self.authenticationFailed = authenticationFailed
self.changed = changed
self.report = report
let wanted = enabled ? Dictionary(uniqueKeysWithValues: values.map { ($0.id, $0) }) : [:]
for id in Array(tasks.keys) where wanted[id] != accounts[id] {
tasks.removeValue(forKey: id)?.cancel()
}
accounts = wanted
for account in wanted.values where tasks[account.id] == nil { launch(account) }
if !enabled || wanted.isEmpty {
pathMonitor?.cancel()
pathMonitor = nil
wakeTask?.cancel()
wakeTask = nil
} else if pathMonitor == nil {
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
guard path.status == .satisfied else { return }
Task { @MainActor [weak self] in self?.restart() }
}
monitor.start(queue: DispatchQueue(label: "MikeMail.IDLE.Network"))
pathMonitor = monitor
wakeTask = Task { [weak self] in
for await _ in NSWorkspace.shared.notificationCenter.notifications(named: NSWorkspace.didWakeNotification) {
guard !Task.isCancelled else { return }
self?.restart()
}
}
}
}
func restart(accountID: UUID? = nil) {
for account in accounts.values where accountID == nil || account.id == accountID {
tasks.removeValue(forKey: account.id)?.cancel()
launch(account)
}
}
private func launch(_ account: MailAccount) {
let changed = self.changed
let report = self.report
let authenticationFailed = self.authenticationFailed
tasks[account.id] = Task {
var retrySeconds = 2
while !Task.isCancelled {
do {
report?(account.id, "Verbindung wird aufgebaut …")
let client = try IMAPClient(account: account)
defer { client.close() }
try await withTaskCancellationHandler {
try Task.checkCancellation()
try await client.login(account: account, password: PasswordStore.password(for: account.id))
guard try await client.capabilities().contains("IDLE") else {
report?(account.id, "Server unterstützt kein IDLE; Intervallabruf bleibt verfügbar.")
return
}
while !Task.isCancelled {
try await client.idleInbox {
retrySeconds = 2
report?(account.id, "Sofortempfang aktiv")
// Reconcile the gap before the listener became ready, also after reconnects.
changed?(account.id)
} onChange: {
changed?(account.id)
}
}
} onCancel: {
client.cancel()
}
} catch {
if Task.isCancelled { return }
if case MailError.authentication = error {
report?(account.id, "Passwort erforderlich")
authenticationFailed?(account.id)
return
}
report?(account.id, "Verbindung unterbrochen; neuer Versuch in \(retrySeconds) s.")
}
do { try await Task.sleep(for: .seconds(retrySeconds)) }
catch { return }
retrySeconds = min(retrySeconds * 2, 60)
}
}
}
}
struct IdleConfiguration: Equatable {
let enabled: Bool
let accounts: [MailAccount]
}
+12 -1
View File
@@ -21,6 +21,13 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable {
let name: String
var selectable = true
var children: [MailFolder]?
var delimiter: String?
var specialUse: [String]?
var isInbox: Bool { path.uppercased() == "INBOX" }
var isTrash: Bool {
specialUse?.contains("\\trash") == true || ["trash", "papierkorb", "deleted messages", "deleted items"].contains(name.lowercased())
}
var icon: String {
switch path.lowercased() {
@@ -49,12 +56,16 @@ struct MailMessage: Identifiable, Sendable {
let body: String
let attachments: [MailAttachment]
var isRead = false
var cc = ""
var replyTo = ""
var attachmentCount: Int { attachments.count }
}
enum MailError: LocalizedError {
case message(String)
case authentication(String)
var errorDescription: String? {
switch self { case .message(let text): text }
switch self { case .message(let text), .authentication(let text): text }
}
}
+25
View File
@@ -79,3 +79,28 @@ int mail_smtp_send(const char *host, unsigned short port, const char *username,
curl_easy_cleanup(curl);
return (int)result;
}
int mail_smtp_check(const char *host, unsigned short port, const char *username,
const char *password, char *error, size_t errorCapacity) {
pthread_once(&curl_once, initialize_curl);
CURL *curl = curl_easy_init();
if (!curl) return CURLE_FAILED_INIT;
char url[1024], detail[CURL_ERROR_SIZE] = {0};
snprintf(url, sizeof(url), "%s://%s:%u", port == 465 ? "smtps" : "smtp", host, port);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_USE_SSL, (long)CURLUSESSL_ALL);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_USERNAME, username);
curl_easy_setopt(curl, CURLOPT_PASSWORD, password);
curl_easy_setopt(curl, CURLOPT_NOBODY, 1L);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "NOOP");
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 25L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 45L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, detail);
CURLcode result = curl_easy_perform(curl);
if (result != CURLE_OK) snprintf(error, errorCapacity, "%s", detail[0] ? detail : curl_easy_strerror(result));
curl_easy_cleanup(curl);
return (int)result;
}
+2
View File
@@ -6,4 +6,6 @@ int mail_smtp_send(const char *host, unsigned short port, const char *username,
const char *password, const char *sender, const char *recipients,
const unsigned char *message, size_t length,
char *error, size_t errorCapacity);
int mail_smtp_check(const char *host, unsigned short port, const char *username,
const char *password, char *error, size_t errorCapacity);
#endif
+81
View File
@@ -0,0 +1,81 @@
import SwiftUI
import AppKit
enum MailDeleteConfirmation: String {
case always, multiple, never
func isRequired(count: Int) -> Bool {
switch self {
case .always: count > 0
case .multiple: count > 1
case .never: false
}
}
}
struct MailSettings: View {
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
@AppStorage("idleEnabled") private var idleEnabled = true
@AppStorage("pollMinutes") private var pollMinutes = 5
@AppStorage("offerTestMail") private var offerTestMail = true
@AppStorage("checkOnLaunch") private var checkOnLaunch = true
@AppStorage("showUnifiedInbox") private var showInbox = true
@AppStorage("showUnifiedTrash") private var showTrash = true
var body: some View {
Form {
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) {
Text("Nur manuell").tag(0)
ForEach([1, 2, 5, 10, 15, 30, 60], id: \.self) { Text("Alle \($0) Minuten").tag($0) }
}
Toggle("Nach neuem Konto Testmail anbieten", isOn: $offerTestMail)
Toggle("Beim Start automatisch auf neue Mails prüfen", isOn: $checkOnLaunch)
Toggle("Gemeinsamen Posteingang anzeigen", isOn: $showInbox)
Toggle("Gemeinsamen Papierkorb anzeigen", isOn: $showTrash)
Picker("Beim Löschen von Mails nachfragen", selection: $deleteConfirmation) {
Text("Immer").tag(MailDeleteConfirmation.always)
Text("Nur bei mehreren Nachrichten").tag(MailDeleteConfirmation.multiple)
Text("Nie").tag(MailDeleteConfirmation.never)
}
}.padding(24).frame(width: 460)
}
}
/// Uses AppKit's native persistence for window geometry and split positions.
struct WindowPersistence: NSViewRepresentable {
func makeNSView(context: Context) -> PersistenceView { PersistenceView() }
func updateNSView(_ view: PersistenceView, context: Context) { view.configure() }
final class PersistenceView: NSView {
override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); configure() }
override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview(); configure() }
func configure() {
guard let window else { return }
if window.frameAutosaveName.isEmpty {
window.setFrameAutosaveName("MikeMail.MainWindow")
window.setFrameUsingName("MikeMail.MainWindow")
}
var ancestor = superview
var index = 0
while let view = ancestor {
if let split = view as? NSSplitView {
if split.autosaveName == nil { split.autosaveName = "MikeMail.Split.\(index)" }
index += 1
}
ancestor = view.superview
}
configureTables(window.contentView)
}
private func configureTables(_ view: NSView?) {
guard let view else { return }
if let table = view as? NSTableView, !(table is NSOutlineView), table.numberOfColumns > 1, table.autosaveName == nil {
table.autosaveName = "MikeMail.MessageTable"
table.autosaveTableColumns = true
}
for child in view.subviews { configureTables(child) }
}
}
}
+396 -15
View File
@@ -8,16 +8,33 @@ final class MailStore {
var folders: [UUID: [MailFolder]] = [:]
var messages: [MailMessage] = []
var isBusy = false
private(set) var sendingAccountIDs: Set<UUID> = []
var unreadCounts: [String: Int] = [:]
var archiveRevision = 0
private var validatedAccounts: [UUID: MailAccount] = [:]
private var validatedPasswords: [UUID: String] = [:]
var totalMessages = 0
var status = "Lokales Archiv bereit."
var error: String?
private var archive: MailArchive?
private var selectedFolderID: String?
private var didStart = false
private let idleMonitor = MailIdleMonitor()
private var idleEnabled = false
private var pendingIdleAccounts: Set<UUID> = []
private var idleRefreshGeneration = UUID()
private var idleRefreshTask: Task<Void, Never>?
var idleStatuses: [UUID: String] = [:]
var authenticationAccountID: UUID?
private var pendingAuthentication: Set<UUID> = []
var authenticationAccount: MailAccount? { accounts.first { $0.id == authenticationAccountID } }
init(archive: MailArchive? = nil) {
do {
let storage = try archive ?? MailArchive()
self.archive = storage
unreadCounts = try storage.unreadCounts()
if let configuration = try storage.configuration() {
accounts = configuration.accounts
folders = configuration.folders
@@ -51,6 +68,11 @@ final class MailStore {
account.imapPort > 0, account.smtpPort > 0 else {
throw MailError.message("Bitte E-Mail-Adresse, Benutzername, Server und Ports prüfen.")
}
if !accounts.contains(where: { $0.id == account.id }) {
guard validatedAccounts[account.id] == account, validatedPasswords[account.id] == password else {
throw MailError.message("Bitte zuerst IMAP und SMTP mit diesen Einstellungen erfolgreich testen.")
}
}
let archive = try storage()
if let old = accounts.first(where: { $0.id == account.id }),
old.host != account.host || old.username != account.username || old.email != account.email {
@@ -62,17 +84,22 @@ final class MailStore {
if !password.isEmpty { try PasswordStore.save(password, for: account.id) }
try archive.saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory))
accounts = updated
validatedAccounts[account.id] = nil
validatedPasswords[account.id] = nil
status = "Konto lokal gespeichert."
configureIdle(enabled: idleEnabled)
idleMonitor.restart(accountID: account.id)
}
func deleteAccount(id: UUID) throws {
guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") }
guard !isBusy, !sendingAccountIDs.contains(id) else { throw MailError.message("Bitte den laufenden Abruf abwarten.") }
let archive = try storage()
let remaining = accounts.filter { $0.id != id }
var remainingFolders = folders
remainingFolders[id] = nil
try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory))
accounts = remaining
configureIdle(enabled: idleEnabled)
folders = remainingFolders
messages = []
totalMessages = 0
@@ -80,28 +107,73 @@ final class MailStore {
status = "Konto entfernt; archivierte Originalmails und Serverdaten bleiben erhalten."
}
private func allFolders(_ items: [MailFolder]) -> [MailFolder] {
func clearLocalMessages(accountID: UUID) throws -> Int {
guard !isBusy, !sendingAccountIDs.contains(accountID) else {
throw MailError.message("Bitte den laufenden Abruf oder Versand abwarten.")
}
guard let account = accounts.first(where: { $0.id == accountID }) else {
throw MailError.message("Das Konto existiert nicht mehr.")
}
let failures = try storage().clearLocalMessages(accountID: accountID)
unreadCounts = try storage().unreadCounts()
archiveRevision += 1
if selectedFolderID?.hasPrefix(accountID.uuidString + ":") == true {
messages = []
totalMessages = 0
}
if let selectedFolderID, selectedFolderID.hasPrefix("unified:") { try display(folderID: selectedFolderID) }
status = "\(account.email): Lokale Mails gelöscht. „Abrufen“ lädt alle Servermails erneut."
return failures
}
func allFolders(_ items: [MailFolder]) -> [MailFolder] {
items.flatMap { [$0] + allFolders($0.children ?? []) }
}
func refresh(folderID: String?) async {
func refresh(folderID: String?, accountID: UUID? = nil) async {
guard !isBusy else { return }
isBusy = true
defer { isBusy = false }
var failures: [String] = []
do {
let archive = try storage()
for account in accounts {
for account in accounts where accountID == nil || account.id == accountID {
do {
let client = try IMAPClient(account: account)
defer { client.close() }
status = "\(account.email): Verbindung wird aufgebaut …"
try await client.login(account: account, password: PasswordStore.password(for: account.id))
let discovered = try await client.folders(accountID: account.id)
var discovered = try await client.folders(accountID: account.id)
let pending = 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()) }
if sent == nil {
try await client.createFolder(path: "Sent")
discovered = try await client.folders(accountID: account.id)
sent = allFolders(discovered).first { $0.path == "Sent" }
}
guard let sent else { throw MailError.message("Gesendet-Ordner wurde nicht gefunden.") }
for copy in pending {
let headers = MIME.split(copy.raw).0
guard let messageID = headers["message-id"] else {
throw MailError.message("Gesendete Kopie ohne Message-ID kann nicht sicher abgeglichen werden.")
}
if try await !client.containsMessage(id: messageID, folder: sent) {
try await client.append(copy.raw, path: sent.path, flags: ["\\Seen"])
}
try archive.store(raw: copy.raw, folderID: account.id.uuidString + ":__local_outgoing__",
validity: "local", uid: copy.uid, flags: ["\\Seen", "SentCopied"], visible: false)
}
}
// Keep the previous listing if saving the new configuration fails.
var updated = folders
updated[account.id] = discovered
try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory))
let discoveredIDs = Set(allFolders(discovered).map(\.id))
for removed in allFolders(folders[account.id] ?? []) where !discoveredIDs.contains(removed.id) {
try archive.reconcile(folderID: removed.id, inventory: MailboxInventory(validity: "", flags: [:]))
}
folders = updated
for folder in allFolders(discovered) where folder.selectable {
do {
@@ -120,20 +192,31 @@ final class MailStore {
if selectedFolderID == folder.id { try display(folderID: folder.id) }
} catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") }
}
} catch { failures.append("\(account.email): \(error.localizedDescription)") }
} catch {
if case MailError.authentication = error { requestPassword(accountID: account.id) }
failures.append("\(account.email): \(error.localizedDescription)")
}
}
unreadCounts = try archive.unreadCounts()
archiveRevision += 1
if let selectedFolderID { try display(folderID: selectedFolderID) }
} catch { failures.append(error.localizedDescription) }
if failures.isEmpty {
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
} else {
error = failures.joined(separator: "\n")
if authenticationAccountID == nil { error = failures.joined(separator: "\n") }
status = "Abgleich unvollständig. Bereits gespeicherte Mails bleiben offline verfügbar."
}
}
private func display(folderID: String) throws {
messages = try storage().messages(folderID: folderID)
if folderID == "unified:inbox" || folderID == "unified:trash" {
let candidates = accounts.flatMap { allFolders(folders[$0.id] ?? []) }
.filter { folderID == "unified:inbox" ? $0.isInbox : $0.isTrash }
messages = try candidates.flatMap { try storage().messages(folderID: $0.id) }.sorted { $0.date > $1.date }
} else {
messages = try storage().messages(folderID: folderID)
}
totalMessages = messages.count
}
@@ -146,29 +229,327 @@ final class MailStore {
} catch { self.error = error.localizedDescription }
}
func send(accountID: UUID?, recipients: 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]) async throws {
guard let account = accounts.first(where: { $0.id == accountID }) else {
throw MailError.message("Bitte ein Absenderkonto auswählen.")
}
guard !sendingAccountIDs.contains(account.id) else {
throw MailError.message("Für dieses Konto läuft bereits ein Versand.")
}
sendingAccountIDs.insert(account.id)
defer { sendingAccountIDs.remove(account.id) }
let archive = try storage()
let addresses = recipients.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let data = try MIME.compose(from: account.email, to: addresses, subject: subject, body: body, attachments: attachments)
let addresses = Self.addresses(recipients)
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 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.
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["Pending"])
try await SMTPClient.send(account: account, password: PasswordStore.password(for: account.id), recipients: addresses, message: data)
try await SMTPClient.send(account: account, password: PasswordStore.password(for: account.id), recipients: envelope, message: data)
do {
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["\\Seen", "Sent"])
for address in addresses {
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["\\Seen", "SentCopyPending"])
for address in envelope {
recipientHistory.removeAll { $0.caseInsensitiveCompare(address) == .orderedSame }
recipientHistory.insert(address, at: 0)
}
try persist()
status = "Nachricht versendet und lokal archiviert."
status = "Nachricht versendet. Gesendet-Ordner wird synchronisiert …"
while isBusy { try await Task.sleep(for: .milliseconds(200)) }
await refresh(folderID: selectedFolderID, accountID: account.id)
} catch {
// SMTP already succeeded: do not offer an accidental resend by throwing here.
self.error = "Nachricht wurde versendet; Archivstatus konnte nicht aktualisiert werden: \(error.localizedDescription)"
}
}
func start(folderID: String?) async {
await load(folderID: folderID)
guard !didStart else { return }
didStart = true
if UserDefaults.standard.object(forKey: "checkOnLaunch") == nil || UserDefaults.standard.bool(forKey: "checkOnLaunch") {
await refresh(folderID: folderID)
}
}
func reorderAccount(_ id: UUID, before target: UUID) {
guard !isBusy, 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 }
updated.insert(source, at: index)
do {
try storage().saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory))
accounts = updated
} catch { self.error = error.localizedDescription }
}
func folder(id: String) -> MailFolder? {
accounts.flatMap { allFolders(folders[$0.id] ?? []) }.first { $0.id == id }
}
private func client(for id: UUID) async throws -> IMAPClient {
guard let account = accounts.first(where: { $0.id == id }) else { throw MailError.message("Konto nicht gefunden.") }
let client = try IMAPClient(account: account)
do {
try await client.login(account: account, password: PasswordStore.password(for: id))
return client
} catch { client.close(); throw error }
}
/// Serializes user mutations with synchronization. Refresh also reconciles partial successes.
private func mutate(_ operation: () async throws -> Void) async {
while isBusy { if Task.isCancelled { return }; try? await Task.sleep(for: .milliseconds(150)) }
isBusy = true
var failure: String?
do { try await operation() }
catch { failure = error.localizedDescription }
isBusy = false
await refresh(folderID: selectedFolderID)
if let failure { error = failure }
}
func moveMessages(ids: [String], to destination: MailFolder) async {
await mutate {
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)
}
}
}
private func moveOne(id: String, to destination: MailFolder) async throws {
let record = try storage().record(id: id)
guard record.folderID != destination.id else { return }
guard let source = folder(id: record.folderID), let uid = UInt64(record.uid), uid > 0 else {
throw MailError.message("Die Nachricht hat keinen gültigen Serverordner.")
}
let sourceClient = try await client(for: source.accountID)
defer { sourceClient.close() }
let inventory = try await sourceClient.inventory(folder: source, writable: true)
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)
return
}
guard capabilities.contains("UIDPLUS") else {
throw MailError.message("Zum sicheren Verschieben benötigt der Quellserver UIDPLUS.")
}
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] ?? [])
// A positive tagged APPEND completion precedes any source deletion.
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)")
}
}
func deleteMessages(ids: [String]) async {
await mutate {
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)
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])
} 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.")
}
try await self.moveOne(id: id, to: trash)
}
}
}
}
func selectionContainsTrash(_ ids: [String]) -> Bool {
ids.contains { id in
guard let record = try? storage().record(id: id) else { return false }
return folder(id: record.folderID)?.isTrash == true
}
}
func createFolder(accountID: UUID, parent: MailFolder?, name: String) async {
await mutate {
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
let separator = parent?.delimiter ?? self.folders[accountID]?.first?.delimiter ?? "/"
guard !name.isEmpty, !name.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
separator.isEmpty || !name.contains(separator) else {
throw MailError.message("Bitte einen Ordnernamen ohne Trenn- oder Steuerzeichen eingeben.")
}
guard parent == nil || !separator.isEmpty else { throw MailError.message("Dieser Server unterstützt keine Unterordner.") }
let path = (parent.map { $0.path + separator } ?? "") + IMAPClient.encodeMailbox(name)
let client = try await self.client(for: accountID)
defer { client.close() }
try await client.createFolder(path: path)
}
}
func deleteFolder(_ folder: MailFolder) async {
await mutate {
guard !folder.isInbox else { throw MailError.message("Der Posteingang kann nicht gelöscht werden.") }
let client = try await self.client(for: folder.accountID)
defer { client.close() }
for item in self.allFolders([folder]).reversed() {
try await client.deleteFolder(path: item.path)
}
}
}
func emptyTrash(folderID: String) async {
let targets = folderID == "unified:trash"
? accounts.flatMap { allFolders(folders[$0.id] ?? []) }.filter(\.isTrash)
: [folder(id: folderID)].compactMap { $0 }.filter(\.isTrash)
await mutate {
for folder in targets {
let client = try await self.client(for: folder.accountID)
defer { client.close() }
let inventory = try await client.inventory(folder: folder, writable: true)
try await client.remove(uids: Array(inventory.flags.keys))
}
}
}
static func addresses(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "," || $0 == ";" })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
}
func testConnection(account: MailAccount, password: String) async throws {
guard MIME.validAddress(account.email), !account.host.isEmpty,
!account.host.contains(where: { $0.isWhitespace || "/:@".contains($0) }),
!account.username.isEmpty else { throw MailError.message("Bitte gültige Kontodaten eingeben.") }
let secret = password.isEmpty ? try PasswordStore.password(for: account.id) : password
let client = try IMAPClient(account: account)
defer { client.close() }
try await client.login(account: account, password: secret)
_ = try await client.folders(accountID: account.id)
try await SMTPClient.check(account: account, password: secret)
validatedAccounts[account.id] = account
validatedPasswords[account.id] = password
}
func message(id: String) -> MailMessage? { try? storage().message(id: id) }
func accountID(for messageID: String) -> UUID? {
guard let record = try? storage().record(id: messageID) else { return nil }
return folder(id: record.folderID)?.accountID
}
func unreadCount(folderID: String) -> Int {
if folderID == "unified:inbox" || folderID == "unified:trash" {
return accounts.flatMap { allFolders(folders[$0.id] ?? []) }
.filter { folderID == "unified:inbox" ? $0.isInbox : $0.isTrash }
.reduce(0) { $0 + (unreadCounts[$1.id] ?? 0) }
}
return unreadCounts[folderID] ?? 0
}
func setRead(ids: [String], read: Bool) async {
await mutate {
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)
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)
}
}
}
func poll(intervalMinutes: Int) async {
guard intervalMinutes > 0 else { return }
while !Task.isCancelled {
do { try await Task.sleep(for: .seconds(max(1, intervalMinutes) * 60)) }
catch { return }
await refresh(folderID: selectedFolderID)
}
}
func configureIdle(enabled: Bool) {
idleEnabled = enabled
if !enabled {
pendingIdleAccounts.removeAll()
idleRefreshGeneration = UUID()
idleRefreshTask?.cancel()
idleRefreshTask = nil
idleStatuses = [:]
} else { idleStatuses = idleStatuses.filter { id, _ in accounts.contains { $0.id == id } } }
idleMonitor.configure(accounts: accounts, enabled: enabled) { [weak self] id in
self?.scheduleIdleRefresh(accountID: id)
} report: { [weak self] id, status in
self?.idleStatuses[id] = status
} authenticationFailed: { [weak self] id in
self?.requestPassword(accountID: id)
}
}
private func scheduleIdleRefresh(accountID: UUID) {
guard idleEnabled, accounts.contains(where: { $0.id == accountID }) else { return }
pendingIdleAccounts.insert(accountID)
guard idleRefreshTask == nil else { return }
let generation = UUID()
idleRefreshGeneration = generation
idleRefreshTask = Task { [weak self] in
guard let self else { return }
defer { if self.idleRefreshGeneration == generation { self.idleRefreshTask = nil } }
// Coalesce bursts and retain notifications arriving during another synchronization.
try? await Task.sleep(for: .milliseconds(400))
while !Task.isCancelled, self.idleEnabled, !self.pendingIdleAccounts.isEmpty {
if self.isBusy {
do { try await Task.sleep(for: .milliseconds(200)) } catch { return }
continue
}
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)
}
}
}
func requestPassword(accountID: UUID) {
guard accounts.contains(where: { $0.id == accountID }) else { return }
if authenticationAccountID == nil { authenticationAccountID = accountID }
else if authenticationAccountID != accountID { pendingAuthentication.insert(accountID) }
error = nil
}
func dismissPasswordRequest() {
authenticationAccountID = nil
pendingAuthentication = pendingAuthentication.filter { id in accounts.contains { $0.id == id } }
if let next = pendingAuthentication.first {
pendingAuthentication.remove(next)
authenticationAccountID = next
}
}
func updatePassword(accountID: UUID, password: String) async throws {
guard !password.isEmpty, let account = accounts.first(where: { $0.id == accountID }) else {
throw MailError.message("Bitte ein Passwort eingeben.")
}
let client = try IMAPClient(account: account)
defer { client.close() }
try await client.login(account: account, password: password)
_ = try await client.folders(accountID: account.id)
try PasswordStore.save(password, for: account.id)
idleMonitor.restart(accountID: account.id)
dismissPasswordRequest()
while isBusy { try await Task.sleep(for: .milliseconds(150)) }
await refresh(folderID: selectedFolderID, accountID: account.id)
}
}
+16 -4
View File
@@ -50,6 +50,8 @@ final class TLSConnection {
func close() { deadline?.cancel(); connection.cancel() }
nonisolated func cancel() { connection.cancel() }
func send(_ data: Data) async throws {
let timeout = Task { [connection] in
try? await Task.sleep(for: .seconds(30))
@@ -66,9 +68,10 @@ final class TLSConnection {
func line(_ text: String) async throws { try await send(Data((text + "\r\n").utf8)) }
private func receive() async throws {
private func receive(timeoutSeconds: Int? = 30) async throws {
let timeout = Task { [connection] in
try? await Task.sleep(for: .seconds(30))
guard let timeoutSeconds else { return }
try? await Task.sleep(for: .seconds(timeoutSeconds))
if !Task.isCancelled { connection.cancel() }
}
defer { timeout.cancel() }
@@ -82,7 +85,7 @@ final class TLSConnection {
buffer.append(data)
}
func readLine() async throws -> String {
func readLine(timeoutSeconds: Int? = 30) async throws -> String {
while true {
if let range = buffer.range(of: Data("\r\n".utf8)) {
let data = buffer[..<range.lowerBound]
@@ -91,7 +94,7 @@ final class TLSConnection {
return value
}
guard buffer.count < 1_000_000 else { throw MailError.message("Serverantwort ist zu lang.") }
try await receive()
try await receive(timeoutSeconds: timeoutSeconds)
}
}
@@ -105,6 +108,15 @@ final class TLSConnection {
}
enum SMTPClient {
static func check(account: MailAccount, password: String) async throws {
let result = await Task.detached(priority: .userInitiated) {
var error = [CChar](repeating: 0, count: 1024)
let code = mail_smtp_check(account.host, account.smtpPort, account.username, password, &error, error.count)
return (code, String(cString: error))
}.value
guard result.0 == 0 else { throw MailError.message("SMTP-Verbindungstest: \(result.1)") }
}
static func send(account: MailAccount, password: String, recipients: [String], message: Data) async throws {
guard MIME.validAddress(account.email), !recipients.isEmpty, recipients.allSatisfy(MIME.validAddress) else {
throw MailError.message("Ungültige E-Mail-Adresse.")
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
enum MessageCommand {
case open, reply, forward, delete, read, unread
}
struct ComposeDraft: Identifiable {
let id = UUID()
var accountID: UUID?
var recipient = ""
var subject = ""
var body = ""
var attachments: [MailAttachment] = []
var isReply = false
static func from(_ message: MailMessage, accountID: UUID?, forwarding: Bool) -> ComposeDraft {
let source = message.replyTo.isEmpty ? message.sender : message.replyTo
let recipient: String
if let start = source.firstIndex(of: "<"), let end = source[start...].firstIndex(of: ">") {
recipient = String(source[source.index(after: start)..<end])
} else { recipient = source }
return ComposeDraft(accountID: accountID, recipient: forwarding ? "" : recipient,
subject: (forwarding ? "Fwd: " : "Re: ") + message.subject,
body: "\n\n" + (forwarding ? "Weitergeleitete Nachricht" : "Am \(message.date.formatted()) schrieb \(message.sender):") +
"\nVon: \(message.sender)\nAn: \(message.recipient)\nBetreff: \(message.subject)\n\n" +
message.body.components(separatedBy: "\n").map { forwarding ? $0 : "> " + $0 }.joined(separator: "\n"),
attachments: forwarding ? message.attachments : [], isReply: !forwarding)
}
}
struct MessageActionButtons: View {
let action: (MessageCommand) -> Void
var body: some View {
Button("Löschen…", systemImage: "trash") { action(.delete) }
Button("Antworten…", systemImage: "arrowshape.turn.up.left") { action(.reply) }
Button("Weiterleiten", systemImage: "arrowshape.turn.up.right") { action(.forward) }
}
}
struct MessageDetailWindow: View {
let store: MailStore
let messageID: String?
@State private var draft: ComposeDraft?
@State private var confirmingDelete = false
@AppStorage("mailDeleteConfirmation") private var deleteConfirmation: MailDeleteConfirmation = .always
private var message: MailMessage? {
_ = store.archiveRevision
return messageID.flatMap { store.message(id: $0) }
}
var body: some View {
MessageReader(message: message)
.frame(minWidth: 600, minHeight: 450)
.navigationTitle(message?.subject ?? "Nachricht")
.toolbar {
MessageActionButtons { command in
guard let message else { return }
switch command {
case .reply, .forward:
draft = .from(message, accountID: store.accountID(for: message.id), forwarding: command == .forward)
case .delete:
if deleteConfirmation.isRequired(count: 1) { confirmingDelete = true }
else { Task { await store.deleteMessages(ids: [message.id]) } }
default: break
}
}.disabled(message == nil || store.isBusy)
}
.sheet(item: $draft) { ComposeView(store: store, draft: $0) }
.alert("Nachricht löschen?", isPresented: $confirmingDelete) {
Button("Abbrechen", role: .cancel) {}
Button("Löschen", role: .destructive) {
if let messageID { Task { await store.deleteMessages(ids: [messageID]) } }
}
} message: {
Text(store.selectionContainsTrash([messageID ?? ""])
? "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) }
}
.alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
Button("OK") { store.error = nil }
} message: { Text(store.error ?? "") }
}
}
struct UnreadBadge: View {
let count: Int
var body: some View {
if count > 0 {
Text(count.formatted()).font(.caption.bold()).monospacedDigit()
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.quaternary, in: Capsule())
.accessibilityLabel("\(count) ungelesen")
}
}
}
+10 -1
View File
@@ -2,12 +2,21 @@ import SwiftUI
@main
struct MikeMailApp: App {
@State private var store = MailStore()
@State private var store: MailStore = {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-fixture") { return MailStore.uiFixture() }
#endif
return MailStore()
}()
var body: some Scene {
WindowGroup {
ContentView(store: store)
}
.defaultSize(width: 1200, height: 800)
WindowGroup("Nachricht", id: "message", for: String.self) { $id in
MessageDetailWindow(store: store, messageID: id)
}.defaultSize(width: 800, height: 650)
Settings { MailSettings() }
}
}
+43
View File
@@ -0,0 +1,43 @@
import SwiftUI
struct PasswordRecoveryView: View {
let store: MailStore
let account: MailAccount
@State private var password = ""
@State private var checking = false
@State private var error: String?
@FocusState private var passwordFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Passwort erforderlich").font(.title2).bold()
Text("Der Mailserver hat die Anmeldung für \(account.email) abgelehnt.")
SecureField("Neues Passwort", text: $password)
.textContentType(.password)
.focused($passwordFocused)
Text("Das Passwort wird erst nach erfolgreicher Anmeldung im Schlüsselbund gespeichert.")
.font(.caption).foregroundStyle(.secondary)
if let error { Text(error).foregroundStyle(.red) }
HStack {
Button("Abbrechen") { store.dismissPasswordRequest() }.keyboardShortcut(.cancelAction)
Spacer()
if checking { ProgressView().controlSize(.small) }
Button("Prüfen und speichern") {
checking = true
error = nil
Task {
defer { checking = false }
do { try await store.updatePassword(accountID: account.id, password: password) }
catch { self.error = error.localizedDescription }
}
}
.keyboardShortcut(.defaultAction)
.disabled(password.isEmpty)
}
}
.padding(24).frame(width: 460)
.disabled(checking)
.interactiveDismissDisabled(checking)
.onAppear { passwordFocused = true }
}
}
+23
View File
@@ -0,0 +1,23 @@
#if DEBUG
import Foundation
extension MailStore {
/// A separate archive keeps UI automation independent of real mail and server availability.
static func uiFixture() -> MailStore {
do {
let root = FileManager.default.temporaryDirectory.appending(path: "MikeMailUI-\(ProcessInfo.processInfo.processIdentifier)")
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: []))
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)
} catch { fatalError("UI fixture: \(error)") }
}
}
#endif
+61
View File
@@ -0,0 +1,61 @@
import Foundation
import Testing
@testable import MikeMail
@MainActor
struct MailActionTests {
@Test func ccAndBccStaySeparateFromEnvelope() throws {
let raw = try MIME.compose(from: "sender@example.com", to: ["to@example.com"],
cc: ["copy@example.com"], bcc: ["hidden@example.com"], subject: "CC BCC", body: "Hello", attachments: [])
let headers = MIME.split(raw).0
#expect(headers["cc"] == "copy@example.com")
#expect(headers["bcc"] == nil)
#expect(!String(decoding: raw, as: UTF8.self).contains("hidden@example.com"))
#expect(MIME.parse(raw, id: "1").cc == "copy@example.com")
#expect(throws: (any Error).self) {
try MIME.compose(from: "sender@example.com", to: [], bcc: ["bad\r\nBcc: evil@example.com"],
subject: "No injection", body: "", attachments: [])
}
let blindOnly = try MIME.compose(from: "sender@example.com", to: [], bcc: ["hidden@example.com"],
subject: "Blind only", body: "", attachments: [])
#expect(MIME.split(blindOnly).0["bcc"] == nil)
}
@Test func replyUsesReplyToAndForwardIncludesAttachments() {
let attachment = MailAttachment(name: "sample.txt", mimeType: "text/plain", data: Data("abc".utf8))
let mail = MailMessage(id: "id", sender: "Sender <from@example.com>", recipient: "to@example.com",
subject: "Question", date: Date(), body: "Original", attachments: [attachment], replyTo: "Reply <reply@example.com>")
let account = UUID()
let reply = ComposeDraft.from(mail, accountID: account, forwarding: false)
#expect(reply.recipient == "reply@example.com")
#expect(reply.accountID == account)
#expect(reply.body.contains("> Original"))
#expect(reply.attachments.isEmpty)
let forward = ComposeDraft.from(mail, accountID: account, forwarding: true)
#expect(forward.recipient.isEmpty)
#expect(forward.attachments.first?.data == attachment.data)
}
@Test func newAccountRequiresValidationAndFlagsDriveCounts() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let archive = try MailArchive(root: root)
try archive.saveConfiguration(ArchiveConfiguration(accounts: [], folders: [:], recipients: []))
let store = MailStore(archive: archive)
let account = MailAccount(name: "New", email: "new@example.com", username: "new", host: "example.com")
#expect(throws: (any Error).self) { try store.save(account: account, password: "unused") }
#expect(store.accounts.isEmpty)
let folder = MailFolder(accountID: account.id, path: "INBOX", name: "Inbox")
let raw = Data("Subject: Flags\r\nMessage-ID: <fixture@example.com>\r\n\r\nBody".utf8)
try archive.store(raw: raw, folderID: folder.id, validity: "1", uid: "1", flags: [])
#expect(try archive.unreadCounts()[folder.id] == 1)
try archive.reconcile(folderID: folder.id, inventory: MailboxInventory(validity: "1", flags: [1: ["\\Seen"]]))
#expect(try archive.unreadCounts()[folder.id] == nil)
try archive.store(raw: raw, folderID: account.id.uuidString + ":__local_outgoing__",
validity: "local", uid: "outgoing", flags: ["\\Seen", "SentCopyPending"])
#expect(try archive.pendingSent(accountID: account.id).count == 1)
try archive.store(raw: raw, folderID: account.id.uuidString + ":__local_outgoing__",
validity: "local", uid: "outgoing", flags: ["\\Seen", "SentCopied"], visible: false)
#expect(try archive.pendingSent(accountID: account.id).isEmpty)
}
}
+40
View File
@@ -55,6 +55,46 @@ struct MailArchiveTests {
#expect(!FileManager.default.fileExists(atPath: base.appending(path: "Rejected").path))
}
@Test func localResetIsAccountScopedAndForcesRedownload() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let archive = try MailArchive(root: root)
let a = MailAccount(name: "A", email: "a@example.com", username: "a", host: "example.com")
let b = MailAccount(name: "B", email: "b@example.com", username: "b", host: "example.com")
let inboxA = MailFolder(accountID: a.id, path: "INBOX", name: "Posteingang")
let inboxB = MailFolder(accountID: b.id, path: "INBOX", name: "Posteingang")
let hiddenA = MailFolder(accountID: a.id, path: "OldFolder", name: "Alt")
let outgoingA = MailFolder(accountID: a.id, path: "__local_outgoing__", name: "Versand")
let shared = Data("Subject: Shared\r\n\r\nShared body".utf8)
let unique = Data("Subject: Local\r\n\r\nOnly A".utf8)
try archive.saveConfiguration(ArchiveConfiguration(accounts: [a, b], folders: [a.id: [inboxA], b.id: [inboxB]], recipients: ["x@example.com"]))
try archive.store(raw: shared, folderID: inboxA.id, validity: "1", uid: "1", flags: [])
try archive.store(raw: shared, folderID: inboxB.id, validity: "1", uid: "1", flags: [])
try archive.store(raw: unique, folderID: hiddenA.id, validity: "old", uid: "2", flags: [], visible: false)
try archive.store(raw: unique, folderID: outgoingA.id, validity: "local", uid: "pending", flags: ["Pending"])
let store = MailStore(archive: archive)
await store.load(folderID: inboxA.id)
#expect(store.messages.count == 1)
store.isBusy = true
#expect(throws: (any Error).self) { try store.clearLocalMessages(accountID: a.id) }
#expect(try archive.knownUIDs(folderID: inboxA.id, validity: "1") == [1])
store.isBusy = false
#expect(try store.clearLocalMessages(accountID: a.id) == 0)
#expect(store.messages.isEmpty)
#expect(store.accounts == [a, b])
#expect(try archive.configuration()?.folders[a.id] == [inboxA])
#expect(try archive.knownUIDs(folderID: inboxA.id, validity: "1").isEmpty)
#expect(try archive.knownUIDs(folderID: hiddenA.id, validity: "old").isEmpty)
#expect(try archive.messages(folderID: outgoingA.id).isEmpty)
#expect(try archive.messages(folderID: inboxB.id).count == 1)
#expect(FileManager.default.fileExists(atPath: root.appending(path: "Objects/\(MailArchive.digest(shared)).eml").path))
#expect(!FileManager.default.fileExists(atPath: root.appending(path: "Objects/\(MailArchive.digest(unique)).eml").path))
let reopened = try MailArchive(root: root)
#expect(try reopened.knownUIDs(folderID: inboxA.id, validity: "1").isEmpty)
try reopened.store(raw: shared, folderID: inboxA.id, validity: "1", uid: "1", flags: [])
#expect(try reopened.messages(folderID: inboxA.id).count == 1)
}
@Test func imapInventoryParsing() throws {
#expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123")
#expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) }
+39
View File
@@ -0,0 +1,39 @@
import Foundation
import Testing
@testable import MikeMail
@MainActor
struct MailIdleTests {
@Test func classifiesServerResponses() {
#expect(IMAPClient.isMailboxChange("* 12 EXISTS"))
#expect(IMAPClient.isMailboxChange("* 2 EXPUNGE"))
#expect(IMAPClient.isMailboxChange("* 2 FETCH (FLAGS (\\Seen))"))
#expect(!IMAPClient.isMailboxChange("* OK Still here"))
#expect(!IMAPClient.isMailboxChange("M4 OK IDLE completed"))
#expect(IMAPClient.isAuthenticationFailure("M1 NO [AUTHENTICATIONFAILED] Authentication failed."))
#expect(IMAPClient.isAuthenticationFailure("M1 NO LOGIN failed"))
#expect(!IMAPClient.isAuthenticationFailure("M1 NO [UNAVAILABLE] Server temporarily unavailable"))
#expect(!IMAPClient.isAuthenticationFailure("* BYE Connection closed"))
}
@Test func queuesPasswordPromptsPerAccount() throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let archive = try MailArchive(root: root)
let a = MailAccount(name: "A"), b = MailAccount(name: "B")
try archive.saveConfiguration(ArchiveConfiguration(accounts: [a, b], folders: [:], recipients: []))
let store = MailStore(archive: archive)
store.requestPassword(accountID: a.id)
store.requestPassword(accountID: a.id)
store.requestPassword(accountID: b.id)
#expect(store.authenticationAccount?.id == a.id)
store.dismissPasswordRequest()
#expect(store.authenticationAccount?.id == b.id)
store.dismissPasswordRequest()
#expect(store.authenticationAccount == nil)
store.requestPassword(accountID: UUID())
#expect(store.authenticationAccount == nil)
store.configureIdle(enabled: false)
#expect(store.idleStatuses.isEmpty)
}
}
+56
View File
@@ -0,0 +1,56 @@
import Foundation
import Testing
@testable import MikeMail
@MainActor
struct MailWorkflowTests {
@Test func searchScopesAndExpressions() {
let mail = MailMessage(id: "1", sender: "anna@example.com", recipient: "", subject: "Rechnung 2026",
date: Date(), body: "Betrag: 123,45 EUR", attachments: [])
#expect(MailSearch(text: "123,45", subject: false, sender: false, body: true, regularExpression: false).matches(mail))
#expect(!MailSearch(text: "123,45", subject: true, sender: true, body: false, regularExpression: false).matches(mail))
#expect(MailSearch(text: #"Rechnung \d{4}$"#, subject: true, sender: false, body: false, regularExpression: true).matches(mail))
#expect(MailSearch(text: "^ANNA@", subject: false, sender: true, body: false, regularExpression: true).matches(mail))
let invalid = MailSearch(text: "[", subject: true, sender: true, body: true, regularExpression: true)
#expect(invalid.error != nil)
#expect(!invalid.matches(mail))
}
@Test func dragSelectionAndMailboxNames() {
let ids: Set<String> = ["account:folder:1:2", "account:folder:1:9"]
#expect(Set(MailDrag.decode(MailDrag.encode(ids))) == ids)
#expect(MailDrag.decode("untrusted input").isEmpty)
for name in ["Büro & Reisen", "日本語", "ASCII", "📨"] {
#expect(IMAPClient.decodeMailbox(IMAPClient.encodeMailbox(name)) == name)
}
#expect(AttachmentList.safeName("../../file.txt") == "file.txt")
#expect(AttachmentList.safeName(#"C:\temp\file.pdf"#) == "file.pdf")
#expect(AttachmentList.safeName("..") == "Anhang")
}
@Test func unifiedFoldersAndAccountOrderSurviveReopen() async throws {
let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let archive = try MailArchive(root: root)
let a = MailAccount(name: "A"), b = MailAccount(name: "B")
let inboxA = MailFolder(accountID: a.id, path: "INBOX", name: "Posteingang")
let inboxB = MailFolder(accountID: b.id, path: "INBOX", name: "Posteingang")
let trash = MailFolder(accountID: a.id, path: "Deleted", name: "Gelöscht", specialUse: ["\\trash"])
try archive.saveConfiguration(ArchiveConfiguration(accounts: [a, b],
folders: [a.id: [inboxA, trash], b.id: [inboxB]], recipients: []))
let raw = Data("Subject: Same\r\n\r\nBody".utf8)
for folder in [inboxA, inboxB, trash] {
try archive.store(raw: raw, folderID: folder.id, validity: "1", uid: "1", flags: [])
}
let store = MailStore(archive: archive)
await store.load(folderID: "unified:inbox")
#expect(store.messages.count == 2)
#expect(Set(store.messages.map(\.id)).count == 2)
await store.load(folderID: "unified:trash")
#expect(store.messages.count == 1)
#expect(store.selectionContainsTrash(store.messages.map(\.id)))
store.reorderAccount(b.id, before: a.id)
let reopened = try MailArchive(root: root)
#expect(try reopened.configuration()?.accounts.map(\.id) == [b.id, a.id])
}
}
+39 -2
View File
@@ -6,7 +6,7 @@ final class MikeMailUITests: XCTestCase {
func testMailWindowAndCompose() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES"]
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "-checkOnLaunch", "NO", "-idleEnabled", "NO", "-selectedFolder", "", "-selectedMessage", ""]
app.launch()
app.activate()
XCTAssertTrue(app.buttons["Neue Nachricht"].waitForExistence(timeout: 5), app.debugDescription)
@@ -16,15 +16,52 @@ final class MikeMailUITests: XCTestCase {
XCTAssertFalse(app.buttons["Senden"].isEnabled)
XCTAssertTrue(app.buttons["Adressbuch"].exists)
XCTAssertTrue(app.textFields["recipientField"].exists)
XCTAssertTrue(app.textFields["composeCC"].exists)
XCTAssertTrue(app.textFields["composeBCC"].exists)
app.buttons["Abbrechen"].click()
app.buttons["Konten"].click()
XCTAssertTrue(app.secureTextFields.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["Speichern"].exists)
XCTAssertTrue(app.buttons["Neues Konto hinzufügen"].exists)
XCTAssertTrue(app.buttons["Konto löschen"].exists)
app.buttons["clearLocalMessages"].click()
XCTAssertTrue(app.buttons["Lokale Mails endgültig löschen"].waitForExistence(timeout: 5))
app.buttons["Abbrechen"].click()
XCTAssertTrue(app.buttons["clearLocalMessages"].exists)
app.buttons["Neues Konto hinzufügen"].click()
XCTAssertTrue(app.textFields["accountEmail"].waitForExistence(timeout: 5))
XCTAssertFalse(app.buttons["Speichern"].isEnabled)
XCTAssertTrue(app.buttons["Verbindung testen"].exists)
app.buttons["Schließen"].click()
XCTAssertTrue(app.buttons["Abrufen"].exists)
XCTAssertTrue(app.descendants(matching: .any)["Abrufen"].firstMatch.waitForExistence(timeout: 5), app.debugDescription)
}
@MainActor
func testMessageWindowAndActions() throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "-checkOnLaunch", "NO", "-idleEnabled", "NO",
"--ui-fixture", "-pollMinutes", "0", "-selectedFolder", "unified:inbox", "-selectedMessage", "", "-mailSearch", ""]
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.element(boundBy: 1)
XCTAssertTrue(table.waitForExistence(timeout: 5), app.debugDescription)
let row = table.descendants(matching: .outlineRow).firstMatch
XCTAssertTrue(row.waitForExistence(timeout: 5), app.debugDescription)
app.activate()
table.staticTexts["UI-Nachricht 1"].firstMatch.doubleClick()
let detail = app.windows["UI-Nachricht 1"]
XCTAssertTrue(detail.waitForExistence(timeout: 5))
let reply = detail.buttons["Antworten…"].firstMatch
XCTAssertTrue(reply.waitForExistence(timeout: 5), app.debugDescription)
XCTAssertGreaterThanOrEqual(app.windows.count, 2, app.debugDescription)
reply.click()
XCTAssertTrue(app.textFields["composeCC"].waitForExistence(timeout: 5))
app.buttons["Abbrechen"].click()
}
}