# 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).
