Erste Alpha-Version
@@ -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.
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"DEVELOPMENT_TEAM": "GQR972L9NJ",
|
"DEVELOPMENT_TEAM": "GQR972L9NJ",
|
||||||
"ENABLE_APP_SANDBOX": "YES",
|
"ENABLE_APP_SANDBOX": "YES",
|
||||||
"ENABLE_HARDENED_RUNTIME": "YES",
|
"ENABLE_HARDENED_RUNTIME": "YES",
|
||||||
|
"ENABLE_OUTGOING_NETWORK_CONNECTIONS": "YES",
|
||||||
"ENABLE_PREVIEWS": "YES",
|
"ENABLE_PREVIEWS": "YES",
|
||||||
"ENABLE_USER_SELECTED_FILES": "readonly",
|
"ENABLE_USER_SELECTED_FILES": "readonly",
|
||||||
"GENERATE_INFOPLIST_FILE": "YES",
|
"GENERATE_INFOPLIST_FILE": "YES",
|
||||||
@@ -53,6 +54,10 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
],
|
],
|
||||||
"MARKETING_VERSION": "1.0",
|
"MARKETING_VERSION": "1.0",
|
||||||
|
"OTHER_LDFLAGS": [
|
||||||
|
"$(inherited)",
|
||||||
|
"-lcurl",
|
||||||
|
],
|
||||||
"PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMail",
|
"PRODUCT_BUNDLE_IDENTIFIER": "Dr.-med.-Michael-Ku-bler.MikeMail",
|
||||||
"PRODUCT_NAME": "$(TARGET_NAME)",
|
"PRODUCT_NAME": "$(TARGET_NAME)",
|
||||||
"REGISTER_APP_GROUPS": "YES",
|
"REGISTER_APP_GROUPS": "YES",
|
||||||
@@ -60,6 +65,7 @@
|
|||||||
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
"SWIFT_APPROACHABLE_CONCURRENCY": "YES",
|
||||||
"SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor",
|
"SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor",
|
||||||
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
"SWIFT_EMIT_LOC_STRINGS": "YES",
|
||||||
|
"SWIFT_OBJC_BRIDGING_HEADER": "MikeMail/MailSMTP.h",
|
||||||
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
"SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY": "YES",
|
||||||
"SWIFT_VERSION": "5.0",
|
"SWIFT_VERSION": "5.0",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,58 +1,68 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images": [
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "1x",
|
"scale": "1x",
|
||||||
"size" : "16x16"
|
"size": "16x16",
|
||||||
|
"filename": "MikeMail-16.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "2x",
|
"scale": "2x",
|
||||||
"size" : "16x16"
|
"size": "16x16",
|
||||||
|
"filename": "MikeMail-32.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "1x",
|
"scale": "1x",
|
||||||
"size" : "32x32"
|
"size": "32x32",
|
||||||
|
"filename": "MikeMail-32.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "2x",
|
"scale": "2x",
|
||||||
"size" : "32x32"
|
"size": "32x32",
|
||||||
|
"filename": "MikeMail-64.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "1x",
|
"scale": "1x",
|
||||||
"size" : "128x128"
|
"size": "128x128",
|
||||||
|
"filename": "MikeMail-128.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "2x",
|
"scale": "2x",
|
||||||
"size" : "128x128"
|
"size": "128x128",
|
||||||
|
"filename": "MikeMail-256.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "1x",
|
"scale": "1x",
|
||||||
"size" : "256x256"
|
"size": "256x256",
|
||||||
|
"filename": "MikeMail-256.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "2x",
|
"scale": "2x",
|
||||||
"size" : "256x256"
|
"size": "256x256",
|
||||||
|
"filename": "MikeMail-512.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "1x",
|
"scale": "1x",
|
||||||
"size" : "512x512"
|
"size": "512x512",
|
||||||
|
"filename": "MikeMail-512.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idiom" : "mac",
|
"idiom": "mac",
|
||||||
"scale" : "2x",
|
"scale": "2x",
|
||||||
"size" : "512x512"
|
"size": "512x512",
|
||||||
|
"filename": "MikeMail-1024.png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info": {
|
||||||
"author" : "xcode",
|
"author": "xcode",
|
||||||
"version" : 1
|
"version": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 803 B |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -1,59 +1,258 @@
|
|||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// MikeMail
|
|
||||||
//
|
|
||||||
// Created by Dr. med. Michael Kübler on 06.11.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
let store: MailStore
|
||||||
@Query private var items: [Item]
|
@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 {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List {
|
AccountSidebar(accounts: store.accounts, folders: store.folders, selection: $selectedFolder)
|
||||||
ForEach(items) { item in
|
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 380)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} detail: {
|
} 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")
|
||||||
private func addItem() {
|
.toolbar {
|
||||||
withAnimation {
|
ToolbarItemGroup {
|
||||||
let newItem = Item(timestamp: Date())
|
Button("Abrufen", systemImage: "arrow.clockwise") {
|
||||||
modelContext.insert(newItem)
|
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) {
|
struct AccountSidebar: View {
|
||||||
withAnimation {
|
let accounts: [MailAccount]
|
||||||
for index in offsets {
|
let folders: [UUID: [MailFolder]]
|
||||||
modelContext.delete(items[index])
|
@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 {
|
struct AttachmentDocument: FileDocument {
|
||||||
ContentView()
|
static var readableContentTypes: [UTType] { [.data] }
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
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()) }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: " ", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,13 @@
|
|||||||
//
|
|
||||||
// MikeMailApp.swift
|
|
||||||
// MikeMail
|
|
||||||
//
|
|
||||||
// Created by Dr. med. Michael Kübler on 06.11.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct MikeMailApp: App {
|
struct MikeMailApp: App {
|
||||||
var sharedModelContainer: ModelContainer = {
|
@State private var store = MailStore()
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView(store: store)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.defaultSize(width: 1200, height: 800)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] == [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,91 @@
|
|||||||
//
|
import Foundation
|
||||||
// MikeMailTests.swift
|
|
||||||
// MikeMailTests
|
|
||||||
//
|
|
||||||
// Created by Dr. med. Michael Kübler on 06.11.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@testable import MikeMail
|
@testable import MikeMail
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct MikeMailTests {
|
struct MikeMailTests {
|
||||||
|
@Test func recipientSuggestionsAndInsertion() {
|
||||||
@Test func example() async throws {
|
let history = ["anna@example.com", "bert@example.com", "anna@work.de"]
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
#expect(RecipientCompletion.matches(in: history, input: "ANN") == ["anna@example.com", "anna@work.de"])
|
||||||
// Swift Testing Documentation
|
#expect(RecipientCompletion.matches(in: history, input: "anna@example.com, ann") == ["anna@work.de"])
|
||||||
// https://developer.apple.com/documentation/testing
|
#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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,30 @@
|
|||||||
//
|
|
||||||
// MikeMailUITests.swift
|
|
||||||
// MikeMailUITests
|
|
||||||
//
|
|
||||||
// Created by Dr. med. Michael Kübler on 06.11.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import XCUIAutomation
|
||||||
|
|
||||||
final class MikeMailUITests: XCTestCase {
|
final class MikeMailUITests: XCTestCase {
|
||||||
|
@MainActor
|
||||||
override func setUpWithError() throws {
|
func testMailWindowAndCompose() 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.
|
|
||||||
continueAfterFailure = false
|
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()
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["-ApplePersistenceIgnoreState", "YES"]
|
||||||
app.launch()
|
app.launch()
|
||||||
|
app.activate()
|
||||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
XCTAssertTrue(app.buttons["Neue Nachricht"].waitForExistence(timeout: 5), app.debugDescription)
|
||||||
// XCUIAutomation Documentation
|
XCTAssertTrue(app.staticTexts["Nachricht auswählen"].waitForExistence(timeout: 5), app.debugDescription)
|
||||||
// https://developer.apple.com/documentation/xcuiautomation
|
app.buttons["Neue Nachricht"].click()
|
||||||
}
|
XCTAssertTrue(app.buttons["Anhang hinzufügen"].waitForExistence(timeout: 5))
|
||||||
|
XCTAssertFalse(app.buttons["Senden"].isEnabled)
|
||||||
@MainActor
|
XCTAssertTrue(app.buttons["Adressbuch"].exists)
|
||||||
func testLaunchPerformance() throws {
|
XCTAssertTrue(app.textFields["recipientField"].exists)
|
||||||
// This measures how long it takes to launch your application.
|
app.buttons["Abbrechen"].click()
|
||||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
app.buttons["Konten"].click()
|
||||||
XCUIApplication().launch()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||