7e68c7ab02
- 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>
13 KiB
13 KiB
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/keyfilegeladen (Pfad konfigurierbar) - Key-Datei:
chmod 400, Ownerarchivmail-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-xapianoder 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 inastore/) - Pfadreferenz zur
.m-Datei instore/
- Xapian-Datenbank liegt unverschlüsselt auf Disk (enthält nur Text-Terme, keinen vollständigen Body)
- Mailkörper (ohne Anhänge) als
- 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
-
AES-256-GCM encryption in
internal/storage/storage.go:- Key loaded from file at
cfg.Storage.Keyfilepath orARCHIVMAIL_KEYenv 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 ciphertextLoad()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}
- Key loaded from file at
-
PostgreSQL
emailsmetadata 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 onsubject Save()inserts metadata via mailparser after writing file (ON CONFLICT DO NOTHING)Delete()also removes DB rowStats()andFirstAndLastMail()use DB queries when available (fast), fall back to FS walk- New methods:
SaveMeta(),SetIndexedAt(),IsIndexed(),WalkStore()
- Schema:
-
Storage constructor changed from
New(dir string)toNew(cfg storage.Config):Configstruct:Dir,Keyfile,DSN- All callers updated:
main.go,cmd_import.go,cmd_export.go Close()method added to release DB pool
-
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 fullStart()launches background goroutine;Stop()drains queue and blocks until done- Serialises Xapian writes (one writer at a time)
- Buffered channel queue (configurable size via
-
SMTP daemon integration:
SetIndexCallback()onsmtpd.Daemon- After each successfully stored mail, callback submits to async worker
- Wired in
main.go
-
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}(noserver_id/customer_idhierarchy) per user decision - Attachment dedup store (
astore/) not yet implemented (body + attachments stored together in.mfiles as before) - No separate
attachmentsoremail_attachmentsDB 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