# PROJ-5: E-Mail-Speicherung & Volltext-Indexierung ## Status: In Review **Created:** 2026-03-12 **Last Updated:** 2026-03-14 ## Dependencies - None (Basis-Feature, wird von Import-Features genutzt) ## User Stories - Als System möchte ich E-Mails unveränderlich (immutable) speichern, damit die Archivintegrität gewährleistet ist. - Als System möchte ich E-Mail-Inhalte (Betreff, Absender, Empfänger, Body, Anhang-Namen) volltext-indexieren, damit schnelle Suche möglich ist. - Als Admin möchte ich den Speicherverbrauch einsehen können, damit ich die Kapazität planen kann. - Als System möchte ich Anhänge getrennt vom E-Mail-Body speichern, damit der Speicher effizient genutzt wird. ## Acceptance Criteria - [ ] Jede E-Mail wird mit ihrer originalen MIME-Struktur gespeichert (kein Datenverlust) - [ ] Metadaten in PostgreSQL: `message_id`, `from`, `to`, `cc`, `subject`, `date`, `size` (Bytes), Attachment-Infos (Dateiname, MIME-Type, Größe, Hash) - [ ] Kein E-Mail-Body in der DB – Body liegt ausschließlich in `/var/archivmail/store/` verschlüsselt auf Disk - [ ] Volltext-Index umfasst: Betreff, Absender, Empfänger, CC, BCC, Plain-Text-Body - [ ] Anhang-Dateinamen und MIME-Types werden indexiert (Inhalt von Anhängen optional) - [ ] Deduplizierung: Gleiche Message-ID wird nur einmal gespeichert - [ ] SHA-256-Hash des originalen RFC-2822-Inhalts für Integritätsprüfung gespeichert - [ ] Admin-Dashboard zeigt: Gesamtanzahl E-Mails, Speicherverbrauch (Store + Astore) - [ ] Mailkörper gespeichert unter `/var/archivmail/store////xxxxx.m` (AES-256-GCM verschlüsselt) - [ ] Anhänge gespeichert unter `/var/archivmail/astore/` (AES-256-GCM verschlüsselt) - [ ] Anhänge werden dedupliziert: gleicher Hash → eine Datei, mehrere Referenzen in der DB - [ ] Verschlüsselungsschlüssel wird beim Start aus `/etc/archivmail/keyfile` geladen (Pfad konfigurierbar) - [ ] Key-Datei: `chmod 400`, Owner `archivmail`-Systembenutzer - [ ] Server verweigert Start wenn Key-Datei fehlt, nicht lesbar oder Schlüssel ≠ 32 Byte nach Base64-Dekodierung - [ ] Xapian-Index enthält keinen vollständigen E-Mail-Body (nur Terme/Tokens) - [ ] PostgreSQL speichert ausschließlich Metadaten + Dateipfade – kein E-Mail-Body in der DB ## Edge Cases - E-Mail ohne Body (nur Anhang) → Body als leer speichern, Anhang indexieren - HTML-Body ohne Plain-Text-Alternative → HTML zu Plain-Text konvertieren für Index - E-Mail mit sehr vielen Empfängern (> 500) → TO/CC/BCC werden vollständig gespeichert - Sonderzeichen und Nicht-ASCII in Headern (RFC 2047 encoded) → dekodieren - Anhang-Deduplizierung: gleicher Inhalt in 1000 E-Mails → nur eine Datei in `astore/`, DB zählt Referenzen; Löschen einer E-Mail dekrementiert Referenzzähler, Datei erst bei 0 gelöscht - Speicherplatz voll → Import-Fehler mit klarer Meldung, keine partiellen Einträge - Verschlüsselungsschlüssel fehlt beim Start → Server startet nicht, klare Fehlermeldung - Schlüssel-Rotation: alte `.enc`-Dateien müssen mit neuem Schlüssel re-verschlüsselt werden (Admin-Tool, nicht automatisch) ## Technical Requirements - **Volltext-Index: Xapian** (via CGo-Bindings, z.B. `github.com/rcaught/go-xapian` oder direkte CGo-Integration) - Xapian-Datenbank liegt auf dem Dateisystem (kein externer Dienst nötig) - Felder als Xapian-Terms und -Values indexiert: Subject, From, To, CC, BCC, Body - Stemming für Deutsch und Englisch (Xapian Snowball Stemmer) - Anhang-Dateinamen als zusätzliche Terms indexiert - **Speicherung: Verschlüsselt im Dateisystem (AES-256-GCM)** - Mailkörper (ohne Anhänge) als `.m`-Datei: ``` /var/archivmail/store////xxxxx.m ``` - Anhänge dedupliziert in separatem Store (ein Anhang = eine Datei, unabhängig wie oft er vorkommt): ``` /var/archivmail/astore/ ``` - Hash = SHA-256 des Inhalts → dient gleichzeitig als Pfad und Integritätsprüfung - Beide Stores AES-256-GCM verschlüsselt auf Disk - Verschlüsselungsschlüssel (32 Byte) aus dedizierter Key-Datei: `/etc/archivmail/keyfile` - Dateiformat: Base64-kodierter 32-Byte-Schlüssel, eine Zeile - Dateiberechtigungen: `chmod 400`, Owner: `archivmail` (Systembenutzer des Dienstes) - Pfad zur Key-Datei konfigurierbar in `config.yml` (`encryption.keyfile`) - Schlüssel wird beim Start einmalig in den Prozessspeicher geladen – danach keine Disk-Zugriffe mehr auf die Key-Datei - Server verweigert Start wenn Key-Datei fehlt, nicht lesbar oder Inhalt nicht exakt 32 Byte (nach Base64-Dekodierung) - PostgreSQL speichert folgende Metadaten (kein Mail-Body): - `message_id`, `from`, `to`, `cc`, `subject`, `date`, `size` - Attachment-Tabelle: `filename`, `mime_type`, `size`, `hash` (→ Pfad in `astore/`) - Pfadreferenz zur `.m`-Datei in `store/` - Xapian-Datenbank liegt unverschlüsselt auf Disk (enthält nur Text-Terme, keinen vollständigen Body) - Xapian-Schreibzugriffe serialisiert (WritableDatabase nicht thread-safe) – Background-Worker-Queue - Indexierung innerhalb 5 Sekunden nach E-Mail-Eingang - Retention-Policy: konfigurierbare automatische Löschung alter E-Mails (DSGVO) löscht sowohl DB-Eintrag als auch Xapian-Dokument --- ## Tech Design (Solution Architect) ### Komponentenstruktur ``` archivmail (Go-Binary) │ ├── Storage Coordinator ← Einziger Eintrittspunkt für alle Schreibvorgänge │ ├── MIME Parser ← Zerlegt eingehende E-Mail in Body + Anhänge │ ├── Mail Store ← Schreibt .m-Datei verschlüsselt auf Disk │ │ └── Encryption Layer ← AES-256-GCM (Schlüssel aus /etc/archivmail/keyfile) │ ├── Attachment Store ← Schreibt Anhänge in astore/, prüft Duplikate per Hash │ │ └── Encryption Layer ← gleiche AES-256-GCM Instanz │ └── Metadata Writer ← Schreibt Metadaten in PostgreSQL │ ├── Index Worker (Hintergrund) ← Serialisierte Warteschlange für Xapian-Schreibzugriffe │ ├── Text Extractor ← HTML → Plain-Text, RFC 2047 Header-Dekodierung │ └── Xapian WritableDatabase ← Ein Schreiber gleichzeitig (Queue verhindert Konflikte) │ └── Xapian ReadonlyDatabase ← Beliebig viele parallele Lesezugriffe (Suche) ``` ### Datenfluss: E-Mail eingehend ``` E-Mail (RFC 2822) – primär via SMTP-BCC │ ▼ MIME Parser ┌────┴──────────────────────────────────┐ │ │ ▼ ▼ Body (ohne Anhänge) Anhänge (0..n) │ │ ├─ SHA-256(Body) → Hash ├─ SHA-256(Anhang) → Hash ├─ AES-256-GCM verschlüsseln ├─ Hash in astore/ vorhanden? → nur ref_count++ └─ /var/archivmail/store/ ├─ AES-256-GCM verschlüsseln ///x.m └─ /var/archivmail/astore/ + ref_count++ in DB │ │ └──────────────┬────────────────────────┘ ▼ PostgreSQL (Metadaten) message_id, from, to, cc, subject, date, size, store_path, sha256, indexed_at = NULL │ ▼ Index Worker Queue (Channel) │ ▼ Text Extractor (HTML→Text, Encoding-Normalisierung) │ ▼ Xapian WritableDatabase Subject, From, To, CC, Body als Terms indexed_at = NOW() in PostgreSQL ``` ### Datenmodell **Tabelle `emails`** – eine Zeile pro archivierter E-Mail: | Feld | Beschreibung | |------|-------------| | `message_id` | RFC-2822 Message-ID (Primärschlüssel, Duplikatschutz) | | `from` | Absender | | `to` | Empfänger | | `cc` | CC-Empfänger | | `subject` | Betreff | | `date` | Sendedatum (UTC) | | `size` | Größe des Originals in Bytes | | `store_path` | Pfad zur .m-Datei | | `sha256` | Hash des Originals (Integritätsprüfung) | | `indexed_at` | Zeitpunkt der Xapian-Indexierung (NULL = ausstehend) | **Tabelle `attachments`** – ein Eintrag pro einzigartigem Anhang: | Feld | Beschreibung | |------|-------------| | `hash` | SHA-256 des Inhalts (= Dateiname in astore/) | | `filename` | Originaldateiname | | `mime_type` | z.B. application/pdf | | `size` | Größe in Bytes | | `ref_count` | Anzahl E-Mails die diesen Anhang referenzieren | **Tabelle `email_attachments`** – Verknüpfung E-Mail ↔ Anhang (n:m) ### Technische Entscheidungen | Entscheidung | Begründung | |---|---| | Body und Anhänge getrennt | Anhang-Deduplizierung: gleicher PDF in 1000 Mails = eine Datei auf Disk | | SHA-256 als Dateipfad | Hash dient gleichzeitig als Pfad und Integritätsprüfung – kein separates Mapping | | AES-256-GCM | Authentifizierte Verschlüsselung erkennt Dateimanipulationen (Tamper Detection) | | Index Worker Queue | Xapian erlaubt nur einen Schreiber – Queue serialisiert ohne Datenverlust | | `indexed_at` NULL-Flag | Nach Absturz können nicht-indexierte Mails beim Neustart nachindexiert werden | | Metadaten in PostgreSQL, Body auf Disk | Filterabfragen (Datum, Absender) ohne Disk-Zugriff; Body nur bei Bedarf lesen | | Storage Coordinator als Single Entry Point | Alle Importwege (SMTP, IMAP, EML/MBOX) rufen dieselbe Schreiblogik auf | ### Go-Abhängigkeiten | Paket | Zweck | |---|---| | Xapian CGo-Bindings | Volltext-Index | | `pgx` | PostgreSQL-Treiber | | `crypto/aes`, `crypto/cipher` | AES-256-GCM (Go Stdlib) | | `crypto/sha256` | Hashing (Go Stdlib) | | `mime`, `mime/multipart` | MIME-Parsing (Go Stdlib) | | `golang.org/x/net/html` | HTML → Plain-Text für Index | ## Implementation Notes (2026-03-14) ### What was built 1. **AES-256-GCM encryption** in `internal/storage/storage.go`: - Key loaded from file at `cfg.Storage.Keyfile` path or `ARCHIVMAIL_KEY` env var - Supports base64-encoded or raw 32-byte key files - If no keyfile configured, stores unencrypted (backwards compatible for dev) - `Save()` encrypts with random 12-byte nonce prepended to ciphertext - `Load()` decrypts transparently; falls back to raw read if decryption fails (pre-encryption files) - SHA-256 dedup based on **plaintext** content (hash before encrypt) - Same flat file path `store/{id[:2]}/{id}` 2. **PostgreSQL `emails` metadata table** auto-created at startup: - Schema: `id TEXT PK, received_at, mail_from, mail_to, subject, size_bytes, has_attach, indexed_at` - Indexes on `received_at`, `mail_from`, and GIN on `subject` - `Save()` inserts metadata via mailparser after writing file (ON CONFLICT DO NOTHING) - `Delete()` also removes DB row - `Stats()` and `FirstAndLastMail()` use DB queries when available (fast), fall back to FS walk - New methods: `SaveMeta()`, `SetIndexedAt()`, `IsIndexed()`, `WalkStore()` 3. **Storage constructor changed** from `New(dir string)` to `New(cfg storage.Config)`: - `Config` struct: `Dir`, `Keyfile`, `DSN` - All callers updated: `main.go`, `cmd_import.go`, `cmd_export.go` - `Close()` method added to release DB pool 4. **Async Index Worker** in `internal/index/worker.go`: - Buffered channel queue (configurable size via `config.Index.AsyncQueueSize`) - `Submit()` is non-blocking; drops + warns if queue full - `Start()` launches background goroutine; `Stop()` drains queue and blocks until done - Serialises Xapian writes (one writer at a time) 5. **SMTP daemon integration**: `SetIndexCallback()` on `smtpd.Daemon` - After each successfully stored mail, callback submits to async worker - Wired in `main.go` 6. **Backfill at startup** in `main.go`: - Runs in background goroutine - Walks store directory, parses each file, upserts DB metadata - Submits un-indexed emails (`indexed_at IS NULL`) to the async worker - Logs progress every 100 files ### Deviations from spec - Store path kept flat `store/{id[:2]}/{id}` (no `server_id/customer_id` hierarchy) per user decision - Attachment dedup store (`astore/`) not yet implemented (body + attachments stored together in `.m` files as before) - No separate `attachments` or `email_attachments` DB tables yet (deferred to future iteration) - IMAP importer still uses synchronous `IndexSync()` directly (not routed through async worker yet) ## QA Test Results _To be added by /qa_ ## Deployment _To be added by /deploy_