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

Täglicher Cron-Job (archivmail reconcile) berechnet pro Tenant/Quelle
(SMTP-Journal, IMAP-Konto, POP3-Konto, Datei-Import) archivierte Mail-Zahlen,
für IMAP zusätzlich einen Soll/Ist-Vergleich via UID-Tracking. Abweichungen
über Schwellenwert erzeugen Audit-Log-Warnung. Neue Admin-Dashboard-Kachel
"Vollständigkeits-Check" (letzte 7 Tage, Warn-Badge, CSV-Export).

Schließt die "teilweise erfüllt"-Lücke bei Vollständigkeit im
GoBD/DSGVO-Compliance-Check (VOI-Grundsatz 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-03 22:48:42 +02:00
co-authored by Claude Sonnet 5
parent b286352d07
commit be93614c9f
24 changed files with 1577 additions and 10 deletions
+1 -1
View File
@@ -68,7 +68,7 @@
| PROJ-49 | Verschlüsselungspflicht at-rest (Healthcheck & Warnung) | Deployed | [PROJ-49](PROJ-49-verschluesselungspflicht.md) | 2026-06-13 |
| PROJ-50 | DSGVO-Löschersuchen für Mail-Inhalte (GoBD-Vorrang) | Deployed | [PROJ-50](PROJ-50-dsgvo-loeschersuchen.md) | 2026-06-13 |
| PROJ-51 | Aufbewahrungsfristen nach Dokumentenart (Retention-Kategorien) | Deployed | [PROJ-51](PROJ-51-retention-kategorien.md) | 2026-06-13 |
| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | Planned | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 |
| PROJ-52 | Vollständigkeits-Reconciliation (Zähl-Report) | In Review | [PROJ-52](PROJ-52-vollstaendigkeits-reconciliation.md) | 2026-06-13 |
| PROJ-53 | Konfigurierbare Listenanzahl pro Seite | Deployed | [PROJ-53](PROJ-53-konfigurierbare-listenanzahl.md) | 2026-06-14 |
| PROJ-54 | Fix Listenansicht/Pagination für Rolle "user" (Nachbesserung PROJ-6/PROJ-21) | Deployed | [PROJ-54](PROJ-54-fix-listenansicht-total.md) | 2026-06-14 |
| PROJ-55 | Fix Tenant-Isolation für Rolle "auditor" + Audit-Log (Sicherheitsbug, DSGVO-relevant) | Deployed | [PROJ-55](PROJ-55-fix-auditor-tenant-isolation.md) | 2026-06-21 |
@@ -0,0 +1,224 @@
# 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 <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
_To be added by /qa_
## Deployment
_To be added by /deploy_