feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen

- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg)
- Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist
- Feature-Status auf In Review gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
@@ -0,0 +1,194 @@
# PROJ-5: E-Mail-Speicherung & Volltext-Indexierung
## Status: In Progress
**Created:** 2026-03-12
**Last Updated:** 2026-03-12
## 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 |
## QA Test Results
_To be added by /qa_
## Deployment
_To be added by /deploy_