diff --git a/ARCHIVE-FORMAT.txt b/ARCHIVE-FORMAT.txt new file mode 100644 index 0000000..636d8f2 --- /dev/null +++ b/ARCHIVE-FORMAT.txt @@ -0,0 +1,54 @@ +MikeMail local archive, format 1 + +Location: Application Support/MikeMail/Archive inside the macOS app sandbox. +Mail.sqlite is the SwiftData index, with a versioned schema/migration plan. +Objects/.eml contains immutable original RFC 5322/MIME bytes, including +all attachments. Paths are relative and content-addressed; user attachment +filenames are never used as filesystem paths. Identical messages share an object. +format.json identifies the archive format. + +Account settings, folder trees and recipient history are stored in the database. +Existing UserDefaults settings migrate on first archive creation. Passwords stay +in Keychain and are never included in snapshots. A restore to another Mac needs +passwords entered again. + +Refresh inventories all selectable folders and compares UIDVALIDITY/UID/FLAGS. +Only missing message bodies download. Removed messages and old UID generations +are hidden from the current folder but retained in the archive. New bodies are +staged before a complete inventory is reconciled. Failed downloads can resume. +Offline folder selection performs no network access. Server-side read flags are +stored; this change does not add local read/delete/move editing operations. +Initial synchronization downloads all messages, subject to the transport's 40 MB +per-message limit; failed folders are reported and remain incomplete. + +Future backup UI should call MailArchive.exportSnapshot(to:) after synchronization +finishes, rather than copying the live SQLite file (which may have a WAL). +The logical snapshot includes manifest.json with formatVersion, date, complete +configuration, every message record (including hidden ones), and its EML objects. +Export serializes with archive mutations, verifies SHA-256, writes a staging +directory, then renames it. No absolute paths or credentials are exported. +Unreferenced orphan objects from interrupted writes are not exported. + +MailArchive.restoreSnapshot(from:to:) validates version, unique record IDs, file +references and hashes, then reconstructs a NEW database. It refuses an existing +destination. A future restore UI must first stage/validate, stop all sync and +close the current archive, then switch archives while retaining the previous one +for rollback. Do not replace an open SQLite store. Restore is local only and +does not upload or delete server mail. + +Backup/restore engine is implemented and tested; user-facing backup/restore +buttons, scheduling, compression and encryption are intentionally not yet added. +A backup contains only mail already downloaded, not inaccessible server mail. + +App icon: generated using built-in image_gen, resized with macOS sips. +Asset: MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-1024.png +Generation prompt: +Use case: stylized-concept. Asset: production macOS application icon for MikeMail, +square 1024x1024. Create a beautiful premium blue 3D mail app icon: a sculpted +pearly white envelope with crisp folded triangular flap, softly rounded edges, +hovering subtly above a rich cobalt-to-azure blue rounded-square tile, depth and +bevel, restrained luminous cyan rim highlights, soft studio reflections and +ambient occlusion. Straight-on view, balanced centered composition, clear simple +silhouette readable at 16px. Blue tile occupies about 88% of canvas, transparent +outside rounded tile with soft small shadow. No text, letters, badges, watermark, +extra objects or presentation mockup. Actual standalone icon artwork. diff --git a/MikeMail.xcodeproj/project.xcproj b/MikeMail.xcodeproj/project.xcproj index 8ada43e..8c97254 100644 --- a/MikeMail.xcodeproj/project.xcproj +++ b/MikeMail.xcodeproj/project.xcproj @@ -44,6 +44,7 @@ "DEVELOPMENT_TEAM": "GQR972L9NJ", "ENABLE_APP_SANDBOX": "YES", "ENABLE_HARDENED_RUNTIME": "YES", + "ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES", "ENABLE_PREVIEWS": "YES", "ENABLE_USER_SELECTED_FILES": "readonly", "GENERATE_INFOPLIST_FILE": "YES", @@ -53,6 +54,10 @@ "@executable_path/../Frameworks", ], "MARKETING_VERSION": "1.0", + "OTHER_LDFLAGS": [ + "$(inherited)", + "-lcurl", + ], "PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMail", "PRODUCT_NAME": "$(TARGET_NAME)", "REGISTER_APP_GROUPS": "YES", @@ -60,6 +65,7 @@ "SWIFT_APPROACHABLE_CONCURRENCY": "YES", "SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor", "SWIFT_EMIT_LOC_STRINGS": "YES", + "SWIFT_OBJC_BRIDGING_HEADER": "MikeMail/MailSMTP.h", "SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES", "SWIFT_VERSION": "5.0", }, diff --git a/MikeMail/AccountsView.swift b/MikeMail/AccountsView.swift new file mode 100644 index 0000000..3d48d13 --- /dev/null +++ b/MikeMail/AccountsView.swift @@ -0,0 +1,126 @@ +import SwiftUI + +struct AccountsView: View { + let store: MailStore + @Environment(\.dismiss) private var dismiss + @State private var selection: UUID? + @State private var drafts: [UUID: MailAccount] = [:] + @State private var passwords: [UUID: String] = [:] + @State private var newAccount: MailAccount? + @State private var error: String? + @State private var confirmingDelete = false + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + List(selection: $selection) { + ForEach(store.accounts) { account in + VStack(alignment: .leading) { + Text(account.name.isEmpty ? account.email : account.name) + Text(account.email).font(.caption).foregroundStyle(.secondary) + }.tag(account.id) + } + if let newAccount { + Label("Neues Konto", systemImage: "plus.circle").tag(newAccount.id) + } + } + .frame(width: 230) + .accessibilityIdentifier("accountList") + Divider() + VStack(alignment: .leading, spacing: 18) { + Text("Konteneinstellungen").font(.title2).bold() + if let id = selection, let account = drafts[id] { + AccountSettingsForm(account: Binding( + get: { drafts[id] ?? account }, + set: { drafts[id] = $0 } + ), password: Binding( + get: { passwords[id] ?? "" }, + set: { passwords[id] = $0 } + )) + Text("Ein leeres Passwortfeld behält das gespeicherte Passwort bei. Änderungen mit „Speichern“ übernehmen.") + .font(.caption).foregroundStyle(.secondary) + } else { + ContentUnavailableView("Kein Konto ausgewählt", systemImage: "person.crop.circle", + description: Text("Füge ein Konto hinzu oder wähle links ein Konto aus.")) + } + if let error { Text(error).foregroundStyle(.red) } + Spacer() + HStack { + Spacer() + Button("Speichern") { save() } + .disabled(selection == nil) + .keyboardShortcut(.defaultAction) + } + } + .padding(24).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + Divider() + HStack { + Button("Neues Konto hinzufügen", systemImage: "plus") { + if let newAccount { selection = newAccount.id } + else { + let account = MailAccount() + newAccount = account + drafts[account.id] = account + selection = account.id + } + error = nil + } + Button("Konto löschen", systemImage: "minus", role: .destructive) { confirmingDelete = true } + .disabled(selection == nil || store.isBusy) + Spacer() + Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction) + }.padding(16) + } + .frame(width: 850, height: 480) + .onAppear { + drafts = Dictionary(uniqueKeysWithValues: store.accounts.map { ($0.id, $0) }) + selection = store.accounts.first?.id + } + .onChange(of: selection) { _, _ in error = nil } + .confirmationDialog("Konto aus MikeMail entfernen?", isPresented: $confirmingDelete) { + Button("Konto löschen", role: .destructive) { delete() } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Die lokalen Kontoeinstellungen und das gespeicherte Passwort werden entfernt. Mails auf dem Server bleiben erhalten.") + } + } + + private func save() { + guard let id = selection, let account = drafts[id] else { return } + do { + try store.save(account: account, password: passwords[id] ?? "") + passwords[id] = nil + if newAccount?.id == id { newAccount = nil } + error = nil + } catch { self.error = error.localizedDescription } + } + + private func delete() { + guard let id = selection else { return } + do { + if newAccount?.id == id { newAccount = nil } + else { try store.deleteAccount(id: id) } + drafts[id] = nil + passwords[id] = nil + selection = store.accounts.first?.id ?? newAccount?.id + } catch { self.error = error.localizedDescription } + } +} + +struct AccountSettingsForm: View { + @Binding var account: MailAccount + @Binding var password: String + + var body: some View { + Form { + TextField("Name", text: $account.name) + TextField("E-Mail-Adresse", text: $account.email).accessibilityIdentifier("accountEmail") + TextField("Benutzername", text: $account.username) + TextField("Server", text: $account.host) + TextField("IMAP-Port (TLS)", value: $account.imapPort, format: .number.grouping(.never)) + TextField("SMTP-Port (587 STARTTLS / 465 TLS)", value: $account.smtpPort, format: .number.grouping(.never)) + SecureField("Passwort", text: $password) + } + } +} diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/Contents.json b/MikeMail/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db4..5fba213 100644 --- a/MikeMail/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/MikeMail/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,58 +1,68 @@ { - "images" : [ + "images": [ { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" + "idiom": "mac", + "scale": "1x", + "size": "16x16", + "filename": "MikeMail-16.png" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" + "idiom": "mac", + "scale": "2x", + "size": "16x16", + "filename": "MikeMail-32.png" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" + "idiom": "mac", + "scale": "1x", + "size": "32x32", + "filename": "MikeMail-32.png" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" + "idiom": "mac", + "scale": "2x", + "size": "32x32", + "filename": "MikeMail-64.png" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" + "idiom": "mac", + "scale": "1x", + "size": "128x128", + "filename": "MikeMail-128.png" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" + "idiom": "mac", + "scale": "2x", + "size": "128x128", + "filename": "MikeMail-256.png" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" + "idiom": "mac", + "scale": "1x", + "size": "256x256", + "filename": "MikeMail-256.png" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" + "idiom": "mac", + "scale": "2x", + "size": "256x256", + "filename": "MikeMail-512.png" }, { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" + "idiom": "mac", + "scale": "1x", + "size": "512x512", + "filename": "MikeMail-512.png" }, { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" + "idiom": "mac", + "scale": "2x", + "size": "512x512", + "filename": "MikeMail-1024.png" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-1024.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-1024.png new file mode 100644 index 0000000..41ef2d3 Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-1024.png differ diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-128.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-128.png new file mode 100644 index 0000000..6ad6ea0 Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-128.png differ diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-16.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-16.png new file mode 100644 index 0000000..26053c1 Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-16.png differ diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-256.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-256.png new file mode 100644 index 0000000..2f52ad4 Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-256.png differ diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-32.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-32.png new file mode 100644 index 0000000..6d1a891 Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-32.png differ diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-512.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-512.png new file mode 100644 index 0000000..816910b Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-512.png differ diff --git a/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-64.png b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-64.png new file mode 100644 index 0000000..29e0b41 Binary files /dev/null and b/MikeMail/Assets.xcassets/AppIcon.appiconset/MikeMail-64.png differ diff --git a/MikeMail/ContentView.swift b/MikeMail/ContentView.swift index d2781b4..b79960c 100644 --- a/MikeMail/ContentView.swift +++ b/MikeMail/ContentView.swift @@ -1,59 +1,258 @@ -// -// ContentView.swift -// MikeMail -// -// Created by Dr. med. Michael Kübler on 06.11.26. -// - import SwiftUI -import SwiftData +import UniformTypeIdentifiers struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] + let store: MailStore + @State private var selectedFolder: String? + @State private var selectedMessage: String? + @State private var showingAccounts = false + @State private var composing = false + @State private var search = "" var body: some View { NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } - .navigationSplitViewColumnWidth(min: 180, ideal: 200) - .toolbar { - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } + AccountSidebar(accounts: store.accounts, folders: store.folders, selection: $selectedFolder) + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 380) } detail: { - Text("Select an item") + VSplitView { + MessageTable(messages: store.messages.filter { + search.isEmpty || $0.subject.localizedCaseInsensitiveContains(search) || $0.sender.localizedCaseInsensitiveContains(search) + }, selection: $selectedMessage) + .frame(minHeight: 160, idealHeight: 300) + MessageReader(message: store.messages.first { $0.id == selectedMessage }) + .frame(minHeight: 220, maxHeight: .infinity) + } + .safeAreaInset(edge: .bottom) { + HStack { + if store.isBusy { ProgressView().controlSize(.small) } + Text(store.status).font(.caption).foregroundStyle(.secondary) + Spacer() + if store.messages.count < store.totalMessages { + Button("Weitere laden") { + Task { await store.load(folderID: selectedFolder, more: true) } + }.disabled(store.isBusy) + } + Text("\(store.messages.count) Nachrichten").font(.caption).foregroundStyle(.secondary) + } + .padding(8) + .background(.bar) + } } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) + .navigationTitle("MikeMail") + .searchable(text: $search, prompt: "Absender oder Betreff suchen") + .toolbar { + ToolbarItemGroup { + Button("Abrufen", systemImage: "arrow.clockwise") { + Task { await store.refresh(folderID: selectedFolder) } + } + .disabled(store.isBusy) + Button("Neue Nachricht", systemImage: "square.and.pencil") { composing = true } + .keyboardShortcut("n", modifiers: .command) + .disabled(store.accounts.isEmpty) + Button("Konten", systemImage: "person.crop.circle") { showingAccounts = true } + } } + .sheet(isPresented: $showingAccounts) { AccountsView(store: store) } + .sheet(isPresented: $composing) { ComposeView(store: store) } + .alert("MikeMail", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) { + Button("OK") { store.error = nil } + } message: { Text(store.error ?? "") } + .onChange(of: selectedFolder) { _, folder in + selectedMessage = nil + Task { await store.load(folderID: folder) } + } + .frame(minWidth: 850, minHeight: 550) } +} - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) +struct AccountSidebar: View { + let accounts: [MailAccount] + let folders: [UUID: [MailFolder]] + @Binding var selection: String? + + var body: some View { + List(selection: $selection) { + ForEach(accounts) { account in + DisclosureGroup { + OutlineGroup(folders[account.id] ?? [], children: \.children) { folder in + Label(folder.name, systemImage: folder.icon) + .tag(folder.id) + .selectionDisabled(!folder.selectable) + } + if folders[account.id] == nil { + Text("Zum Laden „Abrufen“ wählen").font(.caption).foregroundStyle(.secondary) + .selectionDisabled() + } + } label: { + VStack(alignment: .leading) { + Label(account.name.isEmpty ? account.email : account.name, systemImage: "person.crop.circle") + Text(account.email).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .listStyle(.sidebar) + } +} + +struct MessageTable: View { + let messages: [MailMessage] + @Binding var selection: String? + @State private var sortOrder = [KeyPathComparator(\MailMessage.date, order: .reverse)] + + var body: some View { + Table(messages.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) { + TableColumn("Anhang") { message in + if !message.attachments.isEmpty { + Image(systemName: "paperclip") + .accessibilityLabel("\(message.attachments.count) Anhänge") + } + }.width(55) + TableColumn("Absender", value: \.sender).width(min: 110, ideal: 160) + TableColumn("Datum", value: \.date) { message in + if message.date == .distantPast { Text("—") } + else { Text(message.date, format: .dateTime.day().month().year().hour().minute()) } + }.width(min: 120, ideal: 150) + TableColumn("Betreff", value: \.subject).width(min: 160, ideal: 280) + } + .overlay { + if messages.isEmpty { + ContentUnavailableView("Keine Nachrichten", systemImage: "tray", + description: Text("Wähle einen Ordner und rufe deine E-Mails ab.")) } } } } -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) +struct AttachmentDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data] } + var data: Data + init(data: Data) { self.data = data } + init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } } + +struct MessageReader: View { + let message: MailMessage? + @State private var exportDocument: AttachmentDocument? + @State private var exportName = "Anhang" + @State private var exporting = false + @State private var error: String? + + var body: some View { + ScrollView { + if let message { + VStack(alignment: .leading, spacing: 16) { + Text(message.subject).font(.title2).bold().textSelection(.enabled) + VStack(alignment: .leading, spacing: 5) { + Text("Von: \(message.sender)") + Text("An: \(message.recipient)").foregroundStyle(.secondary) + if message.date != .distantPast { + Text(message.date, format: .dateTime.day().month(.wide).year().hour().minute()).foregroundStyle(.secondary) + } + }.textSelection(.enabled) + if !message.attachments.isEmpty { + ForEach(message.attachments) { attachment in + Button { + exportDocument = AttachmentDocument(data: attachment.data) + exportName = (attachment.name as NSString).lastPathComponent + exporting = true + } label: { + Label("\(attachment.name) · \(attachment.data.count.formatted(.byteCount(style: .file)))", systemImage: "paperclip") + } + .help("Anhang speichern") + } + } + Divider() + Text(message.body).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ContentUnavailableView("Nachricht auswählen", systemImage: "envelope.open", + description: Text("Der Inhalt und die Anhänge erscheinen hier.")) + .frame(maxWidth: .infinity, minHeight: 220) + } + } + .fileExporter(isPresented: $exporting, document: exportDocument, contentType: .data, defaultFilename: exportName) { result in + if case .failure(let failure) = result { error = failure.localizedDescription } + } + .alert("Anhang speichern", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK") { error = nil } + } message: { Text(error ?? "") } + } +} + +struct ComposeView: View { + let store: MailStore + @Environment(\.dismiss) private var dismiss + @State private var accountID: UUID? + @State private var recipient = "" + @State private var subject = "" + @State private var text = "" + @State private var attachments: [MailAttachment] = [] + @State private var importing = false + @State private var sending = false + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Neue Nachricht").font(.title2).bold() + Form { + Picker("Von", selection: $accountID) { + ForEach(store.accounts) { account in Text(account.email).tag(Optional(account.id)) } + } + RecipientField(recipient: $recipient, history: store.recipientHistory) + TextField("Betreff", text: $subject) + } + TextEditor(text: $text).font(.body).frame(minHeight: 230).border(.quaternary) + ForEach(attachments) { attachment in + HStack { + Label(attachment.name, systemImage: "paperclip") + Spacer() + Button("Entfernen", systemImage: "xmark.circle") { attachments.removeAll { $0.id == attachment.id } } + .labelStyle(.iconOnly) + } + } + if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) } + HStack { + Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction) + Button("Anhang hinzufügen", systemImage: "paperclip") { importing = true } + Spacer() + if sending { ProgressView().controlSize(.small) } + Button("Senden", systemImage: "paperplane") { + sending = true + error = nil + Task { + do { + try await store.send(accountID: accountID, recipients: recipient, subject: subject, body: text, attachments: attachments) + dismiss() + } catch { self.error = error.localizedDescription } + sending = false + } + } + .buttonStyle(.borderedProminent) + .disabled(recipient.trimmingCharacters(in: .whitespaces).isEmpty || accountID == nil) + } + } + .padding(24).frame(width: 680, height: 620) + .disabled(sending) + .interactiveDismissDisabled(sending) + .onAppear { accountID = store.accounts.first?.id } + .fileImporter(isPresented: $importing, allowedContentTypes: [.data], allowsMultipleSelection: true) { result in + do { + for url in try result.get() { + let access = url.startAccessingSecurityScopedResource() + defer { if access { url.stopAccessingSecurityScopedResource() } } + let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size + attachments.reduce(0, { $0 + $1.data.count }) <= 20_000_000 else { + throw MailError.message("Anhänge dürfen zusammen höchstens 20 MB groß sein.") + } + attachments.append(MailAttachment(name: url.lastPathComponent, mimeType: "application/octet-stream", data: try Data(contentsOf: url))) + } + } catch { self.error = error.localizedDescription } + } + } +} + +#Preview { ContentView(store: MailStore()) } diff --git a/MikeMail/IMAPClient.swift b/MikeMail/IMAPClient.swift new file mode 100644 index 0000000..dd5bcd3 --- /dev/null +++ b/MikeMail/IMAPClient.swift @@ -0,0 +1,209 @@ +import Foundation + +@MainActor +final class IMAPClient { + private let connection: TLSConnection + private var sequence = 0 + + init(account: MailAccount) throws { + connection = try TLSConnection(host: account.host, port: account.imapPort) + } + + func close() { connection.close() } + + func login(account: MailAccount, password: String) async throws { + try await connection.open() + let greeting = try await connection.readLine() + if greeting.uppercased().hasPrefix("* PREAUTH") { return } + guard greeting.uppercased().hasPrefix("* OK") else { throw MailError.message("IMAP: \(greeting)") } + _ = try await command("LOGIN \(Self.quote(account.username)) \(Self.quote(password))") + } + + static func quote(_ value: String) throws -> String { + guard !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { + throw MailError.message("Ungültiges Steuerzeichen in IMAP-Eingabe.") + } + return "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } + + struct Response { + var lines: [String] = [] + var literals: [Data] = [] + } + + private func command(_ value: String) async throws -> Response { + sequence += 1 + let tag = "M\(sequence)" + try await connection.line("\(tag) \(value)") + return try await response(tag: tag) + } + + private func response(tag: String) async throws -> Response { + var result = Response() + for _ in 0..<100_000 { + let line = try await connection.readLine() + if line.hasPrefix(tag + " ") { + guard line.uppercased().hasPrefix(tag + " OK") else { + throw MailError.message("IMAP: \(line)") + } + return result + } + if line.uppercased().hasPrefix("* BYE") { throw MailError.message("IMAP: Verbindung vom Server beendet.") } + result.lines.append(line) + if line.hasSuffix("}"), let start = line.lastIndex(of: "{"), + let count = Int(line[line.index(after: start).. [MailFolder] { + let result = try await command("LIST \"\" \"*\"") + var entries: [(path: String, delimiter: String, selectable: Bool)] = [] + var literalIndex = 0 + for line in result.lines where line.uppercased().hasPrefix("* LIST ") { + guard let close = line.firstIndex(of: ")") else { continue } + let flags = line[..= 2 else { continue } + var path = tokens[1] + if path.hasPrefix("{"), literalIndex < result.literals.count { + path = String(decoding: result.literals[literalIndex], as: UTF8.self) + literalIndex += 1 + } + entries.append((path, tokens[0].uppercased() == "NIL" ? "" : tokens[0], !flags.contains("\\noselect"))) + } + func build(prefix: String?, entries: [(path: String, delimiter: String, selectable: Bool)]) -> [MailFolder] { + var roots: [String: MailFolder] = [:] + for entry in entries { + let remainder: String + if let prefix { + let start = prefix + entry.delimiter + guard !entry.delimiter.isEmpty, entry.path.hasPrefix(start) else { continue } + remainder = String(entry.path.dropFirst(start.count)) + } else { remainder = entry.path } + let component = entry.delimiter.isEmpty ? remainder : remainder.components(separatedBy: entry.delimiter)[0] + let path = prefix.map { $0 + entry.delimiter + component } ?? component + let exact = entries.first { $0.path == path } + roots[path] = MailFolder(accountID: accountID, path: path, + name: path.uppercased() == "INBOX" ? "Posteingang" : Self.decodeMailbox(component), + selectable: exact?.selectable ?? false) + } + return roots.values.map { folder in + var folder = folder + let descendants = build(prefix: folder.path, entries: entries) + folder.children = descendants.isEmpty ? nil : descendants + return folder + }.sorted { + if $0.path.uppercased() == "INBOX" { return true } + if $1.path.uppercased() == "INBOX" { return false } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + return build(prefix: nil, entries: entries) + } + + /// IMAP quoted strings may contain escaped quotes or spaces. + static func tokens(_ value: String) -> [String] { + var tokens: [String] = [] + var token = "" + var quoted = false + var escaped = false + var started = false + for character in value { + if escaped { token.append(character); escaped = false; continue } + if quoted && character == "\\" { escaped = true; continue } + if character == "\"" { quoted.toggle(); started = true; continue } + if character.isWhitespace && !quoted { + if started { tokens.append(token); token = ""; started = false } + } else { token.append(character); started = true } + } + if started { tokens.append(token) } + return tokens + } + + static func decodeMailbox(_ value: String) -> String { + var result = "" + var remainder = value[...] + while let start = remainder.firstIndex(of: "&") { + result += remainder[.. String { + for line in lines { + guard let start = line.range(of: "[UIDVALIDITY ", options: .caseInsensitive), + let end = line[start.upperBound...].firstIndex(of: "]") else { continue } + let value = String(line[start.upperBound.. 0 { return value } + } + throw MailError.message("IMAP-Server liefert keine gültige UIDVALIDITY; Abgleich abgebrochen.") + } + + static func parseFlags(_ lines: [String]) throws -> [UInt64: [String]] { + let uidPattern = try NSRegularExpression(pattern: "(?i)\\bUID ([0-9]+)") + let flagsPattern = try NSRegularExpression(pattern: "(?i)\\bFLAGS \\(([^)]*)\\)") + var result: [UInt64: [String]] = [:] + for line in lines { + let range = NSRange(line.startIndex..., in: line) + guard let uidMatch = uidPattern.firstMatch(in: line, range: range), + let uidRange = Range(uidMatch.range(at: 1), in: line), + let uid = UInt64(line[uidRange]), + let flagsMatch = flagsPattern.firstMatch(in: line, range: range), + let flagsRange = Range(flagsMatch.range(at: 1), in: line) else { continue } + result[uid] = line[flagsRange].split(separator: " ").map(String.init) + } + return result + } + + func inventory(folder: MailFolder) async throws -> MailboxInventory { + let examined = try await command("EXAMINE \(Self.quote(folder.path))") + let validity = try Self.uidValidity(from: examined.lines) + let search = try await command("UID SEARCH ALL") + guard let line = search.lines.first(where: { $0.uppercased().hasPrefix("* SEARCH") }) else { + throw MailError.message("Unvollständige IMAP-Bestandsliste.") + } + let ids = line.split(separator: " ").dropFirst(2).compactMap { UInt64($0) } + var flags: [UInt64: [String]] = [:] + for start in stride(from: 0, to: ids.count, by: 200) { + let batch = ids[start.. Data { + let fetched = try await command("UID FETCH \(uid) (UID BODY.PEEK[])") + guard let raw = fetched.literals.first else { + throw MailError.message("Nachricht \(uid) ist nicht mehr verfügbar; bitte erneut abrufen.") + } + return raw + } + + func appendSent(_ message: Data, path: String) async throws { + sequence += 1 + let tag = "M\(sequence)" + try await connection.line("\(tag) APPEND \(Self.quote(path)) (\\Seen) {\(message.count)}") + let ready = try await connection.readLine() + guard ready.hasPrefix("+") else { throw MailError.message("Gesendet-Kopie konnte nicht gespeichert werden: \(ready)") } + try await connection.send(message + Data("\r\n".utf8)) + _ = try await response(tag: tag) + } +} diff --git a/MikeMail/MIME.swift b/MikeMail/MIME.swift new file mode 100644 index 0000000..7278de1 --- /dev/null +++ b/MikeMail/MIME.swift @@ -0,0 +1,175 @@ +import Foundation + +/// MIME decoding keeps arbitrary attachment bytes separate from decoded text. +enum MIME { + static func headers(_ text: String) -> [String: String] { + let unfolded = text.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\n[ \t]+", with: " ", options: .regularExpression) + var result: [String: String] = [:] + for line in unfolded.components(separatedBy: "\n") { + guard let colon = line.firstIndex(of: ":") else { continue } + result[String(line[.. ([String: String], Data) { + for separator in [Data("\r\n\r\n".utf8), Data("\n\n".utf8)] { + if let range = data.range(of: separator) { + return (headers(String(decoding: data[.. String? { + let pattern = "(?:^|;)\\s*" + NSRegularExpression.escapedPattern(for: name) + "=\\s*(?:\"([^\"]*)\"|([^;\\s]+))" + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) else { return nil } + for i in 1...2 { + if let range = Range(match.range(at: i), in: value) { return String(value[range]) } + } + return nil + } + + static func quotedPrintable(_ data: Data, header: Bool = false) -> Data { + let bytes = Array(data) + var output = Data() + var i = 0 + while i < bytes.count { + if bytes[i] == 61 { + if i + 2 < bytes.count, bytes[i + 1] == 13, bytes[i + 2] == 10 { i += 3; continue } + if i + 1 < bytes.count, bytes[i + 1] == 10 { i += 2; continue } + if i + 2 < bytes.count, let value = UInt8(String(decoding: bytes[(i + 1)...(i + 2)], as: UTF8.self), radix: 16) { + output.append(value); i += 3; continue + } + } + output.append(header && bytes[i] == 95 ? 32 : bytes[i]) + i += 1 + } + return output + } + + static func text(_ data: Data, charset: String?) -> String { + let encoding: String.Encoding = switch charset?.lowercased() { + case "iso-8859-1", "latin1": .isoLatin1 + case "windows-1252": .windowsCP1252 + case "us-ascii": .ascii + default: .utf8 + } + return String(data: data, encoding: encoding) ?? String(data: data, encoding: .isoLatin1) ?? "" + } + + static func decodeHeader(_ value: String) -> String { + guard let regex = try? NSRegularExpression(pattern: "=\\?([^?]+)\\?([bBqQ])\\?([^?]*)\\?=") else { return value } + // Whitespace between adjacent encoded words is folding, not displayed content. + let source = value.replacingOccurrences(of: "(?<=\\?=)\\s+(?==\\?)", with: "", options: .regularExpression) + var result = source + for match in regex.matches(in: source, range: NSRange(source.startIndex..., in: source)).reversed() { + guard let whole = Range(match.range, in: result), + let cs = Range(match.range(at: 1), in: source), + let enc = Range(match.range(at: 2), in: source), + let content = Range(match.range(at: 3), in: source) else { continue } + let raw = String(source[content]) + let bytes = source[enc].lowercased() == "b" ? Data(base64Encoded: raw) : quotedPrintable(Data(raw.utf8), header: true) + if let bytes { result.replaceSubrange(whole, with: text(bytes, charset: String(source[cs]))) } + } + return result + } + + static func parse(_ data: Data, id: String) -> MailMessage { + let (fields, _) = split(data) + var bodies: [String] = [] + var htmlBodies: [String] = [] + var attachments: [MailAttachment] = [] + parsePart(data, depth: 0, bodies: &bodies, htmlBodies: &htmlBodies, attachments: &attachments) + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + var date: Date? + for format in ["EEE, d MMM yyyy HH:mm:ss Z", "d MMM yyyy HH:mm:ss Z", "EEE, d MMM yyyy HH:mm Z"] { + dateFormatter.dateFormat = format + date = dateFormatter.date(from: fields["date"] ?? "") + if date != nil { break } + } + return MailMessage(id: id, sender: decodeHeader(fields["from"] ?? ""), + recipient: decodeHeader(fields["to"] ?? ""), subject: decodeHeader(fields["subject"] ?? "(Ohne Betreff)"), + date: date ?? .distantPast, body: bodies.isEmpty ? htmlBodies.joined(separator: "\n") : bodies.joined(separator: "\n"), + attachments: attachments) + } + + private static func parsePart(_ data: Data, depth: Int, bodies: inout [String], htmlBodies: inout [String], attachments: inout [MailAttachment]) { + guard depth < 30 else { return } + let (fields, body) = split(data) + let type = fields["content-type"] ?? "text/plain" + if type.lowercased().hasPrefix("multipart/"), let boundary = parameter("boundary", in: type) { + // Boundaries are ASCII; Latin-1 preserves every byte through splitting. + let raw = String(data: body, encoding: .isoLatin1) ?? "" + let delimiter = "--" + boundary + let parts = raw.components(separatedBy: delimiter) + for part in parts.dropFirst() { + if part.hasPrefix("--") { break } + var part = part + // CRLF is one Swift Character, though it contains two bytes. + if part.hasPrefix("\r\n") || part.hasPrefix("\n") { part.removeFirst() } + if part.hasSuffix("\r\n") || part.hasSuffix("\n") { part.removeLast() } + if let bytes = part.data(using: .isoLatin1) { + parsePart(bytes, depth: depth + 1, bodies: &bodies, htmlBodies: &htmlBodies, attachments: &attachments) + } + } + return + } + let transfer = fields["content-transfer-encoding"]?.lowercased() ?? "" + let decoded: Data + if transfer == "base64" { decoded = Data(base64Encoded: body, options: .ignoreUnknownCharacters) ?? Data() } + else if transfer == "quoted-printable" { decoded = quotedPrintable(body) } + else { decoded = body } + let disposition = fields["content-disposition"] ?? "" + let extendedName = parameter("filename*", in: disposition).flatMap { $0.components(separatedBy: "''").last?.removingPercentEncoding } + let name = extendedName ?? parameter("filename", in: disposition) ?? parameter("name", in: type) + if name != nil || disposition.lowercased().hasPrefix("attachment") || !type.lowercased().hasPrefix("text/") { + attachments.append(MailAttachment(name: decodeHeader(name ?? "Anhang"), mimeType: type.components(separatedBy: ";")[0], data: decoded)) + } else { + let value = text(decoded, charset: parameter("charset", in: type)) + if type.lowercased().hasPrefix("text/html") { + // Render HTML as text: remote images and active content are never loaded. + let plain = value.replacingOccurrences(of: "(?is)<(script|style)[^>]*>.*?", with: "", options: .regularExpression) + .replacingOccurrences(of: "(?i)|

|", with: "\n", options: .regularExpression) + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .replacingOccurrences(of: " ", with: " ").replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">").replacingOccurrences(of: "&", with: "&") + htmlBodies.append(plain) + } else { bodies.append(value) } + } + } + + static func validAddress(_ value: String) -> Bool { + value.range(of: "^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$", options: .regularExpression) != nil + } + + static func compose(from: String, to: [String], subject: String, body: String, attachments: [MailAttachment]) throws -> Data { + guard validAddress(from), !to.isEmpty, to.allSatisfy(validAddress), + !subject.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else { + throw MailError.message("Bitte gültige E-Mail-Adressen und einen einzeiligen Betreff eingeben.") + } + let boundary = "MikeMail-" + UUID().uuidString + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" + func base64(_ data: Data) -> String { + data.base64EncodedString(options: [.lineLength76Characters, .endLineWithCarriageReturn, .endLineWithLineFeed]) + } + var result = "From: \(from)\r\nTo: \(to.joined(separator: ", "))\r\n" + result += "Subject: =?UTF-8?B?\(Data(subject.utf8).base64EncodedString())?=\r\n" + result += "Date: \(dateFormatter.string(from: Date()))\r\nMessage-ID: <\(UUID().uuidString)@mikemail.local>\r\n" + result += "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"\(boundary)\"\r\n\r\n" + result += "--\(boundary)\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n\(base64(Data(body.utf8)))\r\n" + for attachment in attachments { + let safeName = attachment.name.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "Anhang" + result += "--\(boundary)\r\nContent-Type: application/octet-stream\r\n" + result += "Content-Disposition: attachment; filename*=UTF-8''\(safeName)\r\nContent-Transfer-Encoding: base64\r\n\r\n" + result += base64(attachment.data) + "\r\n" + } + result += "--\(boundary)--\r\n" + return Data(result.utf8) + } +} diff --git a/MikeMail/MailArchive.swift b/MikeMail/MailArchive.swift new file mode 100644 index 0000000..3d5ba91 --- /dev/null +++ b/MikeMail/MailArchive.swift @@ -0,0 +1,237 @@ +import Foundation +import SwiftData +import CryptoKit + +enum MailArchiveSchema: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { [ArchiveState.self, ArchivedMail.self] } + + @Model final class ArchiveState { + @Attribute(.unique) var key: String + var payload: Data + init(key: String, payload: Data) { self.key = key; self.payload = payload } + } + + @Model final class ArchivedMail { + @Attribute(.unique) var key: String + var folderID: String + var validity: String + var uid: String + var objectHash: String + var flags: [String] + var present: Bool + init(key: String, folderID: String, validity: String, uid: String, objectHash: String, flags: [String], present: Bool = true) { + self.key = key + self.folderID = folderID + self.validity = validity + self.uid = uid + self.objectHash = objectHash + self.flags = flags + self.present = present + } + } +} + +enum MailArchiveMigration: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { [MailArchiveSchema.self] } + static var stages: [MigrationStage] { [] } +} + +struct ArchiveConfiguration: Codable { + var accounts: [MailAccount] + var folders: [UUID: [MailFolder]] + var recipients: [String] +} + +struct MailboxInventory { + let validity: String + let flags: [UInt64: [String]] +} + +struct ArchiveMailRecord: Codable { + let key: String + let folderID: String + let validity: String + let uid: String + let objectHash: String + let flags: [String] + let present: Bool +} + +/// All mutations and snapshot creation run serially on the main actor without suspension. +/// Immutable, content-addressed EML objects are written before their database references. +@MainActor +final class MailArchive { + nonisolated static var defaultRoot: URL { + URL.applicationSupportDirectory.appending(path: "MikeMail/Archive", directoryHint: .isDirectory) + } + let root: URL + private let container: ModelContainer + private let context: ModelContext + + init(root: URL = MailArchive.defaultRoot) throws { + self.root = root + try FileManager.default.createDirectory(at: root.appending(path: "Objects"), withIntermediateDirectories: true) + let marker = root.appending(path: "format.json") + if FileManager.default.fileExists(atPath: marker.path) { + let version = try JSONDecoder().decode(Int.self, from: Data(contentsOf: marker)) + guard version == 1 else { throw MailError.message("Nicht unterstützte Mailarchiv-Version: \(version).") } + } + let schema = Schema(versionedSchema: MailArchiveSchema.self) + let configuration = ModelConfiguration("MailArchive", schema: schema, + url: root.appending(path: "Mail.sqlite"), cloudKitDatabase: .none) + container = try ModelContainer(for: schema, migrationPlan: MailArchiveMigration.self, configurations: [configuration]) + context = ModelContext(container) + context.autosaveEnabled = false + if !FileManager.default.fileExists(atPath: marker.path) { try Data("1".utf8).write(to: marker, options: .atomic) } + } + + func configuration() throws -> ArchiveConfiguration? { + let key = "configuration" + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + guard let state = try context.fetch(descriptor).first else { return nil } + return try JSONDecoder().decode(ArchiveConfiguration.self, from: state.payload) + } + + func saveConfiguration(_ value: ArchiveConfiguration) throws { + let payload = try JSONEncoder().encode(value) + let key = "configuration" + do { + if let state = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.key == key })).first { + state.payload = payload + } else { context.insert(MailArchiveSchema.ArchiveState(key: key, payload: payload)) } + try context.save() + } catch { context.rollback(); throw error } + } + + private func rows(folderID: String) throws -> [MailArchiveSchema.ArchivedMail] { + try context.fetch(FetchDescriptor(predicate: #Predicate { $0.folderID == folderID })) + } + + func knownUIDs(folderID: String, validity: String) throws -> Set { + Set(try rows(folderID: folderID).filter { + $0.validity == validity && FileManager.default.fileExists(atPath: objectURL($0.objectHash).path) + }.compactMap { UInt64($0.uid) }) + } + + func store(raw: Data, folderID: String, validity: String, uid: String, flags: [String], visible: Bool = true) throws { + let hash = Self.digest(raw) + let url = objectURL(hash) + if !FileManager.default.fileExists(atPath: url.path) { try raw.write(to: url, options: .atomic) } + let key = folderID + ":" + validity + ":" + uid + do { + if let row = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.key == key })).first { + row.objectHash = hash + row.flags = flags + row.present = visible + } else { + context.insert(MailArchiveSchema.ArchivedMail(key: key, folderID: folderID, validity: validity, + uid: uid, objectHash: hash, flags: flags, present: visible)) + } + try context.save() + } catch { context.rollback(); throw error } + } + + /// Reconcile only after a complete server inventory. Keep removed objects for future backups. + func reconcile(folderID: String, inventory: MailboxInventory) throws { + do { + for row in try rows(folderID: folderID) { + let flags = UInt64(row.uid).flatMap { inventory.flags[$0] } + row.present = row.validity == inventory.validity && flags != nil + if let flags, row.present { row.flags = flags } + } + try context.save() + } catch { context.rollback(); throw error } + } + + func messages(folderID: String) throws -> [MailMessage] { + try rows(folderID: folderID).filter(\.present).map { row in + var mail = MIME.parse(try Data(contentsOf: objectURL(row.objectHash)), id: row.key) + mail.isRead = row.flags.contains { $0.lowercased() == "\\seen" } + return mail + }.sorted { $0.date > $1.date } + } + + private func objectURL(_ hash: String) -> URL { root.appending(path: "Objects/\(hash).eml") } + + static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + struct Snapshot: Codable { + let formatVersion: Int + let createdAt: Date + let configuration: ArchiveConfiguration + let messages: [ArchiveMailRecord] + } + + /// Logical snapshot: never copy a live SQLite database without its WAL. + /// The manifest and immutable EML files are sufficient to rebuild the database on restore. + func exportSnapshot(to destination: URL) throws { + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw MailError.message("Das Backup-Ziel existiert bereits.") + } + guard let configuration = try configuration() else { throw MailError.message("Archiv ist noch nicht eingerichtet.") } + try context.save() + let rows = try context.fetch(FetchDescriptor()) + let records = rows.map { ArchiveMailRecord(key: $0.key, folderID: $0.folderID, validity: $0.validity, + uid: $0.uid, objectHash: $0.objectHash, flags: $0.flags, present: $0.present) } + let staging = destination.deletingLastPathComponent().appending(path: ".MikeMail-\(UUID().uuidString)") + do { + try FileManager.default.createDirectory(at: staging.appending(path: "Objects"), withIntermediateDirectories: true) + for hash in Set(records.map(\.objectHash)) { + let data = try Data(contentsOf: objectURL(hash)) + guard Self.digest(data) == hash else { throw MailError.message("Beschädigte Archivdatei: \(hash)") } + try data.write(to: staging.appending(path: "Objects/\(hash).eml"), options: .atomic) + } + let snapshot = Snapshot(formatVersion: 1, createdAt: Date(), configuration: configuration, messages: records) + try JSONEncoder().encode(snapshot).write(to: staging.appending(path: "manifest.json"), options: .atomic) + try FileManager.default.moveItem(at: staging, to: destination) + } catch { + try? FileManager.default.removeItem(at: staging) + throw error + } + } + + /// Restore into a NEW archive only. The live archive is never overwritten. + static func restoreSnapshot(from source: URL, to destination: URL) throws -> MailArchive { + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw MailError.message("Wiederherstellung benötigt ein neues, leeres Zielverzeichnis.") + } + let snapshot = try JSONDecoder().decode(Snapshot.self, from: Data(contentsOf: source.appending(path: "manifest.json"))) + guard snapshot.formatVersion == 1 else { throw MailError.message("Nicht unterstütztes Backup-Format.") } + guard Set(snapshot.messages.map(\.key)).count == snapshot.messages.count else { + throw MailError.message("Das Backup enthält doppelte Nachrichtenschlüssel.") + } + // Validate every referenced object before creating the destination. + for hash in Set(snapshot.messages.map(\.objectHash)) { + guard hash.count == 64, hash.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) else { + throw MailError.message("Ungültiger Dateiverweis im Backup.") + } + let url = source.appending(path: "Objects/\(hash).eml") + guard try url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink != true, + digest(try Data(contentsOf: url)) == hash else { + throw MailError.message("Backup ist unvollständig oder beschädigt.") + } + } + let archive = try MailArchive(root: destination) + do { + try archive.saveConfiguration(snapshot.configuration) + for record in snapshot.messages { + let data = try Data(contentsOf: source.appending(path: "Objects/\(record.objectHash).eml")) + guard digest(data) == record.objectHash else { throw MailError.message("Backup wurde während des Restores verändert.") } + try data.write(to: archive.objectURL(record.objectHash), options: .atomic) + archive.context.insert(MailArchiveSchema.ArchivedMail(key: record.key, folderID: record.folderID, + validity: record.validity, uid: record.uid, objectHash: record.objectHash, + flags: record.flags, present: record.present)) + } + try archive.context.save() + return archive + } catch { + archive.context.rollback() + // Destination is a newly created, failed restore; source and live archive are untouched. + try? FileManager.default.removeItem(at: destination) + throw error + } + } +} diff --git a/MikeMail/MailModels.swift b/MikeMail/MailModels.swift new file mode 100644 index 0000000..8773660 --- /dev/null +++ b/MikeMail/MailModels.swift @@ -0,0 +1,101 @@ +import Foundation +import Security + +struct MailAccount: Identifiable, Codable, Hashable, Sendable { + var id = UUID() + var name = "" + var email = "" + var username = "" + var host = "" + var smtpPort: UInt16 = 587 + var imapPort: UInt16 = 993 + + static let testAccount = MailAccount(name: "Testkonto", email: "test@kuebler-sulzbach.de", + username: "test@kuebler-sulzbach.de", host: "w0099985.kasserver.com") +} + +struct MailFolder: Identifiable, Hashable, Codable, Sendable { + var id: String { accountID.uuidString + ":" + path } + let accountID: UUID + let path: String + let name: String + var selectable = true + var children: [MailFolder]? + + var icon: String { + switch path.lowercased() { + case "inbox": "tray" + case let value where value.contains("sent"): "paperplane" + case let value where value.contains("trash"): "trash" + case let value where value.contains("draft"): "doc" + default: "folder" + } + } +} + +struct MailAttachment: Identifiable, Sendable { + var id = UUID() + let name: String + let mimeType: String + let data: Data +} + +struct MailMessage: Identifiable, Sendable { + let id: String + let sender: String + let recipient: String + let subject: String + let date: Date + let body: String + let attachments: [MailAttachment] + var isRead = false +} + +enum MailError: LocalizedError { + case message(String) + var errorDescription: String? { + switch self { case .message(let text): text } + } +} + +enum PasswordStore { + private static func query(_ id: UUID) -> [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "de.mikemail.account", + kSecAttrAccount as String: id.uuidString] + } + + static func save(_ password: String, for id: UUID) throws { + let attributes = [kSecValueData as String: Data(password.utf8)] + let status = SecItemUpdate(query(id) as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var entry = query(id) + entry.merge(attributes) { _, new in new } + entry[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + let result = SecItemAdd(entry as CFDictionary, nil) + guard result == errSecSuccess else { throw MailError.message("Passwort konnte nicht im Schlüsselbund gespeichert werden (\(result)).") } + } else if status != errSecSuccess { + throw MailError.message("Schlüsselbundfehler (\(status)).") + } + } + + static func delete(for id: UUID) throws { + let status = SecItemDelete(query(id) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw MailError.message("Passwort konnte nicht entfernt werden (\(status)).") + } + } + + static func password(for id: UUID) throws -> String { + var entry = query(id) + entry[kSecReturnData as String] = true + entry[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(entry as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + throw MailError.message("Bitte unter „Konten“ das Passwort für dieses Konto speichern.") + } + return value + } +} diff --git a/MikeMail/MailSMTP.c b/MikeMail/MailSMTP.c new file mode 100644 index 0000000..bf3a287 --- /dev/null +++ b/MikeMail/MailSMTP.c @@ -0,0 +1,81 @@ +#include "MailSMTP.h" +#include +#include +#include +#include +#include + +static pthread_once_t curl_once = PTHREAD_ONCE_INIT; +static void initialize_curl(void) { curl_global_init(CURL_GLOBAL_DEFAULT); } + +struct MessageReader { + const unsigned char *bytes; + size_t length; + size_t offset; +}; + +static size_t read_message(char *destination, size_t size, size_t count, void *context) { + struct MessageReader *reader = context; + size_t available = reader->length - reader->offset; + size_t requested = size * count; + size_t copied = available < requested ? available : requested; + memcpy(destination, reader->bytes + reader->offset, copied); + reader->offset += copied; + return copied; +} + +int mail_smtp_send(const char *host, unsigned short port, const char *username, + const char *password, const char *sender, const char *recipients, + const unsigned char *message, size_t length, + char *error, size_t errorCapacity) { + pthread_once(&curl_once, initialize_curl); + CURL *curl = curl_easy_init(); + if (!curl) { + snprintf(error, errorCapacity, "SMTP konnte nicht initialisiert werden."); + return CURLE_FAILED_INIT; + } + char url[1024]; + snprintf(url, sizeof(url), "%s://%s:%u", port == 465 ? "smtps" : "smtp", host, port); + char detail[CURL_ERROR_SIZE] = {0}; + struct MessageReader reader = { message, length, 0 }; + struct curl_slist *addresses = NULL; + char *copy = strdup(recipients); + if (!copy) { curl_easy_cleanup(curl); return CURLE_OUT_OF_MEMORY; } + char *save = NULL; + for (char *address = strtok_r(copy, "\n", &save); address; address = strtok_r(NULL, "\n", &save)) { + struct curl_slist *next = curl_slist_append(addresses, address); + if (!next) { + free(copy); + curl_slist_free_all(addresses); + curl_easy_cleanup(curl); + return CURLE_OUT_OF_MEMORY; + } + addresses = next; + } + curl_easy_setopt(curl, CURLOPT_URL, url); + // Require STARTTLS on submission ports; never authenticate over plaintext. + 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_MAIL_FROM, sender); + curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, addresses); + curl_easy_setopt(curl, CURLOPT_READFUNCTION, read_message); + curl_easy_setopt(curl, CURLOPT_READDATA, &reader); + curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L); + curl_easy_setopt(curl, CURLOPT_INFILESIZE_LARGE, (curl_off_t)length); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 25L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 90L); + 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%s", detail[0] ? detail : curl_easy_strerror(result), + reader.offset > 0 ? " — Versandstatus möglicherweise unklar; vor erneutem Senden Eingang prüfen." : ""); + } + free(copy); + curl_slist_free_all(addresses); + curl_easy_cleanup(curl); + return (int)result; +} diff --git a/MikeMail/MailSMTP.h b/MikeMail/MailSMTP.h new file mode 100644 index 0000000..bbe4d94 --- /dev/null +++ b/MikeMail/MailSMTP.h @@ -0,0 +1,9 @@ +#ifndef MailSMTP_h +#define MailSMTP_h +#include + +int mail_smtp_send(const char *host, unsigned short port, const char *username, + const char *password, const char *sender, const char *recipients, + const unsigned char *message, size_t length, + char *error, size_t errorCapacity); +#endif diff --git a/MikeMail/MailStore.swift b/MikeMail/MailStore.swift new file mode 100644 index 0000000..c246e89 --- /dev/null +++ b/MikeMail/MailStore.swift @@ -0,0 +1,174 @@ +import Foundation +import Observation + +@MainActor @Observable +final class MailStore { + var accounts: [MailAccount] = [] + var recipientHistory: [String] = [] + var folders: [UUID: [MailFolder]] = [:] + var messages: [MailMessage] = [] + var isBusy = false + var totalMessages = 0 + var status = "Lokales Archiv bereit." + var error: String? + private var archive: MailArchive? + private var selectedFolderID: String? + + init(archive: MailArchive? = nil) { + do { + let storage = try archive ?? MailArchive() + self.archive = storage + if let configuration = try storage.configuration() { + accounts = configuration.accounts + folders = configuration.folders + recipientHistory = configuration.recipients + } else { + if let data = UserDefaults.standard.data(forKey: "mailAccounts") { + accounts = try JSONDecoder().decode([MailAccount].self, from: data) + } else { accounts = [.testAccount] } + recipientHistory = UserDefaults.standard.stringArray(forKey: "recipientHistory") ?? [] + try persist() + } + } catch { + self.error = "Lokales Archiv konnte nicht geöffnet werden: \(error.localizedDescription)" + status = "Archivfehler – vorhandene Daten wurden nicht ersetzt." + } + } + + private func storage() throws -> MailArchive { + guard let archive else { throw MailError.message("Lokales Archiv ist nicht verfügbar.") } + return archive + } + + private func persist() throws { + try storage().saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: folders, recipients: recipientHistory)) + } + + func save(account: MailAccount, password: String) throws { + guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") } + guard MIME.validAddress(account.email), !account.username.isEmpty, + !account.host.isEmpty, !account.host.contains(where: { $0.isWhitespace || $0 == "/" }), + account.imapPort > 0, account.smtpPort > 0 else { + throw MailError.message("Bitte E-Mail-Adresse, Benutzername, Server und Ports prüfen.") + } + let archive = try storage() + if let old = accounts.first(where: { $0.id == account.id }), + old.host != account.host || old.username != account.username || old.email != account.email { + throw MailError.message("Für eine andere Server-, Benutzer- oder Mailadresse bitte ein neues Konto anlegen. So bleiben archivierte Mails eindeutig zugeordnet.") + } + var updated = accounts + if let index = updated.firstIndex(where: { $0.id == account.id }) { updated[index] = account } + else { updated.append(account) } + if !password.isEmpty { try PasswordStore.save(password, for: account.id) } + try archive.saveConfiguration(ArchiveConfiguration(accounts: updated, folders: folders, recipients: recipientHistory)) + accounts = updated + status = "Konto lokal gespeichert." + } + + func deleteAccount(id: UUID) throws { + guard !isBusy else { throw MailError.message("Bitte den laufenden Abruf abwarten.") } + let archive = try storage() + let remaining = accounts.filter { $0.id != id } + var remainingFolders = folders + remainingFolders[id] = nil + try archive.saveConfiguration(ArchiveConfiguration(accounts: remaining, folders: remainingFolders, recipients: recipientHistory)) + accounts = remaining + folders = remainingFolders + messages = [] + totalMessages = 0 + try PasswordStore.delete(for: id) + status = "Konto entfernt; archivierte Originalmails und Serverdaten bleiben erhalten." + } + + private func allFolders(_ items: [MailFolder]) -> [MailFolder] { + items.flatMap { [$0] + allFolders($0.children ?? []) } + } + + func refresh(folderID: String?) async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + var failures: [String] = [] + do { + let archive = try storage() + for account in accounts { + do { + let client = try IMAPClient(account: account) + defer { client.close() } + status = "\(account.email): Verbindung wird aufgebaut …" + try await client.login(account: account, password: PasswordStore.password(for: account.id)) + let discovered = try await client.folders(accountID: account.id) + // Keep the previous listing if saving the new configuration fails. + var updated = folders + updated[account.id] = discovered + try archive.saveConfiguration(ArchiveConfiguration(accounts: accounts, folders: updated, recipients: recipientHistory)) + folders = updated + for folder in allFolders(discovered) where folder.selectable { + do { + status = "\(folder.name): Änderungen prüfen …" + let inventory = try await client.inventory(folder: folder) + let known = try archive.knownUIDs(folderID: folder.id, validity: inventory.validity) + let missing = Set(inventory.flags.keys).subtracting(known).sorted(by: >) + for (index, uid) in missing.enumerated() { + try Task.checkCancellation() + status = "\(folder.name): \(index + 1) von \(missing.count) neuen Mails speichern …" + let raw = try await client.rawMessage(uid: uid) + try archive.store(raw: raw, folderID: folder.id, validity: inventory.validity, + uid: String(uid), flags: inventory.flags[uid] ?? [], visible: false) + } + try archive.reconcile(folderID: folder.id, inventory: inventory) + if selectedFolderID == folder.id { try display(folderID: folder.id) } + } catch { failures.append("\(account.email) / \(folder.name): \(error.localizedDescription)") } + } + } catch { failures.append("\(account.email): \(error.localizedDescription)") } + } + if let selectedFolderID { try display(folderID: selectedFolderID) } + } catch { failures.append(error.localizedDescription) } + if failures.isEmpty { + status = "Abgleich abgeschlossen. Alle abgerufenen Mails sind offline verfügbar." + } else { + error = failures.joined(separator: "\n") + status = "Abgleich unvollständig. Bereits gespeicherte Mails bleiben offline verfügbar." + } + } + + private func display(folderID: String) throws { + messages = try storage().messages(folderID: folderID) + totalMessages = messages.count + } + + func load(folderID: String?, more: Bool = false) async { + selectedFolderID = folderID + guard let folderID else { messages = []; totalMessages = 0; return } + do { + try display(folderID: folderID) + if !isBusy { status = "\(messages.count) lokal gespeicherte Nachrichten · Abrufen gleicht Änderungen ab." } + } catch { self.error = error.localizedDescription } + } + + func send(accountID: UUID?, recipients: String, subject: String, body: String, attachments: [MailAttachment]) async throws { + guard let account = accounts.first(where: { $0.id == accountID }) else { + throw MailError.message("Bitte ein Absenderkonto auswählen.") + } + let archive = try storage() + let addresses = recipients.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let data = try MIME.compose(from: account.email, to: addresses, subject: subject, body: body, attachments: attachments) + let localFolder = MailFolder(accountID: account.id, path: "__local_outgoing__", name: "Lokale Versandkopien") + let localID = UUID().uuidString + // Persist BEFORE SMTP, so a crash or uncertain delivery cannot lose the composed message. + try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["Pending"]) + try await SMTPClient.send(account: account, password: PasswordStore.password(for: account.id), recipients: addresses, message: data) + do { + try archive.store(raw: data, folderID: localFolder.id, validity: "local", uid: localID, flags: ["\\Seen", "Sent"]) + for address in addresses { + recipientHistory.removeAll { $0.caseInsensitiveCompare(address) == .orderedSame } + recipientHistory.insert(address, at: 0) + } + try persist() + status = "Nachricht versendet und lokal archiviert." + } catch { + // SMTP already succeeded: do not offer an accidental resend by throwing here. + self.error = "Nachricht wurde versendet; Archivstatus konnte nicht aktualisiert werden: \(error.localizedDescription)" + } + } +} diff --git a/MikeMail/MailTransport.swift b/MikeMail/MailTransport.swift new file mode 100644 index 0000000..e9f5b82 --- /dev/null +++ b/MikeMail/MailTransport.swift @@ -0,0 +1,128 @@ +import Foundation +import Network + +/// One sequential protocol session. TLS uses the system trust store and hostname validation. +@MainActor +final class TLSConnection { + private let connection: NWConnection + private var buffer = Data() + private var connectionContinuation: CheckedContinuation? + private var deadline: Task? + + init(host: String, port: UInt16) throws { + guard let endpointPort = NWEndpoint.Port(rawValue: port), !host.isEmpty else { + throw MailError.message("Ungültiger Server oder Port.") + } + connection = NWConnection(host: NWEndpoint.Host(host), port: endpointPort, using: .tls) + } + + func open() async throws { + try await withCheckedThrowingContinuation { continuation in + connectionContinuation = continuation + connection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self else { return } + switch state { + case .ready: self.finishOpen(nil) + case .failed(let error), .waiting(let error): self.finishOpen(error) + case .cancelled: self.finishOpen(MailError.message("Verbindung beendet.")) + default: break + } + } + } + connection.start(queue: .global(qos: .userInitiated)) + deadline = Task { [weak self] in + try? await Task.sleep(for: .seconds(25)) + guard !Task.isCancelled else { return } + self?.finishOpen(MailError.message("Zeitüberschreitung beim Verbindungsaufbau.")) + self?.connection.cancel() + } + } + } + + private func finishOpen(_ error: Error?) { + guard let continuation = connectionContinuation else { return } + connectionContinuation = nil + deadline?.cancel() + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + } + + func close() { deadline?.cancel(); connection.cancel() } + + func send(_ data: Data) async throws { + let timeout = Task { [connection] in + try? await Task.sleep(for: .seconds(30)) + if !Task.isCancelled { connection.cancel() } + } + defer { timeout.cancel() } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume() } + }) + } + } + + func line(_ text: String) async throws { try await send(Data((text + "\r\n").utf8)) } + + private func receive() async throws { + let timeout = Task { [connection] in + try? await Task.sleep(for: .seconds(30)) + if !Task.isCancelled { connection.cancel() } + } + defer { timeout.cancel() } + let data: Data = try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { data, _, complete, error in + if let error { continuation.resume(throwing: error) } + else if let data, !data.isEmpty { continuation.resume(returning: data) } + else { continuation.resume(throwing: MailError.message(complete ? "Der Server hat die Verbindung beendet." : "Keine Serverantwort.")) } + } + } + buffer.append(data) + } + + func readLine() async throws -> String { + while true { + if let range = buffer.range(of: Data("\r\n".utf8)) { + let data = buffer[.. Data { + guard count >= 0, count <= 40_000_000 else { throw MailError.message("Nachricht überschreitet das Limit von 40 MB.") } + while buffer.count < count { try await receive() } + let data = Data(buffer.prefix(count)) + buffer.removeFirst(count) + return data + } +} + +enum SMTPClient { + static func send(account: MailAccount, password: String, recipients: [String], message: Data) async throws { + guard MIME.validAddress(account.email), !recipients.isEmpty, recipients.allSatisfy(MIME.validAddress) else { + throw MailError.message("Ungültige E-Mail-Adresse.") + } + let host = account.host + let port = account.smtpPort + let username = account.username + let sender = account.email + let addresses = recipients.joined(separator: "\n") + // libcurl runs on a worker, so TLS negotiation and uploads never block the UI. + let result = await Task.detached(priority: .userInitiated) { + var error = [CChar](repeating: 0, count: 1024) + let code = message.withUnsafeBytes { bytes in + mail_smtp_send(host, port, username, password, sender, addresses, + bytes.bindMemory(to: UInt8.self).baseAddress, message.count, &error, error.count) + } + return (code, String(cString: error)) + }.value + guard result.0 == 0 else { throw MailError.message("SMTP: \(result.1) (\(result.0))") } + } +} diff --git a/MikeMail/MikeMailApp.swift b/MikeMail/MikeMailApp.swift index 3243767..7048a78 100644 --- a/MikeMail/MikeMailApp.swift +++ b/MikeMail/MikeMailApp.swift @@ -1,32 +1,13 @@ -// -// MikeMailApp.swift -// MikeMail -// -// Created by Dr. med. Michael Kübler on 06.11.26. -// - import SwiftUI -import SwiftData @main struct MikeMailApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() + @State private var store = MailStore() var body: some Scene { WindowGroup { - ContentView() + ContentView(store: store) } - .modelContainer(sharedModelContainer) + .defaultSize(width: 1200, height: 800) } } diff --git a/MikeMail/RecipientField.swift b/MikeMail/RecipientField.swift new file mode 100644 index 0000000..db552f9 --- /dev/null +++ b/MikeMail/RecipientField.swift @@ -0,0 +1,129 @@ +import SwiftUI +import AppKit +import Contacts +import ContactsUI + +enum RecipientCompletion { + static func matches(in history: [String], input: String) -> [String] { + let parts = input.components(separatedBy: ",") + let query = (parts.last ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return [] } + let selected = Set(parts.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }) + return history.filter { + !selected.contains($0.lowercased()) && $0.localizedCaseInsensitiveContains(query) + && $0.caseInsensitiveCompare(query) != .orderedSame + } + } + + static func inserting(_ address: String, into input: String) -> String { + var parts = input.components(separatedBy: ",").dropLast().map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + }.filter { !$0.isEmpty } + if !parts.contains(where: { $0.caseInsensitiveCompare(address) == .orderedSame }) { parts.append(address) } + return parts.joined(separator: ", ") + ", " + } +} + +/// The system picker includes iCloud contacts synchronized with macOS Contacts. +/// Only the email address explicitly selected by the user is passed to MikeMail. +struct ContactPickerButton: NSViewRepresentable { + var onSelect: (String) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onSelect: onSelect) } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(image: NSImage(systemSymbolName: "person.crop.square", accessibilityDescription: "Adressbuch") ?? NSImage(), + target: context.coordinator, action: #selector(Coordinator.show(_:))) + button.bezelStyle = .rounded + button.toolTip = "Empfänger aus Kontakten auswählen (einschließlich iCloud)" + button.setAccessibilityLabel("Adressbuch") + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { context.coordinator.onSelect = onSelect } + + static func dismantleNSView(_ nsView: NSButton, coordinator: Coordinator) { coordinator.picker.close() } + + final class Coordinator: NSObject, CNContactPickerDelegate { + let picker = CNContactPicker() + var onSelect: (String) -> Void + + init(onSelect: @escaping (String) -> Void) { + self.onSelect = onSelect + super.init() + picker.delegate = self + picker.displayedKeys = [CNContactEmailAddressesKey] + } + + @objc func show(_ sender: NSButton) { + picker.showRelative(to: sender.bounds, of: sender, preferredEdge: .maxY) + } + + func contactPicker(_ picker: CNContactPicker, didSelect contactProperty: CNContactProperty) { + guard contactProperty.key == CNContactEmailAddressesKey, + let email = contactProperty.value as? String else { return } + onSelect(email) + picker.close() + } + } +} + +struct RecipientField: View { + @Binding var recipient: String + let history: [String] + @FocusState private var focused: Bool + @State private var highlighted = 0 + + var body: some View { + let suggestions = RecipientCompletion.matches(in: history, input: recipient) + VStack(alignment: .leading, spacing: 4) { + HStack { + ContactPickerButton { address in + recipient = RecipientCompletion.inserting(address, into: recipient) + focused = true + }.frame(width: 30, height: 26) + TextField("An (mehrere mit Komma trennen)", text: $recipient) + .accessibilityIdentifier("recipientField") + .focused($focused) + .onKeyPress(.downArrow) { + guard !suggestions.isEmpty else { return .ignored } + highlighted = min(highlighted + 1, suggestions.count - 1) + return .handled + } + .onKeyPress(.upArrow) { + guard !suggestions.isEmpty else { return .ignored } + highlighted = max(0, highlighted - 1) + return .handled + } + .onKeyPress(.return) { + guard suggestions.indices.contains(highlighted) else { return .ignored } + recipient = RecipientCompletion.inserting(suggestions[highlighted], into: recipient) + return .handled + } + } + if focused && !suggestions.isEmpty { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(suggestions.enumerated()), id: \.element) { index, address in + Button { + recipient = RecipientCompletion.inserting(address, into: recipient) + focused = true + } label: { + Text(address).frame(maxWidth: .infinity, alignment: .leading).padding(6) + .background(index == highlighted ? Color.accentColor.opacity(0.15) : .clear) + } + .buttonStyle(.plain) + .id(index) + } + } + } + .frame(height: min(CGFloat(suggestions.count) * 30, 120)) + .onChange(of: highlighted) { _, index in proxy.scrollTo(index) } + } + .padding(.leading, 38) + } + } + .onChange(of: recipient) { _, _ in highlighted = 0 } + } +} diff --git a/MikeMailTests/MailArchiveTests.swift b/MikeMailTests/MailArchiveTests.swift new file mode 100644 index 0000000..739d0c1 --- /dev/null +++ b/MikeMailTests/MailArchiveTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import MikeMail + +@MainActor +struct MailArchiveTests { + @Test func diskPersistenceIncrementalReconciliationAndRestore() throws { + let base = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: base) } + let root = base.appending(path: "Archive") + let account = MailAccount(name: "Fixture", email: "fixture@example.com", username: "fixture", host: "example.com") + let folder = MailFolder(accountID: account.id, path: "INBOX", name: "Posteingang") + let bytes = Data((0...255).map(UInt8.init)) + let raw = try MIME.compose(from: account.email, to: ["recipient@example.com"], subject: "Offline Büro", + body: "Dauerhaft verfügbar", attachments: [MailAttachment(name: "Prüfung.bin", mimeType: "application/octet-stream", data: bytes)]) + var archive: MailArchive? = try MailArchive(root: root) + try archive?.saveConfiguration(ArchiveConfiguration(accounts: [account], folders: [account.id: [folder]], recipients: ["recipient@example.com"])) + try archive?.store(raw: raw, folderID: folder.id, validity: "10", uid: "1", flags: []) + try archive?.store(raw: raw, folderID: folder.id, validity: "10", uid: "1", flags: ["\\Seen"]) + #expect(try archive?.messages(folderID: folder.id).count == 1) + #expect(try archive?.knownUIDs(folderID: folder.id, validity: "10") == [1]) + #expect(try archive?.knownUIDs(folderID: folder.id, validity: "11") == []) + archive = nil + + let reopened = try MailArchive(root: root) + #expect(try reopened.configuration()?.accounts == [account]) + #expect(try reopened.messages(folderID: folder.id).first?.attachments.first?.data == bytes) + #expect(try reopened.messages(folderID: folder.id).first?.isRead == true) + try reopened.reconcile(folderID: folder.id, inventory: MailboxInventory(validity: "10", flags: [1: []])) + #expect(try reopened.messages(folderID: folder.id).first?.isRead == false) + // Partial download of a new UID generation is durable but not mixed into the visible old generation. + try reopened.store(raw: raw, folderID: folder.id, validity: "11", uid: "2", flags: [], visible: false) + #expect(try reopened.messages(folderID: folder.id).count == 1) + #expect(try reopened.knownUIDs(folderID: folder.id, validity: "11") == [2]) + let backup = base.appending(path: "Backup") + try reopened.exportSnapshot(to: backup) + let restored = try MailArchive.restoreSnapshot(from: backup, to: base.appending(path: "Restored")) + #expect(try restored.messages(folderID: folder.id).first?.body == "Dauerhaft verfügbar") + #expect(try restored.messages(folderID: folder.id).first?.attachments.first?.data == bytes) + #expect(try restored.configuration()?.recipients == ["recipient@example.com"]) + + // A server UIDVALIDITY reset must not attach old data to the reused UID. + try reopened.reconcile(folderID: folder.id, inventory: MailboxInventory(validity: "11", flags: [1: []])) + #expect(try reopened.messages(folderID: folder.id).isEmpty) + let retained = base.appending(path: "RetainedBackup") + try reopened.exportSnapshot(to: retained) + let manifest = try JSONDecoder().decode(MailArchive.Snapshot.self, from: Data(contentsOf: retained.appending(path: "manifest.json"))) + #expect(manifest.messages.count == 2) + #expect(manifest.messages.allSatisfy { !$0.present }) + let object = retained.appending(path: "Objects/\(MailArchive.digest(raw)).eml") + try Data("corrupt".utf8).write(to: object) + #expect(throws: (any Error).self) { + try MailArchive.restoreSnapshot(from: retained, to: base.appending(path: "Rejected")) + } + #expect(!FileManager.default.fileExists(atPath: base.appending(path: "Rejected").path)) + } + + @Test func imapInventoryParsing() throws { + #expect(try IMAPClient.uidValidity(from: ["* OK [UIDVALIDITY 123] UIDs valid"]) == "123") + #expect(throws: (any Error).self) { try IMAPClient.uidValidity(from: ["* OK hello"]) } + let flags = try IMAPClient.parseFlags(["* 1 FETCH (UID 7 FLAGS (\\Seen \\Flagged))", "* 2 FETCH (FLAGS () UID 9)"]) + #expect(flags[7] == ["\\Seen", "\\Flagged"]) + #expect(flags[9] == []) + } +} diff --git a/MikeMailTests/MikeMailTests.swift b/MikeMailTests/MikeMailTests.swift index 9ac4108..9bf0493 100644 --- a/MikeMailTests/MikeMailTests.swift +++ b/MikeMailTests/MikeMailTests.swift @@ -1,19 +1,91 @@ -// -// MikeMailTests.swift -// MikeMailTests -// -// Created by Dr. med. Michael Kübler on 06.11.26. -// - +import Foundation import Testing @testable import MikeMail +@MainActor struct MikeMailTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing + @Test func recipientSuggestionsAndInsertion() { + let history = ["anna@example.com", "bert@example.com", "anna@work.de"] + #expect(RecipientCompletion.matches(in: history, input: "ANN") == ["anna@example.com", "anna@work.de"]) + #expect(RecipientCompletion.matches(in: history, input: "anna@example.com, ann") == ["anna@work.de"]) + #expect(RecipientCompletion.matches(in: history, input: "") == []) + #expect(RecipientCompletion.inserting("bert@example.com", into: "anna@example.com, be") == "anna@example.com, bert@example.com, ") + #expect(RecipientCompletion.inserting("anna@example.com", into: "anna@example.com, an") == "anna@example.com, ") } + @Test func imapEscapingAndMailboxNames() throws { + #expect(try IMAPClient.quote("Ordner \"Büro\"") == "\"Ordner \\\"Büro\\\"\"") + #expect(throws: (any Error).self) { try IMAPClient.quote("INBOX\r\nDELETE INBOX") } + #expect(IMAPClient.tokens("\"/\" \"Ordner mit Leerzeichen\"") == ["/", "Ordner mit Leerzeichen"]) + #expect(IMAPClient.decodeMailbox("B&APw-ro") == "Büro") + #expect(IMAPClient.decodeMailbox("A &- B") == "A & B") + } + + @Test func binaryAttachmentRoundTrip() throws { + let bytes = Data((0...255).map(UInt8.init)) + let attachment = MailAttachment(name: "Büroprüfung.bin", mimeType: "application/octet-stream", data: bytes) + let raw = try MIME.compose(from: "sender@example.com", to: ["recipient@example.com"], + subject: "Grüße aus dem Büro", body: "Zeile 1\n.\nÄÖÜ ß", attachments: [attachment]) + let decoded = MIME.parse(raw, id: "1") + #expect(decoded.subject == "Grüße aus dem Büro") + #expect(decoded.body == "Zeile 1\n.\nÄÖÜ ß") + #expect(decoded.attachments.count == 1) + #expect(decoded.attachments.first?.name == "Büroprüfung.bin") + #expect(decoded.attachments.first?.data == bytes) + } + + @Test func incomingNestedMIME() { + let raw = """ + From: =?UTF-8?Q?J=C3=B6rg?= + Subject: =?UTF-8?B?R3LDvMOfZQ==?= + Date: Fri, 06 Nov 2026 10:30:00 +0100 + Content-Type: multipart/mixed; boundary="outer" + + --outer + Content-Type: multipart/alternative; boundary="inner" + + --inner + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Gr=C3=BC=C3=9Fe aus dem B=C3=BCro. + --inner + Content-Type: text/html; charset=utf-8 + +

Alternative

+ --inner-- + --outer + Content-Type: application/pdf + Content-Disposition: attachment; filename="rechnung.pdf" + Content-Transfer-Encoding: base64 + + JVBERi0xLjc= + --outer-- + """ + let message = MIME.parse(Data(raw.replacingOccurrences(of: "\n", with: "\r\n").utf8), id: "2") + #expect(message.sender == "Jörg ") + #expect(message.subject == "Grüße") + #expect(message.body == "Grüße aus dem Büro.") + #expect(message.attachments.first?.name == "rechnung.pdf") + #expect(message.attachments.first?.data == Data("%PDF-1.7".utf8)) + #expect(message.date != .distantPast) + } + + @Test func foldedHeaderAndQuotedPrintable() { + #expect(MIME.decodeHeader("=?UTF-8?Q?Gr=C3=BC=C3=9Fe?= =?UTF-8?Q?_Welt?=") == "Grüße Welt") + #expect(MIME.headers("Subject: Erste\r\n zweite\r\nFrom: a@example.com")["subject"] == "Erste zweite") + #expect(MIME.quotedPrintable(Data("a=\r\nb=3Dc".utf8)) == Data("ab=c".utf8)) + } + + @Test func preventsHeaderInjection() { + #expect(throws: (any Error).self) { + try MIME.compose(from: "a@example.com", to: ["b@example.com\r\nBcc: x@example.com"], + subject: "Hallo", body: "", attachments: []) + } + #expect(throws: (any Error).self) { + try MIME.compose(from: "a@example.com", to: ["b@example.com"], + subject: "Hallo\r\nBcc: x@example.com", body: "", attachments: []) + } + #expect(!MIME.validAddress("not an address")) + } } diff --git a/MikeMailUITests/MikeMailUITests.swift b/MikeMailUITests/MikeMailUITests.swift index 63c1efd..257008e 100644 --- a/MikeMailUITests/MikeMailUITests.swift +++ b/MikeMailUITests/MikeMailUITests.swift @@ -1,43 +1,30 @@ -// -// MikeMailUITests.swift -// MikeMailUITests -// -// Created by Dr. med. Michael Kübler on 06.11.26. -// - import XCTest +import XCUIAutomation final class MikeMailUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. + @MainActor + func testMailWindowAndCompose() throws { continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. let app = XCUIApplication() + app.launchArguments = ["-ApplePersistenceIgnoreState", "YES"] app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - // XCUIAutomation Documentation - // https://developer.apple.com/documentation/xcuiautomation - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } + app.activate() + XCTAssertTrue(app.buttons["Neue Nachricht"].waitForExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(app.staticTexts["Nachricht auswählen"].waitForExistence(timeout: 5), app.debugDescription) + app.buttons["Neue Nachricht"].click() + XCTAssertTrue(app.buttons["Anhang hinzufügen"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["Senden"].isEnabled) + XCTAssertTrue(app.buttons["Adressbuch"].exists) + XCTAssertTrue(app.textFields["recipientField"].exists) + app.buttons["Abbrechen"].click() + app.buttons["Konten"].click() + XCTAssertTrue(app.secureTextFields.firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.buttons["Speichern"].exists) + XCTAssertTrue(app.buttons["Neues Konto hinzufügen"].exists) + XCTAssertTrue(app.buttons["Konto löschen"].exists) + app.buttons["Neues Konto hinzufügen"].click() + XCTAssertTrue(app.textFields["accountEmail"].waitForExistence(timeout: 5)) + app.buttons["Schließen"].click() + XCTAssertTrue(app.buttons["Abrufen"].exists) } }