Files
archivmail/features/PROJ-5-speicherung-und-indexierung.md
T
sysops 7e68c7ab02 feat(PROJ-5): AES-256-GCM Verschlüsselung, PostgreSQL Metadaten, Async Index Worker
- Storage: AES-256-GCM Verschlüsselung (keyfile, graceful fallback bei fehlendem Key)
- Storage: PostgreSQL emails-Tabelle mit Auto-Migration
- Storage: Save/Delete/Stats/FirstAndLastMail nutzen DB wenn verfügbar
- Index: Async IndexWorker (Go-Channel, Queue 1000, non-blocking Submit)
- SMTP: IndexCallback für async Indexierung nach Mail-Eingang
- main: Backfill beim Start (40 Mails migriert + indexiert)
- Bestehende Mails werden transparent entschlüsselt (Fallback auf Raw)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:26:50 +01:00

243 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<server_id>/<customer_id>/<hash>/xxxxx.m` (AES-256-GCM verschlüsselt)
- [ ] Anhänge gespeichert unter `/var/archivmail/astore/<hash>` (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/<server_id>/<customer_id>/<hash>/xxxxx.m
```
- Anhänge dedupliziert in separatem Store (ein Anhang = eine Datei, unabhängig wie oft er vorkommt):
```
/var/archivmail/astore/<hash>
```
- 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
<server>/<customer>/<hash>/x.m └─ /var/archivmail/astore/<hash>
+ 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_