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>
290 lines
16 KiB
Markdown
290 lines
16 KiB
Markdown
# 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).
|