# PROJ-52: Vollständigkeits-Reconciliation (Zähl-Report Mailserver vs. Archiv) ## Status: Deployed **Created:** 2026-06-13 **Last Updated:** 2026-07-04 ## 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:`, `pop3:`, `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.go` → `smtp`, nil - `internal/imap/importer.go` + `internal/imap/scheduler.go` → `imap`, account_id (accountID durch `fetchBatch`/`fetchSyncBatch`/`storeAndIndex` durchgereicht) - `internal/pop3/importer.go` → `pop3`, account_id - `cmd/archivmail/cmd_import.go` + `internal/api/upload.go` → `import`, 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) ```json { "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:` | `pop3:`. `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`-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 - [x] **Täglicher Job pro Tag/Quelle** — BESTANDEN. `archivmail reconcile --date 2026-07-03` läuft, berechnet pro Bucket (`smtp`, `imap:`, `import`) die Tages-Zahl. Ausgabe: `reconcile: complete days=1 threshold_pct=50 anomalies_total=1`. - [x] **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)". - [x] **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. - [x] **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. - [x] **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. - [x] **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. - [x] **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 **Deployt:** 2026-07-04 auf Produktivserver 192.168.1.131 via `update.sh` (Commit 4c92587, zusammen mit PROJ-43 im selben Deploy). Enthält den QA-Fix für das Minor-Finding "days-Clamping" (`internal/api/reconciliation_handlers.go`: `days>90` wird jetzt auf 90 geclampt statt still auf den Default 7 zurückzufallen). - Backend ✓ läuft (`archivmail`, Health `/api/health` → `{"status":"ok","version":"0.9.1"}`) - Frontend ✓ läuft (`archivmail-web`) - Smoke-Test: `GET /api/admin/reconciliation` ohne Auth → `401` (Endpoint erreichbar, Auth-Gate aktiv). Rollen-/Funktionstest ist QA-Scope, nicht Teil dieses Deploy-Checks. - Cron-Eintrag für `archivmail reconcile` bereits vorhanden in `/etc/cron.d/archivmail` (Zeile "Vollständigkeits-Reconciliation (PROJ-52)", nachts 04:10 Uhr, eingespielt via `update.sh` Schritt "Spiele Cron-Jobs ein..."). Kein offener Handoff-Punkt mehr — der ursprünglich genannte Cron-Handoff wurde bereits in einem früheren Commit (`e91206c chore(PROJ-52): Cron-Job für Reconciliation in /etc/cron.d/archivmail ergänzen`) erledigt und beim heutigen Deploy verifiziert (Datei auf 131 geprüft, Zeile vorhanden).