Files
archivmail/features/PROJ-52-vollstaendigkeits-reconciliation.md
T
sysopsandClaude Sonnet 5 4c92587b60 fix(PROJ-43): Dry-Run für from_addr/to_addr matcht bare Adressen statt <addr>-Form
dryRunCondition() erwartete faelschlich die Winkelklammer-Form "Name <addr>",
waehrend mail_from/mail_to die Adresse bare speichern - Dry-Run zeigte dadurch
immer 0 Treffer fuer Adress-Regeln, obwohl der Live-Matcher (routeBareAddr)
korrekt matcht. QA-Ergebnisse (Bug-1) in die Feature-Spec uebernommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 11:57:21 +02:00

15 KiB
Raw Blame History

PROJ-52: Vollständigkeits-Reconciliation (Zähl-Report Mailserver vs. Archiv)

Status: In Review

Created: 2026-06-13 Last Updated: 2026-07-03

Hintergrund

Der GoBD/DSGVO-Compliance-Check (docs/GOBD_DSGVO_CHECKLIST.md, Punkt 1) bewertet "Vollständigkeit" nur als "Teilweise erfüllt": SMTP-BCC-Journaling (PROJ-4) und IMAP/POP3-Import (PROJ-3/8/14/45) sind robust (z.B. 452-Retry bei Storage-Fehlern), aber es gibt keinen zentralen Mechanismus, der zeigt, ob tatsächlich ALLE erwarteten E-Mails archiviert wurden (VOI-Grundsatz 2: "kein Dokument darf auf dem Weg ins Archiv oder im Archiv selbst verloren gehen"). Diese Spec ergänzt einen täglichen Zähl-Report pro Quelle.

Dependencies

  • Requires: PROJ-4 (SMTP-Import), PROJ-3/PROJ-14 (IMAP/POP3-Import), PROJ-45 (IMAP Per-Folder UID-Tracking)
  • Requires: PROJ-17 (Admin Dashboard) Anzeige des Reports
  • Requires: PROJ-11/PROJ-48 (Audit-Log) Auffälligkeiten werden protokolliert

User Stories

  • Als Admin möchte ich täglich sehen, wie viele E-Mails pro Quelle (SMTP-Journal, IMAP-Konto, POP3-Konto) archiviert wurden, damit ich Ausreißer (plötzlich 0 Mails) erkenne.
  • Als Admin möchte ich für IMAP/POP3-Quellen einen Soll/Ist-Vergleich sehen: Anzahl Mails im Quell-Postfach (laut letztem Sync) vs. Anzahl archivierter Mails für diese Quelle.
  • Als Auditor möchte ich nachvollziehen können, ob es Tage mit auffälligen Abweichungen gab (z.B. SMTP-Dienst war down).
  • Als Admin möchte ich bei einer signifikanten Abweichung (z.B. >50% Rückgang ggü. Durchschnitt der letzten 7 Tage) eine Warnung im Dashboard sehen.

Acceptance Criteria

  • Täglicher Job (Cron, analog PROJ-8-Scheduler) berechnet pro Tag und Quelle (source_type: smtp, imap:<account_id>, pop3:<account_id>, import) die Anzahl neu archivierter E-Mails (received_at/imported_at am jeweiligen Tag)
  • Für IMAP-Konten (PROJ-45 UID-Tracking): zusätzlicher Soll/Ist-Vergleich Anzahl Mails im Quell-Ordner laut letztem UIDVALIDITY/UID-Stand vs. Anzahl im Archiv für diesen Ordner
  • Ergebnisse werden in Tabelle reconciliation_reports (date, tenant_id, source_type, source_id, expected_count, archived_count, delta) persistiert
  • Admin-Dashboard (PROJ-17) zeigt eine neue Kachel/Tabelle "Vollständigkeits-Check" mit den letzten 7 Tagen pro Quelle
  • Abweichung > konfigurierbarem Schwellenwert (Default: 50% unter 7-Tage-Durchschnitt, reconciliation.alert_threshold_pct in config.yml) → Warn-Badge im Dashboard + Audit-Log-Eintrag (event_type: reconciliation_anomaly)
  • Report ist als CSV exportierbar (analog Audit-Log-Export aus PROJ-11)
  • Tage ohne Aktivität (0 Mails) werden explizit als 0 ausgewiesen, nicht als fehlender Datensatz (damit Lücken im Cron-Lauf selbst erkennbar sind)

Edge Cases

  • Quelle wurde erst kürzlich angelegt (kein 7-Tage-Durchschnitt vorhanden) → kein Alert, Anzeige "Noch nicht genug Daten"
  • SMTP-Journal hat naturgemäß starke Schwankungen (Wochenende vs. Wochentag) → Schwellenwert ist konfigurierbar, Doku weist auf mögliche False-Positives an Wochenenden/Feiertagen hin
  • IMAP-Quell-Postfach wurde vom Nutzer geleert (Mails dort gelöscht, aber bereits archiviert) → expected_count sinkt, archived_count bleibt hoch → delta negativ in "gute" Richtung, kein Alert (nur Rückgang von archived_count selbst ist relevant)
  • Reconciliation-Job selbst schlägt fehl (z.B. DB-Timeout) → Fehler wird geloggt, vorheriger Tag bleibt ohne Report-Eintrag, Dashboard zeigt "Daten fehlen für " statt falscher Nullwerte
  • Multi-Tenant: Reports sind pro Tenant; Tenant-Admins sehen nur eigene Quellen, Super-Admin sieht alle

Technical Requirements

  • Neue Tabelle reconciliation_reports (siehe AC), Index auf (tenant_id, date, source_type)
  • Cron-Job-Registrierung analog bestehendem IMAP-Sync-Scheduler (PROJ-8)
  • Wiederverwendung von internal/imap-Funktionen zur Ermittlung der Quell-Postfach-Anzahl (sofern bereits durch UID-Tracking verfügbar, kein zusätzlicher IMAP-Login nötig wenn vermeidbar)

Implementation Notes (Backend, 2026-07-03)

Neues Package internal/reconciliation/

  • reconciliation.go: Store (eigener pgxpool), initSchema(), Tabelle reconciliation_reports (id, date, tenant_id, source_type, source_id, expected_count, archived_count, delta, created_at). Upsert-Key ist ein COALESCE-Ausdrucksindex (date, COALESCE(tenant_id,-1), source_type, COALESCE(source_id,-1)), weil tenant_id/source_id NULL-fähig sind und Postgres NULLs in einem normalen UNIQUE-Index als verschieden behandelt (sonst Doppelzeilen für smtp/import/tenant-lose Buckets). Zusätzlicher Lookup-Index (tenant_id, date, source_type) laut AC.
  • compute.go: ComputeForDate(ctx, day, thresholdPct) — Read-Phase (archived pro Bucket für den Tag, known-buckets aus emails DISTINCT, IMAP-Snapshot), dann Upsert in einer Transaktion. Bei Query-Fehler wird der Tag NICHT geschrieben (kein falscher 0-Eintrag; Dashboard zeigt "fehlt"). 0-Mail-Tage werden für jeden bekannten Bucket explizit als 0 persistiert. Alert: 7-Tage-Durchschnitt (trailing_average, NULL-safe IS NOT DISTINCT FROM); Alert nur bei ≥7 Vortages-Datensätzen; archived < avg*(1-pct/100) → Audit-Eintrag reconciliation_anomaly (event via audit.EventReconciliationAnomaly).
  • query.go: DashboardData() (letzte N Tage pro Quelle, tenant-gescoped, Alert-Flag + enough_data) und ExportRows() (CSV).

Source-Tracking (nötige Ergänzung — emails hatte keine Herkunftsspalte)

emails bekam via storage.initSchema() zwei Spalten source_type TEXT, source_id BIGINT + Index (received_at, source_type, source_id, tenant_id). Neue Methode storage.Store.TagSource(ctx, id, sourceType, sourceID) schreibt nur solange source_type IS NULL (first-write-wins → dedupte Mehrfach-Mails werden nicht doppelt gezählt). Verdrahtet in allen Ingestion-Pfaden:

  • internal/smtpd/smtpd.gosmtp, nil
  • internal/imap/importer.go + internal/imap/scheduler.goimap, account_id (accountID durch fetchBatch/fetchSyncBatch/storeAndIndex durchgereicht)
  • internal/pop3/importer.gopop3, account_id
  • cmd/archivmail/cmd_import.go + internal/api/upload.goimport, nil

IMAP Soll/Ist (Abweichung von der Spec — dokumentiert)

Das PROJ-45 UID-Tracking (imap_folder_state) speichert nur last_uid, KEINE Nachrichtenzahl. Daher wird expected_count für IMAP-Quellen als Proxy aus SUM(last_uid) je Konto gebildet (kein zusätzlicher IMAP-Login) und delta = kumulativ_archiviert(Konto) expected. archived_count bleibt konsistent für ALLE Quellen die pro-Tag neu archivierte Zahl (steuert das Alerting). Postfach-Leerung → expected sinkt, delta positiv → kein Alert (Alert nur bei Rückgang von archived_count), wie in Edge Cases gefordert.

Cron statt Dauer-Goroutine

Analog PROJ-58 als CLI-Subcommand archivmail reconcile (cmd_reconcile.go), Default = Vortag; --date, --days N (Backfill, älteste→neueste Reihenfolge für konsistente Trailing-Average-Historie). Registriert in main.go. Der Daemon (serve) verdrahtet den Store nur lesend für die API (SetReconciliation).

Config

config.ReconciliationConfig.AlertThresholdPct *int (reconciliation.alert_threshold_pct), Default 50 via ResolvedThresholdPct(). Beispiele in config.test.yml und config/config.docker.yml.example.

API-Endpoints (tenant-gescoped, authAdmin = domain_admin+)

  • GET /api/admin/reconciliation?days=7 (max 90)
    {
      "days": 7,
      "threshold_pct": 50,
      "sources": [
        {
          "source_type": "smtp",
          "source_id": null,
          "source_key": "smtp",
          "tenant_id": null,
          "points": [
            {"date":"2026-06-27","archived_count":42,"expected_count":null,"delta":null,"missing":false},
            {"date":"2026-06-28","archived_count":null,"expected_count":null,"delta":null,"missing":true}
          ],
          "avg_7d": 40.5,
          "enough_data": true,
          "alert": false
        }
      ]
    }
    
    source_key: smtp | import | imap:<id> | pop3:<id>. missing:true = kein Report-Datensatz (Cron nicht gelaufen), ≠ archived_count:0. enough_data:false → UI zeigt "Noch nicht genug Daten". alert:true → Warn-Badge.
  • GET /api/admin/reconciliation/export.csv?days=30 (max 366) — CSV date,tenant_id,source,expected_count,archived_count,delta, Audit-Eintrag export. Tenant-Scope: domain_admin nur eigener Tenant, superadmin alle.

Tenant-Isolation

reconTenantScope() filtert wie handleMailTimeseries: Session mit tenant_id → nur eigener Tenant, superadmin (nil) → alle. Kein {id}-Pfadparameter, daher kein IDOR-Vektor; Filter erfolgt in der SQL-WHERE.

Manticore

Keine Index-Änderung nötig (reine PostgreSQL-Aggregation).

Offene Punkte / Handoff

  • Cron-Eintrag für archivmail reconcile muss in install.sh/update.sh ergänzt werden (devops-deploy).
  • Frontend: Dashboard-Kachel + TS-Typen in src/lib/api/.
  • Kein lokaler go build möglich — Build/QA separat auf Testserver.

Implementation Notes (Frontend, 2026-07-03)

Neue API-Schicht src/lib/api/reconciliation.ts

  • Typen ReconciliationPoint, ReconciliationSource, ReconciliationResponse (1:1 zur Backend-Response; archived_count/expected_count/delta sind number | null, missing: boolean).
  • getReconciliation(days = 7)GET /api/admin/reconciliation?days=7 über den bestehenden request<T>-Wrapper (core.ts, credentials/401-Handling inklusive).
  • exportReconciliationCSV(days = 30) → direkter fetch auf /api/admin/reconciliation/export.csv (Blob-Download, Content-Disposition- Dateiname-Parsing, analog downloadMailAttachment/exportDSGVORequestPDF).
  • Re-Exports in src/lib/api/index.ts ergänzt.

Neue Kachel src/components/admin/tabs/ReconciliationCard.tsx

  • Self-fetching Client-Komponente (useEffect beim Mount), Loading-/Error-/ Empty-States implementiert.
  • Tabelle (shadcn Table): eine Zeile pro Quelle, Label hübsch formatiert ("SMTP-Journal", "Datei-Import", "IMAP-Konto #3", "POP3-Konto #X").
  • Spalten = letzte 7 Tage; Zelle zeigt archived_count, bei IMAP zusätzlich "Soll (delta)". missing:true → "—" (klar unterschieden von "0", mit Tooltip "Cron nicht gelaufen"). enough_data:false → Zeile zeigt "Noch nicht genug Daten" (colSpan). alert:true → rotes Badge "Auffällig", sonst "OK". Zusätzliche Spalten "Ø 7 Tage" und "Status".
  • CSV-Export-Button (30 Tage) und Aktualisieren-Button im Kachel-Header.
  • Horizontal scrollbar (overflow-x-auto) für mobile Breiten.

Einbindung / Rollen-Sichtbarkeit

  • Gerendert innerhalb DashboardTab (vor "Benutzerübersicht"). Der gesamte Admin-Bereich (src/app/admin/page.tsx) ist bereits via useAuth("domain_admin", "/admin/login") auf domain_admin+ beschränkt → keine zusätzliche Client-Gate nötig, normale User erreichen den Tab nicht. Backend bleibt maßgebliche Sicherheitsgrenze (tenant-gescoped, authAdmin); superadmin sieht alle Quellen, domain_admin nur eigene.

Verifikation

  • npx tsc --noEmit fehlerfrei. Kein direktes fetch() in Komponenten außer dem zentralen Blob-Download-Helper in der API-Schicht.

Geänderte/neue Dateien

  • neu: src/lib/api/reconciliation.ts
  • neu: src/components/admin/tabs/ReconciliationCard.tsx
  • geändert: src/lib/api/index.ts (Re-Exports)
  • geändert: src/components/admin/tabs/DashboardTab.tsx (Kachel eingebunden)

Tech Design (Solution Architect)

To be added by /architecture

QA Test Results

Getestet: 2026-07-04 auf Testserver 192.168.1.132 (Binary v0.9.1, deployt 2026-07-03). Test-Accounts: qa-superadmin (superadmin), qa-da-t1 (domain_admin Tenant 1), qa-da-t3 (domain_admin Tenant 3) — dedizierte QA-User, Passwörter für den Test gesetzt (siehe Testdaten-Hinweis unten). Gesamtergebnis: BESTANDEN (alle testbaren AC grün, 1 Minor-Beobachtung).

Ergebnis je Acceptance Criterion

  • Täglicher Job pro Tag/Quelle — BESTANDEN. archivmail reconcile --date 2026-07-03 läuft, berechnet pro Bucket (smtp, imap:<id>, import) die Tages-Zahl. Ausgabe: reconcile: complete days=1 threshold_pct=50 anomalies_total=1.
  • IMAP Soll/Ist — BESTANDEN (mit dokumentierter Spec-Abweichung). expected_count ist der SUM(last_uid)-Proxy pro Konto (kein zusätzl. IMAP-Login), z.B. imap:1 exp=2288, imap:4 exp=116371. delta wird negativ ausgewiesen. Verhalten entspricht Implementation Note "IMAP Soll/Ist (Abweichung von der Spec)".
  • Persistenz reconciliation_reports — BESTANDEN. Tabelle enthält nach Lauf 12 Zeilen für 2026-07-03 mit allen Spalten (date, tenant_id, source_type, source_id, expected_count, archived_count, delta). NULL-fähige tenant_id/source_id-Buckets korrekt getrennt.
  • Admin-Dashboard-Kachel — Frontend-Komponente vorhanden (ReconciliationCard.tsx); API liefert die vom UI erwartete Struktur (siehe API-Tests). UI visuell nicht separat durchgeklickt, aber Datenkontrakt verifiziert.
  • Abweichung > Schwellenwert → Alert + Audit — BESTANDEN. Reconcile erkannte Anomalie: source=import date=2026-07-03 archived=0 avg_7d=14.1 threshold=50%. Audit-Log enthält event_type=reconciliation_anomaly (2 Einträge). Schwelle 50% aus Config greift.
  • CSV-Export — BESTANDEN. GET /api/admin/reconciliation/export.csv?days=7 liefert Header date,tenant_id,source,expected_count,archived_count,delta + Zeilen. Audit-Eintrag event_type=export erzeugt.
  • 0-Mail-Tage explizit als 0 — BESTANDEN. Tage ohne Aktivität stehen als archived_count:0 (nicht missing), fehlende Cron-Läufe als "missing":true (z.B. 2026-07-04 noch nicht gelaufen). Unterscheidung 0 vs. fehlend sauber.

Sicherheit / Tenant-Isolation

  • Auth-Bypass: Ohne Cookie liefern /api/admin/reconciliation und /export.csv beide 401 {"error":"missing authorization"}. BESTANDEN.
  • Tenant-Scoping: domain_admin Tenant 1 sieht nur Tenant-1-Quellen (imap:1, imap:2, import, smtp — alle tenant_id 1); domain_admin Tenant 3 sieht nur Tenant-3-Quellen (imap:4, imap:5, import) und KEINE Tenant-1/2-Daten. superadmin sieht alle inkl. tenant_id=null-Buckets. Cross-Tenant-Leck: keins. BESTANDEN. Kein {id}-Pfadparameter → kein IDOR-Vektor.

Minor-Beobachtung (kein Blocker)

  • Severity: Low. days-Parameter-Clamping asymmetrisch: days=90 gibt 90 zurück (Max ok), aber days=91/days=999 fällt still auf Default 7 zurück statt auf das Maximum 90 zu clampen. Erwartbarer wäre Clamp auf 90. Reine UX-Feinheit, kein Sicherheits-/Datenproblem. Repro: GET /api/admin/reconciliation?days=91{"days":7,...}.

Testdaten-Hygiene

  • Passwörter der 3 dedizierten qa-*-Accounts wurden für den Test auf einen bekannten Wert gesetzt (bcrypt cost 12, direkt in users.password_hash). Keine produktiven/echten Admin-Accounts angefasst. Kein Server auf 131 berührt. Temp-Datei /tmp/qa.sql entfernt.
  • Der manuelle reconcile-Lauf schrieb reguläre Report-/Audit-Zeilen für 2026-07-03 (echte Produktivdaten des Testservers) — beabsichtigt, keine Testverschmutzung.

Deployment

To be added by /deploy