bb963a796f
- seedDefaultUsers: generiert kryptographisch zufällige Passwörter (crypto/rand) statt hartkodiertes "archivmailrockz" — Passwörter werden einmalig im Terminal angezeigt und können danach nicht wiederhergestellt werden - generateJTI: verwendet crypto/rand (16 Byte, hex) statt time.UnixNano XOR deadbeef Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
243 lines
13 KiB
Markdown
243 lines
13 KiB
Markdown
# PROJ-5: E-Mail-Speicherung & Volltext-Indexierung
|
||
|
||
## Status: Deployed
|
||
**Created:** 2026-03-12
|
||
**Last Updated:** 2026-03-17
|
||
|
||
## 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_
|