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>
121 lines
4.1 KiB
Go
121 lines
4.1 KiB
Go
// Package reconciliation implements the daily completeness reconciliation
|
|
// report (PROJ-52). It counts newly archived mails per source (SMTP journal,
|
|
// IMAP account, POP3 account, bulk import) and per day, persists the counts in
|
|
// the reconciliation_reports table, and flags significant drops against the
|
|
// trailing 7-day average via the audit log.
|
|
//
|
|
// The reconciliation is deliberately a read-only observer of the emails table
|
|
// plus the IMAP UID-tracking state (imap_folder_state, PROJ-45). It never
|
|
// mutates archived mail content and issues no additional IMAP logins — the
|
|
// IMAP soll/ist comparison reuses the UID high-water marks already persisted by
|
|
// the sync scheduler.
|
|
package reconciliation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"archivmail/internal/audit"
|
|
)
|
|
|
|
// Store owns the reconciliation_reports table and the reconciliation logic.
|
|
// It uses its own connection pool so the CLI cron command and the daemon can
|
|
// both operate it independently.
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
audlog *audit.Logger // optional; when nil, anomalies are only logged
|
|
}
|
|
|
|
// Report is a single persisted reconciliation row for one day and one source.
|
|
type Report struct {
|
|
Date time.Time `json:"date"`
|
|
TenantID *int64 `json:"tenant_id"`
|
|
SourceType string `json:"source_type"`
|
|
SourceID *int64 `json:"source_id"`
|
|
ExpectedCount *int64 `json:"expected_count"`
|
|
ArchivedCount int64 `json:"archived_count"`
|
|
Delta *int64 `json:"delta"`
|
|
}
|
|
|
|
// New connects to PostgreSQL and initialises the reconciliation schema.
|
|
func New(dsn string, logger *slog.Logger) (*Store, error) {
|
|
pool, err := pgxpool.New(context.Background(), dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reconciliation: connect: %w", err)
|
|
}
|
|
s := &Store{pool: pool, logger: logger}
|
|
if err := s.initSchema(context.Background()); err != nil {
|
|
pool.Close()
|
|
return nil, fmt.Errorf("reconciliation: init schema: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// SetAuditLogger wires an audit.Logger so anomalies are persisted as
|
|
// tenant-visible `reconciliation_anomaly` audit entries. Optional.
|
|
func (s *Store) SetAuditLogger(a *audit.Logger) { s.audlog = a }
|
|
|
|
// Close releases the connection pool.
|
|
func (s *Store) Close() {
|
|
if s.pool != nil {
|
|
s.pool.Close()
|
|
}
|
|
}
|
|
|
|
// initSchema creates the reconciliation_reports table and its indexes.
|
|
// Idempotent and safe on existing databases (CREATE ... IF NOT EXISTS).
|
|
func (s *Store) initSchema(ctx context.Context) error {
|
|
if _, err := s.pool.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS reconciliation_reports (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
date DATE NOT NULL,
|
|
tenant_id BIGINT,
|
|
source_type TEXT NOT NULL,
|
|
source_id BIGINT,
|
|
expected_count BIGINT,
|
|
archived_count BIGINT NOT NULL DEFAULT 0,
|
|
delta BIGINT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Upsert key. tenant_id and source_id are nullable and PostgreSQL treats
|
|
// NULLs as distinct in a plain UNIQUE index, which would allow duplicate
|
|
// rows for the (tenant-less / smtp) buckets. A COALESCE-based expression
|
|
// index gives a single deterministic key per (date, tenant, source_type,
|
|
// source_id). -1 is a safe sentinel because real tenant/account IDs are
|
|
// positive BIGSERIALs.
|
|
if _, err := s.pool.Exec(ctx, `
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_recon_reports_key
|
|
ON reconciliation_reports (date, COALESCE(tenant_id, -1), source_type, COALESCE(source_id, -1));
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Lookup index for the dashboard / CSV queries (per AC).
|
|
if _, err := s.pool.Exec(ctx, `
|
|
CREATE INDEX IF NOT EXISTS idx_recon_reports_lookup
|
|
ON reconciliation_reports (tenant_id, date, source_type);
|
|
`); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SourceKey returns the canonical source identifier used in the API / CSV:
|
|
// "smtp", "import", "imap:<account_id>", "pop3:<account_id>".
|
|
func SourceKey(sourceType string, sourceID *int64) string {
|
|
if sourceID != nil && (sourceType == "imap" || sourceType == "pop3") {
|
|
return fmt.Sprintf("%s:%d", sourceType, *sourceID)
|
|
}
|
|
return sourceType
|
|
}
|