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)[^>]*>.*?\\1>", 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)
}
}