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
+39
View File
@@ -359,9 +359,48 @@ func (s *Store) initSchema(ctx context.Context) error {
ALTER TABLE emails ADD COLUMN IF NOT EXISTS marked_for_deletion_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_emails_marked_for_deletion ON emails (marked_for_deletion) WHERE marked_for_deletion = TRUE;
`)
if err != nil {
return err
}
// PROJ-52: ingestion source tracking for the completeness reconciliation
// report. source_type is one of 'smtp', 'imap', 'pop3', 'import'; source_id
// holds the IMAP/POP3 account ID (NULL for smtp/import). Both are NULL for
// legacy mails archived before this migration — the reconciliation job
// buckets those as 'import'. The composite index accelerates the per-day,
// per-source GROUP BY the reconciliation job runs.
_, err = s.db.Exec(ctx, `
ALTER TABLE emails ADD COLUMN IF NOT EXISTS source_type TEXT;
ALTER TABLE emails ADD COLUMN IF NOT EXISTS source_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_emails_source_recon ON emails (received_at, source_type, source_id, tenant_id);
`)
return err
}
// TagSource records the ingestion channel of an archived mail (PROJ-52).
// sourceType is one of 'smtp', 'imap', 'pop3', 'import'; sourceID holds the
// IMAP/POP3 account ID (nil for smtp/import).
//
// First-write-wins: the update only sets the columns while source_type IS NULL.
// A mail deduplicated across channels (SHA-256 / Message-ID dedup in Save) keeps
// the source of its first ingestion, so the reconciliation counts never
// double-count a re-delivered mail. Errors are non-fatal for the intake path —
// callers log and continue so a reconciliation-metadata write never blocks
// archival (GoBD completeness of the mail itself takes precedence).
func (s *Store) TagSource(ctx context.Context, id, sourceType string, sourceID *int64) error {
if s.db == nil {
return nil
}
_, err := s.db.Exec(ctx, `
UPDATE emails SET source_type = $2, source_id = $3
WHERE id = $1 AND source_type IS NULL
`, id, sourceType, sourceID)
if err != nil {
return fmt.Errorf("storage: tag source: %w", err)
}
return nil
}
// ── Core operations ───────────────────────────────────────────────────────
// Save writes raw email bytes to storage. The ID is the hex-encoded SHA256 of