Erste Alpha-Version

This commit is contained in:
Dr. Michael Kübler
2026-11-06 18:21:25 +01:00
committed by Dr. Michael Kübler
parent 93feb97739
commit 223bf746ef
25 changed files with 1888 additions and 145 deletions
+54
View File
@@ -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/<SHA-256>.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.
+6
View File
@@ -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",
},
+126
View File
@@ -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)
}
}
}
@@ -3,52 +3,62 @@
{
"idiom": "mac",
"scale": "1x",
"size" : "16x16"
"size": "16x16",
"filename": "MikeMail-16.png"
},
{
"idiom": "mac",
"scale": "2x",
"size" : "16x16"
"size": "16x16",
"filename": "MikeMail-32.png"
},
{
"idiom": "mac",
"scale": "1x",
"size" : "32x32"
"size": "32x32",
"filename": "MikeMail-32.png"
},
{
"idiom": "mac",
"scale": "2x",
"size" : "32x32"
"size": "32x32",
"filename": "MikeMail-64.png"
},
{
"idiom": "mac",
"scale": "1x",
"size" : "128x128"
"size": "128x128",
"filename": "MikeMail-128.png"
},
{
"idiom": "mac",
"scale": "2x",
"size" : "128x128"
"size": "128x128",
"filename": "MikeMail-256.png"
},
{
"idiom": "mac",
"scale": "1x",
"size" : "256x256"
"size": "256x256",
"filename": "MikeMail-256.png"
},
{
"idiom": "mac",
"scale": "2x",
"size" : "256x256"
"size": "256x256",
"filename": "MikeMail-512.png"
},
{
"idiom": "mac",
"scale": "1x",
"size" : "512x512"
"size": "512x512",
"filename": "MikeMail-512.png"
},
{
"idiom": "mac",
"scale": "2x",
"size" : "512x512"
"size": "512x512",
"filename": "MikeMail-1024.png"
}
],
"info": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+239 -40
View File
@@ -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)
}
}
.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 addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
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)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
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()) }
+209
View File
@@ -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)..<line.index(before: line.endIndex)]) {
result.literals.append(try await connection.readBytes(count))
}
}
throw MailError.message("IMAP-Antwort überschreitet das Limit.")
}
func folders(accountID: UUID) async throws -> [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[..<close].lowercased()
let rest = String(line[line.index(after: close)...])
let tokens = Self.tokens(rest)
guard tokens.count >= 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[..<start]
guard let end = remainder[start...].firstIndex(of: "-") else { result += remainder[start...]; return result }
var encoded = String(remainder[remainder.index(after: start)..<end])
if encoded.isEmpty { result += "&" }
else {
encoded = encoded.replacingOccurrences(of: ",", with: "/")
while encoded.count % 4 != 0 { encoded += "=" }
if let data = Data(base64Encoded: encoded), let decoded = String(data: data, encoding: .utf16BigEndian) { result += decoded }
else { result += remainder[start...end] }
}
remainder = remainder[remainder.index(after: end)...]
}
return result + remainder
}
static func uidValidity(from lines: [String]) throws -> 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..<end])
if let number = UInt64(value), number > 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..<min(start + 200, ids.count)].map(String.init).joined(separator: ",")
let fetched = try await command("UID FETCH \(batch) (UID FLAGS)")
flags.merge(try Self.parseFlags(fetched.lines)) { _, new in new }
}
guard Set(flags.keys) == Set(ids) else {
throw MailError.message("Der Ordner hat sich während des Abgleichs verändert. Bitte erneut abrufen.")
}
return MailboxInventory(validity: validity, flags: flags)
}
func rawMessage(uid: UInt64) async throws -> 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)
}
}
+175
View File
@@ -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[..<colon]).lowercased()] = String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
}
return result
}
static func split(_ data: Data) -> ([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[..<range.lowerBound], as: UTF8.self)), Data(data[range.upperBound...]))
}
}
return ([:], data)
}
static func parameter(_ name: String, in value: String) -> 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)<br\\s*/?>|</p>|</div>", with: "\n", options: .regularExpression)
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.replacingOccurrences(of: "&nbsp;", with: " ").replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">").replacingOccurrences(of: "&amp;", 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)
}
}
+237
View File
@@ -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<MailArchiveSchema.ArchiveState>(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<MailArchiveSchema.ArchiveState>(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<MailArchiveSchema.ArchivedMail> { $0.folderID == folderID }))
}
func knownUIDs(folderID: String, validity: String) throws -> Set<UInt64> {
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<MailArchiveSchema.ArchivedMail>(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<MailArchiveSchema.ArchivedMail>())
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
}
}
}
+101
View File
@@ -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
}
}
+81
View File
@@ -0,0 +1,81 @@
#include "MailSMTP.h"
#include <curl/curl.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}
+9
View File
@@ -0,0 +1,9 @@
#ifndef MailSMTP_h
#define MailSMTP_h
#include <stddef.h>
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
+174
View File
@@ -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)"
}
}
}
+128
View File
@@ -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<Void, Error>?
private var deadline: Task<Void, Never>?
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<Void, Error>) 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[..<range.lowerBound]
let value = String(decoding: data, as: UTF8.self)
buffer.removeSubrange(..<range.upperBound)
return value
}
guard buffer.count < 1_000_000 else { throw MailError.message("Serverantwort ist zu lang.") }
try await receive()
}
}
func readBytes(_ count: Int) async throws -> 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))") }
}
}
+3 -22
View File
@@ -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)
}
}
+129
View File
@@ -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 }
}
}
+65
View File
@@ -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] == [])
}
}
+84 -12
View File
@@ -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?= <joerg@example.com>
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
<p>Alternative</p>
--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 <joerg@example.com>")
#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"))
}
}
+22 -35
View File
@@ -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)
}
}