Alpha 2 Update
This commit is contained in:
committed by
Dr. Michael Kübler
parent
223bf746ef
commit
8e95cc3acd
@@ -9,6 +9,15 @@ struct AccountsView: View {
|
|||||||
@State private var newAccount: MailAccount?
|
@State private var newAccount: MailAccount?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var confirmingDelete = false
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -43,12 +52,26 @@ struct AccountsView: View {
|
|||||||
ContentUnavailableView("Kein Konto ausgewählt", systemImage: "person.crop.circle",
|
ContentUnavailableView("Kein Konto ausgewählt", systemImage: "person.crop.circle",
|
||||||
description: Text("Füge ein Konto hinzu oder wähle links ein Konto aus."))
|
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) }
|
if let error { Text(error).foregroundStyle(.red) }
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if testing { ProgressView().controlSize(.small) }
|
||||||
|
Button("Verbindung testen") { testConnection() }
|
||||||
|
.disabled(selection == nil || testing)
|
||||||
Button("Speichern") { save() }
|
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)
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,12 +95,43 @@ struct AccountsView: View {
|
|||||||
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
|
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
}.padding(16)
|
}.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 {
|
.onAppear {
|
||||||
drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) })
|
drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) })
|
||||||
selection = store.accounts.first?.id
|
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) {
|
.confirmationDialog("Konto aus MikeMail entfernen?", isPresented: $confirmingDelete) {
|
||||||
Button("Konto löschen", role: .destructive) { delete() }
|
Button("Konto löschen", role: .destructive) { delete() }
|
||||||
Button("Abbrechen", role: .cancel) {}
|
Button("Abbrechen", role: .cancel) {}
|
||||||
@@ -89,13 +143,38 @@ struct AccountsView: View {
|
|||||||
private func save() {
|
private func save() {
|
||||||
guard let id = selection, let account = drafts[id] else { return }
|
guard let id = selection, let account = drafts[id] else { return }
|
||||||
do {
|
do {
|
||||||
|
let isNew = !store.accounts.contains { $0.id == id }
|
||||||
try store.save(account: account, password: passwords[id] ?? "")
|
try store.save(account: account, password: passwords[id] ?? "")
|
||||||
passwords[id] = nil
|
passwords[id] = nil
|
||||||
if newAccount?.id == id { newAccount = 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
|
error = nil
|
||||||
} catch { self.error = error.localizedDescription }
|
} 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() {
|
private func delete() {
|
||||||
guard let id = selection else { return }
|
guard let id = selection else { return }
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -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
@@ -1,144 +1,507 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
let store: MailStore
|
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 selectedFolder: String?
|
||||||
@State private var selectedMessage: String?
|
@State private var selectedMessages: Set<String> = []
|
||||||
@State private var showingAccounts = false
|
@State private var showingAccounts = false
|
||||||
@State private var composing = false
|
@State private var 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 {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
AccountSidebar(accounts: store.accounts, folders: store.folders, selection: $selectedFolder)
|
AccountSidebar(store: store, selection: $selectedFolder)
|
||||||
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 380)
|
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 380)
|
||||||
} detail: {
|
} detail: {
|
||||||
VSplitView {
|
VStack(spacing: 0) {
|
||||||
MessageTable(messages: store.messages.filter {
|
if !search.isEmpty {
|
||||||
search.isEmpty || $0.subject.localizedCaseInsensitiveContains(search) || $0.sender.localizedCaseInsensitiveContains(search)
|
HStack {
|
||||||
}, selection: $selectedMessage)
|
Text("Suchen in:")
|
||||||
.frame(minHeight: 160, idealHeight: 300)
|
Toggle("Betreff", isOn: $searchSubject)
|
||||||
MessageReader(message: store.messages.first { $0.id == selectedMessage })
|
Toggle("Absender", isOn: $searchSender)
|
||||||
.frame(minHeight: 220, maxHeight: .infinity)
|
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) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
HStack {
|
HStack {
|
||||||
if store.isBusy { ProgressView().controlSize(.small) }
|
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)
|
Text(store.status).font(.caption).foregroundStyle(.secondary)
|
||||||
Spacer()
|
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)
|
Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}.padding(8).background(.bar)
|
||||||
.padding(8)
|
|
||||||
.background(.bar)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("MikeMail")
|
.navigationTitle("MikeMail")
|
||||||
.searchable(text: $search, prompt: "Absender oder Betreff suchen")
|
.searchable(text: $search, prompt: "Nachrichten durchsuchen")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup {
|
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) }
|
Task { await store.refresh(folderID: selectedFolder) }
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("Abrufen")
|
||||||
.disabled(store.isBusy)
|
.disabled(store.isBusy)
|
||||||
Button("Neue Nachricht", systemImage: "square.and.pencil") { composing = true }
|
Button("Neue Nachricht", systemImage: "square.and.pencil") { composing = true }
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command).disabled(store.accounts.isEmpty)
|
||||||
.disabled(store.accounts.isEmpty)
|
Button("Löschen", systemImage: "trash") { deleteSelection() }
|
||||||
|
.disabled(selectedMessages.isEmpty || store.isBusy)
|
||||||
Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true }
|
Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true }
|
||||||
|
SettingsLink { Label("Einstellungen", systemImage: "gearshape") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAccounts) { AccountsView(store: store) }
|
.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 } })) {
|
.alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
|
||||||
Button("OK") { store.error = nil }
|
Button("OK") { store.error = nil }
|
||||||
} message: { Text(store.error ?? "") }
|
} message: { Text(store.error ?? "") }
|
||||||
.onChange(of: selectedFolder) { _, folder in
|
.onChange(of: selectedFolder) { _, folder in
|
||||||
selectedMessage = nil
|
savedFolder = folder ?? ""
|
||||||
|
selectedMessages = []
|
||||||
Task { await store.load(folderID: folder) }
|
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)
|
.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 {
|
struct AccountSidebar: View {
|
||||||
let accounts: [MailAccount]
|
let store: MailStore
|
||||||
let folders: [UUID: [MailFolder]]
|
|
||||||
@Binding var selection: String?
|
@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 {
|
var body: some View {
|
||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
ForEach(accounts) { account in
|
if showInbox {
|
||||||
DisclosureGroup {
|
HStack { Label("Gemeinsamer Posteingang", systemImage: "tray.2"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:inbox")) }.tag("unified:inbox")
|
||||||
OutlineGroup(folders[account.id] ?? [], children: \.children) { folder in
|
}
|
||||||
Label(folder.name, systemImage: folder.icon)
|
if showTrash {
|
||||||
.tag(folder.id)
|
HStack { Label("Gemeinsamer Papierkorb", systemImage: "trash"); Spacer(); UnreadBadge(count: store.unreadCount(folderID: "unified:trash")) }.tag("unified:trash")
|
||||||
.selectionDisabled(!folder.selectable)
|
.contextMenu { Button("Papierkorb leeren…") { confirmTrash("unified:trash") }.disabled(store.isBusy) }
|
||||||
}
|
}
|
||||||
if folders[account.id] == nil {
|
ForEach(store.accounts) { account in
|
||||||
Text("Zum Laden „Abrufen“ wählen").font(.caption).foregroundStyle(.secondary)
|
AccountTree(store: store, account: account, selection: $selection,
|
||||||
.selectionDisabled()
|
create: { parent in beginCreate(account.id, parent: parent) },
|
||||||
}
|
delete: { folder in deleting = folder; confirmingFolder = true },
|
||||||
} label: {
|
empty: { confirmTrash($0.id) })
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Label(account.name.isEmpty ? account.email : account.name, systemImage: "person.crop.circle")
|
|
||||||
Text(account.email).font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.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 {
|
struct MessageTable: View {
|
||||||
let messages: [MailMessage]
|
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)]
|
@State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Table(messages.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) {
|
Table(of: MailMessage.self, selection: $selection, sortOrder: $sortOrder) {
|
||||||
TableColumn("Anhang") { message in
|
TableColumn("Anhang", value: \.attachmentCount) { message in
|
||||||
if !message.attachments.isEmpty {
|
if !message.attachments.isEmpty {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip").accessibilityLabel(message.attachments.count == 1 ? "Ein Anhang" : "\(message.attachments.count) Anhänge")
|
||||||
.accessibilityLabel("\(message.attachments.count) Anhänge")
|
|
||||||
}
|
}
|
||||||
}.width(55)
|
}.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
|
TableColumn("Datum", value: \.date) { message in
|
||||||
if message.date == .distantPast { Text("—") }
|
if message.date == .distantPast { Text("—") }
|
||||||
else { Text(message.date, format: .dateTime.day().month().year().hour().minute()) }
|
else { Text(message.date, format: .dateTime.day().month().year().hour().minute()).fontWeight(message.isRead ? .regular : .bold) }
|
||||||
}.width(min: 120, ideal: 150)
|
}.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 {
|
.overlay {
|
||||||
if messages.isEmpty {
|
if messages.isEmpty {
|
||||||
ContentUnavailableView("Keine Nachrichten", systemImage: "tray",
|
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] }
|
enum MailDrag {
|
||||||
var data: Data
|
static func encode(_ ids: Set<String>) -> String {
|
||||||
init(data: Data) { self.data = data }
|
let data = (try? JSONEncoder().encode(ids.sorted())) ?? Data()
|
||||||
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
|
return "mikemail-messages:" + data.base64EncodedString()
|
||||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
|
}
|
||||||
|
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 {
|
struct MessageReader: View {
|
||||||
let message: MailMessage?
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
if let message {
|
if let message {
|
||||||
@@ -147,52 +510,47 @@ struct MessageReader: View {
|
|||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text("Von: \(message.sender)")
|
Text("Von: \(message.sender)")
|
||||||
Text("An: \(message.recipient)").foregroundStyle(.secondary)
|
Text("An: \(message.recipient)").foregroundStyle(.secondary)
|
||||||
|
if !message.cc.isEmpty { Text("CC: \(message.cc)").foregroundStyle(.secondary) }
|
||||||
if message.date != .distantPast {
|
if message.date != .distantPast {
|
||||||
Text(message.date, format: .dateTime.day().month(.wide).year().hour().minute()).foregroundStyle(.secondary)
|
Text(message.date, format: .dateTime.day().month(.wide).year().hour().minute()).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}.textSelection(.enabled)
|
}.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()
|
Divider()
|
||||||
Text(message.body).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
Text(message.body).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
if !message.attachments.isEmpty {
|
||||||
.padding(24)
|
AttachmentList(attachments: message.attachments)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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 {
|
} else {
|
||||||
ContentUnavailableView("Nachricht auswählen", systemImage: "envelope.open",
|
ContentUnavailableView("Nachricht auswählen", systemImage: "envelope.open",
|
||||||
description: Text("Der Inhalt und die Anhänge erscheinen hier."))
|
description: Text("Der Inhalt und die Anhänge erscheinen hier."))
|
||||||
.frame(maxWidth: .infinity, minHeight: 220)
|
.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 {
|
struct ComposeView: View {
|
||||||
let store: MailStore
|
let store: MailStore
|
||||||
|
var draft: ComposeDraft? = nil
|
||||||
|
var defaultAccountID: UUID? = nil
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var accountID: UUID?
|
@State private var accountID: UUID?
|
||||||
@State private var recipient = ""
|
@State private var recipient = ""
|
||||||
|
@State private var cc = ""
|
||||||
|
@State private var bcc = ""
|
||||||
@State private var subject = ""
|
@State private var subject = ""
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var attachments: [MailAttachment] = []
|
@State private var attachments: [MailAttachment] = []
|
||||||
@State private var importing = false
|
@State private var importing = false
|
||||||
@State private var sending = false
|
@State private var sending = false
|
||||||
|
@State private var initialized = false
|
||||||
|
@State private var dropTargeted = false
|
||||||
|
@FocusState private var bodyFocused: Bool
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -203,17 +561,35 @@ struct ComposeView: View {
|
|||||||
ForEach(store.accounts) { account in Text(account.email).tag(Optional(account.id)) }
|
ForEach(store.accounts) { account in Text(account.email).tag(Optional(account.id)) }
|
||||||
}
|
}
|
||||||
RecipientField(recipient: $recipient, history: store.recipientHistory)
|
RecipientField(recipient: $recipient, history: store.recipientHistory)
|
||||||
|
TextField("CC", text: $cc).accessibilityIdentifier("composeCC")
|
||||||
|
TextField("BCC", text: $bcc).accessibilityIdentifier("composeBCC")
|
||||||
TextField("Betreff", text: $subject)
|
TextField("Betreff", text: $subject)
|
||||||
}
|
}
|
||||||
TextEditor(text: $text).font(.body).frame(minHeight: 230).border(.quaternary)
|
TextEditor(text: $text).font(.body).frame(minHeight: 180).border(.quaternary)
|
||||||
ForEach(attachments) { attachment in
|
.focused($bodyFocused)
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label(attachment.name, systemImage: "paperclip")
|
Label(attachments.count == 1 ? "Anhang – Dateien hier ablegen" : "Anhänge – Dateien hier ablegen", systemImage: "paperclip").font(.headline)
|
||||||
Spacer()
|
ScrollView {
|
||||||
Button("Entfernen", systemImage: "xmark.circle") { attachments.removeAll { $0.id == attachment.id } }
|
ForEach(attachments) { attachment in
|
||||||
.labelStyle(.iconOnly)
|
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) }
|
if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
|
||||||
HStack {
|
HStack {
|
||||||
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
@@ -225,33 +601,57 @@ struct ComposeView: View {
|
|||||||
error = nil
|
error = nil
|
||||||
Task {
|
Task {
|
||||||
do {
|
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()
|
dismiss()
|
||||||
} catch { self.error = error.localizedDescription }
|
} catch { self.error = error.localizedDescription }
|
||||||
sending = false
|
sending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.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)
|
.disabled(sending)
|
||||||
.interactiveDismissDisabled(sending)
|
.interactiveDismissDisabled(sending)
|
||||||
.onAppear { accountID = store.accounts.first?.id }
|
.onAppear {
|
||||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in
|
guard !initialized else { return }
|
||||||
do {
|
initialized = true
|
||||||
for url in try result.get() {
|
accountID = draft?.accountID ?? defaultAccountID ?? store.accounts.first?.id
|
||||||
let access = url.startAccessingSecurityScopedResource()
|
recipient = draft?.recipient ?? ""
|
||||||
defer { if access { url.stopAccessingSecurityScopedResource() } }
|
subject = draft?.subject ?? ""
|
||||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
|
text = draft?.body ?? ""
|
||||||
guard size + attachments.reduce(0, { $0 + $1.data.count }) <= 20_000_000 else {
|
attachments = draft?.attachments ?? []
|
||||||
throw MailError.message("Anhänge dürfen zusammen höchstens 20 MB groß sein.")
|
if draft?.isReply == true { bodyFocused = true }
|
||||||
}
|
|
||||||
attachments.append(MailAttachment(name: url.lastPathComponent, mimeType: "application/octet-stream", data: try Data(contentsOf: url)))
|
|
||||||
}
|
|
||||||
} catch { self.error = error.localizedDescription }
|
|
||||||
}
|
}
|
||||||
|
.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
@@ -10,6 +10,7 @@ final class IMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func close() { connection.close() }
|
func close() { connection.close() }
|
||||||
|
nonisolated func cancel() { connection.cancel() }
|
||||||
|
|
||||||
func login(account: MailAccount, password: String) async throws {
|
func login(account: MailAccount, password: String) async throws {
|
||||||
try await connection.open()
|
try await connection.open()
|
||||||
@@ -44,8 +45,10 @@ final class IMAPClient {
|
|||||||
let line = try await connection.readLine()
|
let line = try await connection.readLine()
|
||||||
if line.hasPrefix(tag + " ") {
|
if line.hasPrefix(tag + " ") {
|
||||||
guard line.uppercased().hasPrefix(tag + " OK") else {
|
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)")
|
throw MailError.message("IMAP: \(line)")
|
||||||
}
|
}
|
||||||
|
result.lines.append(line)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
if line.uppercased().hasPrefix("* BYE") { throw MailError.message("IMAP: Verbindung vom Server beendet.") }
|
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] {
|
func folders(accountID: UUID) async throws -> [MailFolder] {
|
||||||
let result = try await command("LIST \"\" \"*\"")
|
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
|
var literalIndex = 0
|
||||||
for line in result.lines where line.uppercased().hasPrefix("* LIST ") {
|
for line in result.lines where line.uppercased().hasPrefix("* LIST ") {
|
||||||
guard let close = line.firstIndex(of: ")") else { continue }
|
guard let close = line.firstIndex(of: ")") else { continue }
|
||||||
@@ -73,9 +76,9 @@ final class IMAPClient {
|
|||||||
path = String(decoding: result.literals[literalIndex], as: UTF8.self)
|
path = String(decoding: result.literals[literalIndex], as: UTF8.self)
|
||||||
literalIndex += 1
|
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] = [:]
|
var roots: [String: MailFolder] = [:]
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let remainder: String
|
let remainder: String
|
||||||
@@ -89,7 +92,7 @@ final class IMAPClient {
|
|||||||
let exact = entries.first { $0.path == path }
|
let exact = entries.first { $0.path == path }
|
||||||
roots[path] = MailFolder(accountID: accountID, path: path,
|
roots[path] = MailFolder(accountID: accountID, path: path,
|
||||||
name: path.uppercased() == "INBOX" ? "Posteingang" : Self.decodeMailbox(component),
|
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
|
return roots.values.map { folder in
|
||||||
var folder = folder
|
var folder = folder
|
||||||
@@ -169,8 +172,8 @@ final class IMAPClient {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func inventory(folder: MailFolder) async throws -> MailboxInventory {
|
func inventory(folder: MailFolder, writable: Bool = false) async throws -> MailboxInventory {
|
||||||
let examined = try await command("EXAMINE \(Self.quote(folder.path))")
|
let examined = try await command("\(writable ? "SELECT" : "EXAMINE") \(Self.quote(folder.path))")
|
||||||
let validity = try Self.uidValidity(from: examined.lines)
|
let validity = try Self.uidValidity(from: examined.lines)
|
||||||
let search = try await command("UID SEARCH ALL")
|
let search = try await command("UID SEARCH ALL")
|
||||||
guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else {
|
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 connection.send(message + Data("\r\n".utf8))
|
||||||
_ = try await response(tag: tag)
|
_ = 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
@@ -94,7 +94,7 @@ enum MIME {
|
|||||||
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
|
return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""),
|
||||||
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"),
|
recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"),
|
||||||
date: date ?? .distantPast, body: bodies.isEmpty ? htmlBodies.joined(separator: "\n") : bodies.joined(separator: "\n"),
|
date: date ?? .distantPast, body: bodies.isEmpty ? 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]) {
|
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
|
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 {
|
static func compose(from: String, to: [String], cc: [String] = [], bcc: [String] = [], subject: String, body: String, attachments: [MailAttachment]) throws -> Data {
|
||||||
guard validAddress(from), !to.isEmpty, to.allSatisfy(validAddress),
|
guard validAddress(from), !(to + cc + bcc).isEmpty, (to + cc + bcc).allSatisfy(validAddress),
|
||||||
!subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
|
!subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else {
|
||||||
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.")
|
throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.")
|
||||||
}
|
}
|
||||||
@@ -159,6 +159,7 @@ enum MIME {
|
|||||||
data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed])
|
data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed])
|
||||||
}
|
}
|
||||||
var result = "From: \(from)\r\nTo: \(to.joined(separator: ", "))\r\n"
|
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 += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n"
|
||||||
result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n"
|
result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n"
|
||||||
result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
|
result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n"
|
||||||
|
|||||||
@@ -132,6 +132,34 @@ final class MailArchive {
|
|||||||
} catch { context.rollback(); throw error }
|
} 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.
|
/// Reconcile only after a complete server inventory. Keep removed objects for future backups.
|
||||||
func reconcile(folderID: String, inventory: MailboxInventory) throws {
|
func reconcile(folderID: String, inventory: MailboxInventory) throws {
|
||||||
do {
|
do {
|
||||||
@@ -152,6 +180,37 @@ final class MailArchive {
|
|||||||
}.sorted { $0.date > $1.date }
|
}.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") }
|
private func objectURL(_ hash: String) -> URL { root.appending(path: "Objects/\(hash).eml") }
|
||||||
|
|
||||||
static func digest(_ data: Data) -> String {
|
static func digest(_ data: Data) -> String {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -21,6 +21,13 @@ struct MailFolder: Identifiable, Hashable, Codable, Sendable {
|
|||||||
let name: String
|
let name: String
|
||||||
var selectable = true
|
var selectable = true
|
||||||
var children: [MailFolder]?
|
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 {
|
var icon: String {
|
||||||
switch path.lowercased() {
|
switch path.lowercased() {
|
||||||
@@ -49,12 +56,16 @@ struct MailMessage: Identifiable, Sendable {
|
|||||||
let body: String
|
let body: String
|
||||||
let attachments: [MailAttachment]
|
let attachments: [MailAttachment]
|
||||||
var isRead = false
|
var isRead = false
|
||||||
|
var cc = ""
|
||||||
|
var replyTo = ""
|
||||||
|
var attachmentCount: Int { attachments.count }
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MailError: LocalizedError {
|
enum MailError: LocalizedError {
|
||||||
case message(String)
|
case message(String)
|
||||||
|
case authentication(String)
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self { case .message(let text): text }
|
switch self { case .message(let text), .authentication(let text): text }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,28 @@ int mail_smtp_send(const char *host, unsigned short port, const char *username,
|
|||||||
curl_easy_cleanup(curl);
|
curl_easy_cleanup(curl);
|
||||||
return (int)result;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 char *password, const char *sender, const char *recipients,
|
||||||
const unsigned char *message, size_t length,
|
const unsigned char *message, size_t length,
|
||||||
char *error, size_t errorCapacity);
|
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
|
#endif
|
||||||
|
|||||||
@@ -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
@@ -8,16 +8,33 @@ final class MailStore {
|
|||||||
var folders: [UUID: [MailFolder]] = [:]
|
var folders: [UUID: [MailFolder]] = [:]
|
||||||
var messages: [MailMessage] = []
|
var messages: [MailMessage] = []
|
||||||
var isBusy = false
|
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 totalMessages = 0
|
||||||
var status = "Lokales Archiv bereit."
|
var status = "Lokales Archiv bereit."
|
||||||
var error: String?
|
var error: String?
|
||||||
private var archive: MailArchive?
|
private var archive: MailArchive?
|
||||||
private var selectedFolderID: String?
|
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) {
|
init(archive: MailArchive? = nil) {
|
||||||
do {
|
do {
|
||||||
let storage = try archive ?? MailArchive()
|
let storage = try archive ?? MailArchive()
|
||||||
self.archive = storage
|
self.archive = storage
|
||||||
|
unreadCounts = try storage.unreadCounts()
|
||||||
if let configuration = try storage.configuration() {
|
if let configuration = try storage.configuration() {
|
||||||
accounts = configuration.accounts
|
accounts = configuration.accounts
|
||||||
folders = configuration.folders
|
folders = configuration.folders
|
||||||
@@ -51,6 +68,11 @@ final class MailStore {
|
|||||||
account.imapPort > 0, account.smtpPort > 0 else {
|
account.imapPort > 0, account.smtpPort > 0 else {
|
||||||
throw MailError.message("Bitte E-Mail-Adresse, Benutzername, Server und Ports prüfen.")
|
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()
|
let archive = try storage()
|
||||||
if let old = accounts.first(where: { $0.id == account.id }),
|
if let old = accounts.first(where: { $0.id == account.id }),
|
||||||
old.host != account.host || old.username != account.username || old.email != account.email {
|
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) }
|
if !password.isEmpty { try PasswordStore.save(password, for: account.id) }
|
||||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory))
|
try archive.saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory))
|
||||||
accounts = updated
|
accounts = updated
|
||||||
|
validatedAccounts[account.id] = nil
|
||||||
|
validatedPasswords[account.id] = nil
|
||||||
status = "Konto lokal gespeichert."
|
status = "Konto lokal gespeichert."
|
||||||
|
configureIdle(enabled: idleEnabled)
|
||||||
|
idleMonitor.restart(accountID: account.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAccount(id: UUID) throws {
|
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 archive = try storage()
|
||||||
let remaining = accounts.filter { $0.id != id }
|
let remaining = accounts.filter { $0.id != id }
|
||||||
var remainingFolders = folders
|
var remainingFolders = folders
|
||||||
remainingFolders[id] = nil
|
remainingFolders[id] = nil
|
||||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory))
|
try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory))
|
||||||
accounts = remaining
|
accounts = remaining
|
||||||
|
configureIdle(enabled: idleEnabled)
|
||||||
folders = remainingFolders
|
folders = remainingFolders
|
||||||
messages = []
|
messages = []
|
||||||
totalMessages = 0
|
totalMessages = 0
|
||||||
@@ -80,28 +107,73 @@ final class MailStore {
|
|||||||
status = "Konto entfernt; archivierte Originalmails und Serverdaten bleiben erhalten."
|
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 ?? []) }
|
items.flatMap { [$0] + allFolders($0.children ?? []) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh(folderID: String?) async {
|
func refresh(folderID: String?, accountID: UUID? = nil) async {
|
||||||
guard !isBusy else { return }
|
guard !isBusy else { return }
|
||||||
isBusy = true
|
isBusy = true
|
||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
var failures: [String] = []
|
var failures: [String] = []
|
||||||
do {
|
do {
|
||||||
let archive = try storage()
|
let archive = try storage()
|
||||||
for account in accounts {
|
for account in accounts where accountID == nil || account.id == accountID {
|
||||||
do {
|
do {
|
||||||
let client = try IMAPClient(account: account)
|
let client = try IMAPClient(account: account)
|
||||||
defer { client.close() }
|
defer { client.close() }
|
||||||
status = "\(account.email): Verbindung wird aufgebaut …"
|
status = "\(account.email): Verbindung wird aufgebaut …"
|
||||||
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
try await client.login(account: account, password: PasswordStore.password(for: account.id))
|
||||||
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.
|
// Keep the previous listing if saving the new configuration fails.
|
||||||
var updated = folders
|
var updated = folders
|
||||||
updated[account.id] = discovered
|
updated[account.id] = discovered
|
||||||
try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory))
|
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
|
folders = updated
|
||||||
for folder in allFolders(discovered) where folder.selectable {
|
for folder in allFolders(discovered) where folder.selectable {
|
||||||
do {
|
do {
|
||||||
@@ -120,20 +192,31 @@ final class MailStore {
|
|||||||
if selectedFolderID == folder.id { try display(folderID: folder.id) }
|
if selectedFolderID == folder.id { try display(folderID: folder.id) }
|
||||||
} catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") }
|
} 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) }
|
if let selectedFolderID { try display(folderID: selectedFolderID) }
|
||||||
} catch { failures.append(error.localizedDescription) }
|
} catch { failures.append(error.localizedDescription) }
|
||||||
if failures.isEmpty {
|
if failures.isEmpty {
|
||||||
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
|
status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar."
|
||||||
} else {
|
} 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."
|
status = "Abgleich unvollständig. Bereits gespeicherte Mails bleiben offline verfügbar."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func display(folderID: String) throws {
|
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
|
totalMessages = messages.count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,29 +229,327 @@ final class MailStore {
|
|||||||
} catch { self.error = error.localizedDescription }
|
} 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 {
|
guard let account = accounts.first(where: { $0.id == accountID }) else {
|
||||||
throw MailError.message("Bitte ein Absenderkonto auswählen.")
|
throw MailError.message("Bitte ein Absenderkonto auswählen.")
|
||||||
}
|
}
|
||||||
|
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 archive = try storage()
|
||||||
let addresses = recipients.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
let addresses = Self.addresses(recipients)
|
||||||
let data = try MIME.compose(from: account.email, to: addresses, subject: subject, body: body, attachments: attachments)
|
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 localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien")
|
||||||
let localID = UUID().uuidString
|
let localID = UUID().uuidString
|
||||||
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message.
|
// Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message.
|
||||||
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["Pending"])
|
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 {
|
do {
|
||||||
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["\\Seen", "Sent"])
|
try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["\\Seen", "SentCopyPending"])
|
||||||
for address in addresses {
|
for address in envelope {
|
||||||
recipientHistory.removeAll { $0.caseInsensitiveCompare(address) == .orderedSame }
|
recipientHistory.removeAll { $0.caseInsensitiveCompare(address) == .orderedSame }
|
||||||
recipientHistory.insert(address, at: 0)
|
recipientHistory.insert(address, at: 0)
|
||||||
}
|
}
|
||||||
try persist()
|
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 {
|
} catch {
|
||||||
// SMTP already succeeded: do not offer an accidental resend by throwing here.
|
// SMTP already succeeded: do not offer an accidental resend by throwing here.
|
||||||
self.error = "Nachricht wurde versendet; Archivstatus konnte nicht aktualisiert werden: \(error.localizedDescription)"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ final class TLSConnection {
|
|||||||
|
|
||||||
func close() { deadline?.cancel(); connection.cancel() }
|
func close() { deadline?.cancel(); connection.cancel() }
|
||||||
|
|
||||||
|
nonisolated func cancel() { connection.cancel() }
|
||||||
|
|
||||||
func send(_ data: Data) async throws {
|
func send(_ data: Data) async throws {
|
||||||
let timeout = Task { [connection] in
|
let timeout = Task { [connection] in
|
||||||
try? await Task.sleep(for: .seconds(30))
|
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)) }
|
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
|
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() }
|
if !Task.isCancelled { connection.cancel() }
|
||||||
}
|
}
|
||||||
defer { timeout.cancel() }
|
defer { timeout.cancel() }
|
||||||
@@ -82,7 +85,7 @@ final class TLSConnection {
|
|||||||
buffer.append(data)
|
buffer.append(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLine() async throws -> String {
|
func readLine(timeoutSeconds: Int? = 30) async throws -> String {
|
||||||
while true {
|
while true {
|
||||||
if let range = buffer.range(of: Data("\r\n".utf8)) {
|
if let range = buffer.range(of: Data("\r\n".utf8)) {
|
||||||
let data = buffer[..<range.lowerBound]
|
let data = buffer[..<range.lowerBound]
|
||||||
@@ -91,7 +94,7 @@ final class TLSConnection {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
guard buffer.count < 1_000_000 else { throw MailError.message("Serverantwort ist zu lang.") }
|
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 {
|
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 {
|
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 {
|
guard MIME.validAddress(account.email), !recipients.isEmpty, recipients.allSatisfy(MIME.validAddress) else {
|
||||||
throw MailError.message("Ungültige E-Mail-Adresse.")
|
throw MailError.message("Ungültige E-Mail-Adresse.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,21 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct MikeMailApp: App {
|
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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(store: store)
|
ContentView(store: store)
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1200, height: 800)
|
.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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,46 @@ struct MailArchiveTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: base.appending(path: "Rejected").path))
|
#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 {
|
@Test func imapInventoryParsing() throws {
|
||||||
#expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123")
|
#expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123")
|
||||||
#expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) }
|
#expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ final class MikeMailUITests: XCTestCase {
|
|||||||
func testMailWindowAndCompose() throws {
|
func testMailWindowAndCompose() throws {
|
||||||
continueAfterFailure = false
|
continueAfterFailure = false
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES"]
|
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES", "-checkOnLaunch", "NO", "-idleEnabled", "NO", "-selectedFolder", "", "-selectedMessage", ""]
|
||||||
app.launch()
|
app.launch()
|
||||||
app.activate()
|
app.activate()
|
||||||
XCTAssertTrue(app.buttons["Neue Nachricht"].waitForExistence(timeout: 5), app.debugDescription)
|
XCTAssertTrue(app.buttons["Neue Nachricht"].waitForExistence(timeout: 5), app.debugDescription)
|
||||||
@@ -16,15 +16,52 @@ final class MikeMailUITests: XCTestCase {
|
|||||||
XCTAssertFalse(app.buttons["Senden"].isEnabled)
|
XCTAssertFalse(app.buttons["Senden"].isEnabled)
|
||||||
XCTAssertTrue(app.buttons["Adressbuch"].exists)
|
XCTAssertTrue(app.buttons["Adressbuch"].exists)
|
||||||
XCTAssertTrue(app.textFields["recipientField"].exists)
|
XCTAssertTrue(app.textFields["recipientField"].exists)
|
||||||
|
XCTAssertTrue(app.textFields["composeCC"].exists)
|
||||||
|
XCTAssertTrue(app.textFields["composeBCC"].exists)
|
||||||
app.buttons["Abbrechen"].click()
|
app.buttons["Abbrechen"].click()
|
||||||
app.buttons["Konten"].click()
|
app.buttons["Konten"].click()
|
||||||
XCTAssertTrue(app.secureTextFields.firstMatch.waitForExistence(timeout: 5))
|
XCTAssertTrue(app.secureTextFields.firstMatch.waitForExistence(timeout: 5))
|
||||||
XCTAssertTrue(app.buttons["Speichern"].exists)
|
XCTAssertTrue(app.buttons["Speichern"].exists)
|
||||||
XCTAssertTrue(app.buttons["Neues Konto hinzufügen"].exists)
|
XCTAssertTrue(app.buttons["Neues Konto hinzufügen"].exists)
|
||||||
XCTAssertTrue(app.buttons["Konto löschen"].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()
|
app.buttons["Neues Konto hinzufügen"].click()
|
||||||
XCTAssertTrue(app.textFields["accountEmail"].waitForExistence(timeout: 5))
|
XCTAssertTrue(app.textFields["accountEmail"].waitForExistence(timeout: 5))
|
||||||
|
XCTAssertFalse(app.buttons["Speichern"].isEnabled)
|
||||||
|
XCTAssertTrue(app.buttons["Verbindung testen"].exists)
|
||||||
app.buttons["Schließen"].click()
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user