Files
archivmail/features/PROJ-52-vollstaendigkeits-reconciliation.md
T
sysopsandClaude Sonnet 5 30b7586d94 docs(PROJ-43,PROJ-52): Status auf Deployed setzen nach Produktiv-Deploy
Beide Features wurden auf 192.168.1.131 deployt (Commit 4c92587), inkl. der
zugehörigen QA-Bugfixes (Dry-Run-Adressmatching PROJ-43, days-Clamp PROJ-52).
Deployment-Abschnitte in den Feature-Specs und INDEX.md aktualisiert.

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

290 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<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 <Datum>" 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)
---
<!-- Sections below are added by subsequent skills -->
## 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:<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 <expected> (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:<id>`, `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).