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>
12 KiB
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_atam 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_pctinconfig.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
0ausgewiesen, 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_countsinkt,archived_countbleibt hoch →deltanegativ in "gute" Richtung, kein Alert (nur Rückgang vonarchived_countselbst 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(), Tabellereconciliation_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)), weiltenant_id/source_idNULL-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 ausemailsDISTINCT, 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-safeIS NOT DISTINCT FROM); Alert nur bei ≥7 Vortages-Datensätzen;archived < avg*(1-pct/100)→ Audit-Eintragreconciliation_anomaly(event viaaudit.EventReconciliationAnomaly).query.go:DashboardData()(letzte N Tage pro Quelle, tenant-gescoped, Alert-Flag +enough_data) undExportRows()(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, nilinternal/imap/importer.go+internal/imap/scheduler.go→imap, account_id (accountID durchfetchBatch/fetchSyncBatch/storeAndIndexdurchgereicht)internal/pop3/importer.go→pop3, account_idcmd/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){ "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) — CSVdate,tenant_id,source,expected_count,archived_count,delta, Audit-Eintragexport. 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 reconcilemuss ininstall.sh/update.shergänzt werden (devops-deploy). - Frontend: Dashboard-Kachel + TS-Typen in
src/lib/api/. - Kein lokaler
go buildmö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/deltasindnumber | null,missing: boolean). getReconciliation(days = 7)→GET /api/admin/reconciliation?days=7über den bestehendenrequest<T>-Wrapper (core.ts, credentials/401-Handling inklusive).exportReconciliationCSV(days = 30)→ direkterfetchauf/api/admin/reconciliation/export.csv(Blob-Download, Content-Disposition- Dateiname-Parsing, analogdownloadMailAttachment/exportDSGVORequestPDF).- Re-Exports in
src/lib/api/index.tsergänzt.
Neue Kachel src/components/admin/tabs/ReconciliationCard.tsx
- Self-fetching Client-Komponente (
useEffectbeim 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 viauseAuth("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 --noEmitfehlerfrei. Kein direktesfetch()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