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

13 KiB
Raw Blame History

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