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
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"encoding/csv"
"fmt"
"net/http"
"strconv"
"archivmail/internal/audit"
)
// tenantScope returns the tenant filter for reconciliation queries: a
// domain_admin (and any other tenant-scoped role) is restricted to its own
// tenant, while superadmin (sess.TenantID == nil) sees all tenants. This
// mirrors handleMailTimeseries and prevents cross-tenant leakage of source
// figures (PROJ-55/61 tenant-isolation discipline).
func (s *Server) reconTenantScope(r *http.Request) *int64 {
sess := sessionFromCtx(r.Context())
if sess.TenantID != nil {
return tenantFromCtx(r.Context())
}
return nil
}
// handleReconciliation returns the last N days (default 7) of completeness
// figures per source, tenant-scoped.
// GET /api/admin/reconciliation?days=7
func (s *Server) handleReconciliation(w http.ResponseWriter, r *http.Request) {
if s.reconStore == nil {
writeError(w, http.StatusServiceUnavailable, "reconciliation not enabled")
return
}
days := 7
if v := r.URL.Query().Get("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 90 {
days = n
}
}
tid := s.reconTenantScope(r)
sources, err := s.reconStore.DashboardData(r.Context(), tid, days, s.reconThresholdPct)
if err != nil {
s.logger.Error("reconciliation dashboard query failed", "err", err)
writeError(w, http.StatusInternalServerError, "reconciliation query failed")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"days": days,
"threshold_pct": s.reconThresholdPct,
"sources": sources,
})
}
// handleReconciliationExport streams the reconciliation report as CSV,
// tenant-scoped (analog PROJ-11 audit export).
// GET /api/admin/reconciliation/export.csv?days=30
func (s *Server) handleReconciliationExport(w http.ResponseWriter, r *http.Request) {
if s.reconStore == nil {
writeError(w, http.StatusServiceUnavailable, "reconciliation not enabled")
return
}
days := 30
if v := r.URL.Query().Get("days"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 366 {
days = n
}
}
tid := s.reconTenantScope(r)
rows, err := s.reconStore.ExportRows(r.Context(), tid, days)
if err != nil {
s.logger.Error("reconciliation export query failed", "err", err)
writeError(w, http.StatusInternalServerError, "reconciliation query failed")
return
}
sess := sessionFromCtx(r.Context())
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="reconciliation.csv"`)
w.WriteHeader(http.StatusOK)
cw := csv.NewWriter(w)
cw.Write([]string{"date", "tenant_id", "source", "expected_count", "archived_count", "delta"}) //nolint:errcheck
for _, row := range rows {
cw.Write([]string{ //nolint:errcheck
row.Date.UTC().Format("2006-01-02"),
nullableInt(row.TenantID),
reconSourceKey(row.SourceType, row.SourceID),
nullableInt(row.ExpectedCount),
strconv.FormatInt(row.ArchivedCount, 10),
nullableInt(row.Delta),
})
}
cw.Flush()
s.audlog.Log(audit.Entry{
EventType: audit.EventExport,
Username: sess.Username,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Detail: fmt.Sprintf("reconciliation csv: %d days, %d rows", days, len(rows)),
Success: true,
})
}
// nullableInt formats a *int64 for CSV, emitting an empty string for nil.
func nullableInt(v *int64) string {
if v == nil {
return ""
}
return strconv.FormatInt(*v, 10)
}
// reconSourceKey mirrors reconciliation.SourceKey without importing the package
// into the CSV hot path (kept local and tiny).
func reconSourceKey(sourceType string, sourceID *int64) string {
if sourceID != nil && (sourceType == "imap" || sourceType == "pop3") {
return fmt.Sprintf("%s:%d", sourceType, *sourceID)
}
return sourceType
}
+14
View File
@@ -20,6 +20,7 @@ import (
ldapcfg "archivmail/internal/ldapconfig"
"archivmail/internal/mailer"
pop3store "archivmail/internal/pop3"
"archivmail/internal/reconciliation"
"archivmail/internal/smtpoutconfig"
"archivmail/internal/smtpd"
"archivmail/internal/storage"
@@ -90,6 +91,8 @@ type Server struct {
fqdn string // from server.fqdn config (PROJ-28)
smtpOutStore *smtpoutconfig.Store
apiKeyMw *auth.APIKeyMiddleware // PROJ-13: external API auth
reconStore *reconciliation.Store // PROJ-52: completeness reconciliation
reconThresholdPct int // PROJ-52: alert threshold (percent below 7-day avg)
}
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
@@ -151,6 +154,14 @@ func (s *Server) SetSMTPOutStore(store *smtpoutconfig.Store) {
s.smtpOutStore = store
}
// SetReconciliation wires the completeness-reconciliation store and the alert
// threshold (percent below the trailing 7-day average) into the API server
// (PROJ-52).
func (s *Server) SetReconciliation(store *reconciliation.Store, thresholdPct int) {
s.reconStore = store
s.reconThresholdPct = thresholdPct
}
// New creates and wires up a new API server.
func New(
cfg config.APIConfig,
@@ -222,6 +233,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/admin/system/stats", s.authAdmin(s.handleSystemStats))
s.mux.HandleFunc("GET /api/admin/stats/timeseries", s.authAdmin(s.handleMailTimeseries))
// PROJ-52: Vollständigkeits-Reconciliation (Dashboard + CSV-Export) — admin, tenant-scoped.
s.mux.HandleFunc("GET /api/admin/reconciliation", s.authAdmin(s.handleReconciliation))
s.mux.HandleFunc("GET /api/admin/reconciliation/export.csv", s.authAdmin(s.handleReconciliationExport))
s.mux.HandleFunc("GET /api/admin/security/audit", s.authAdmin(s.handleSecurityAudit))
// SEC-17: Security fix actions require superadmin, not just domain_admin.
s.mux.HandleFunc("POST /api/admin/security/fix", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSecurityFix)))
+5
View File
@@ -167,6 +167,11 @@ func (s *Server) importRawMessage(ctx context.Context, raw []byte, tenantID *int
return "error"
}
// PROJ-52: uploaded mails count as source 'import' for reconciliation.
if err := s.store.TagSource(ctx, id, "import", nil); err != nil {
s.logger.Warn("upload: tag source failed", "id", id, "err", err)
}
// Check dedup: storage.Save returns same id for duplicate content.
// If already indexed, skip indexing.
if already, _ := s.store.IsIndexed(ctx, id); already {
+4
View File
@@ -24,6 +24,10 @@ const (
EventUserMgmt = "user_mgmt"
EventOCRDownload = "mail:ocr_download" // PROJ-44: extracted OCR text downloaded
EventDSGVORequest = "dsgvo_request" // PROJ-50: DSGVO Löschersuchen erfasst/bearbeitet
// EventReconciliationAnomaly (PROJ-52): a source's newly-archived count for a
// day dropped significantly below its trailing 7-day average, or the IMAP
// soll/ist comparison revealed a shortfall.
EventReconciliationAnomaly = "reconciliation_anomaly"
)
// Entry is a single audit log record.
+11 -4
View File
@@ -170,7 +170,7 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
// Set per-batch deadline to prevent indefinite blocking on stalled connections.
c.SetFetchDeadline()
count, err := imp.fetchBatch(ctx, c.Client, batch, acc.TenantID, log)
count, err := imp.fetchBatch(ctx, c.Client, batch, acc.TenantID, acc.ID, log)
c.ClearDeadline()
if err != nil {
log.Error("batch fetch error — aborting import", "folder", folder, "offset", i, "err", err)
@@ -188,7 +188,7 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
}
// fetchBatch fetches and stores a batch of messages by UID.
func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids []imapv2.UID, tenantID *int64, log *slog.Logger) (int, error) {
func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids []imapv2.UID, tenantID *int64, accountID int64, log *slog.Logger) (int, error) {
if len(uids) == 0 {
return 0, nil
}
@@ -223,7 +223,7 @@ func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids
continue
}
if err := imp.storeAndIndex(raw, tenantID, log); err != nil {
if err := imp.storeAndIndex(raw, tenantID, accountID, log); err != nil {
log.Warn("failed to store/index message", "err", err)
continue
}
@@ -240,7 +240,8 @@ func (imp *Importer) fetchBatch(ctx context.Context, c *imapclient.Client, uids
}
// storeAndIndex saves a raw email to storage and indexes it.
func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, log *slog.Logger) error {
// accountID identifies the IMAP account for PROJ-52 source tracking.
func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, accountID int64, log *slog.Logger) error {
ctx := context.Background()
// Save to file storage (deduplicates by SHA256 automatically)
id, err := imp.mailStore.Save(ctx, raw, time.Now(), tenantID)
@@ -248,6 +249,12 @@ func (imp *Importer) storeAndIndex(raw []byte, tenantID *int64, log *slog.Logger
return fmt.Errorf("save: %w", err)
}
// PROJ-52: record ingestion source (imap:<account_id>). Non-fatal.
accID := accountID
if err := imp.mailStore.TagSource(ctx, id, "imap", &accID); err != nil {
log.Warn("failed to tag source", "id", id, "err", err)
}
// Parse for indexing
pm, err := mailparser.Parse(raw)
if err != nil {
+3 -2
View File
@@ -438,7 +438,7 @@ func (s *Scheduler) syncFolder(
batch := uids[i:end]
c.SetFetchDeadline()
count, batchMaxUID, err := s.fetchSyncBatch(c.Client, batch, acc.TenantID, log)
count, batchMaxUID, err := s.fetchSyncBatch(c.Client, batch, acc.TenantID, acc.ID, log)
c.ClearDeadline()
if err != nil {
log.Warn("imap scheduler: batch error, continuing",
@@ -468,6 +468,7 @@ func (s *Scheduler) fetchSyncBatch(
c *imapclient.Client,
uids []imapv2.UID,
tenantID *int64,
accountID int64,
log *slog.Logger,
) (int, uint32, error) {
if len(uids) == 0 {
@@ -515,7 +516,7 @@ func (s *Scheduler) fetchSyncBatch(
}
if len(raw) > 0 {
if err := s.importer.storeAndIndex(raw, tenantID, log); err != nil {
if err := s.importer.storeAndIndex(raw, tenantID, accountID, log); err != nil {
log.Warn("imap scheduler: store/index failed", "err", err)
} else {
imported++
+9 -2
View File
@@ -119,7 +119,7 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
continue
}
if err := imp.storeAndIndex(raw, log); err != nil {
if err := imp.storeAndIndex(raw, acc.ID, log); err != nil {
log.Warn("failed to store/index message, skipping", "msg_num", num, "err", err)
} else {
imported++
@@ -133,7 +133,8 @@ func (imp *Importer) doImport(ctx context.Context, acc *Account, password string
}
// storeAndIndex saves a raw email to storage and indexes it.
func (imp *Importer) storeAndIndex(raw []byte, log *slog.Logger) error {
// accountID identifies the POP3 account for PROJ-52 source tracking.
func (imp *Importer) storeAndIndex(raw []byte, accountID int64, log *slog.Logger) error {
ctx := context.Background()
// Save to file storage (deduplicates by SHA256 automatically)
id, err := imp.mailStore.Save(ctx, raw, time.Now(), imp.TenantID)
@@ -141,6 +142,12 @@ func (imp *Importer) storeAndIndex(raw []byte, log *slog.Logger) error {
return fmt.Errorf("pop3 save: %w", err)
}
// PROJ-52: record ingestion source (pop3:<account_id>). Non-fatal.
accID := accountID
if err := imp.mailStore.TagSource(ctx, id, "pop3", &accID); err != nil {
log.Warn("failed to tag source", "id", id, "err", err)
}
// Parse for indexing
pm, err := mailparser.Parse(raw)
if err != nil {
+337
View File
@@ -0,0 +1,337 @@
package reconciliation
import (
"context"
"fmt"
"time"
"archivmail/internal/audit"
)
// bucketKey identifies one reconciliation bucket in memory. Nil tenant/source
// IDs are encoded as -1 so they can be used as map keys.
type bucketKey struct {
tenant int64
sourceType string
sourceID int64
}
func keyOf(tenant, sourceID *int64, sourceType string) bucketKey {
t := int64(-1)
if tenant != nil {
t = *tenant
}
sid := int64(-1)
if sourceID != nil {
sid = *sourceID
}
return bucketKey{tenant: t, sourceType: sourceType, sourceID: sid}
}
func ptr(v int64) *int64 { return &v }
func nilIfNeg(v int64) *int64 {
if v < 0 {
return nil
}
return ptr(v)
}
// imapExpected holds the IMAP soll/ist snapshot for one account.
type imapExpected struct {
tenant *int64
expected int64 // sum of per-folder last_uid high-water marks (source proxy)
cumulArch int64 // cumulative archived mails for this account
}
// Anomaly describes a detected significant drop for one source/day.
type Anomaly struct {
Date time.Time
TenantID *int64
SourceKey string
Archived int64
Average float64
ThresholdPct int
}
// ComputeForDate reconciles a single calendar day (UTC) and upserts one row per
// known source bucket. Rows are only written after every read query has
// succeeded, so a mid-job DB failure leaves the day WITHOUT a report row
// (dashboard shows "data missing") instead of a misleading all-zero report.
//
// After persisting, it evaluates the trailing 7-day average per source and
// writes a `reconciliation_anomaly` audit entry when today's archived count has
// dropped more than thresholdPct percent below that average. Sources with fewer
// than 7 prior daily records are skipped ("not enough data yet").
//
// Returns the anomalies detected (also useful for the CLI summary/tests).
func (s *Store) ComputeForDate(ctx context.Context, day time.Time, thresholdPct int) ([]Anomaly, error) {
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, time.UTC)
dayEnd := dayStart.Add(24 * time.Hour)
// ── Read phase (all-or-nothing) ────────────────────────────────────────
archived, err := s.archivedForDay(ctx, dayStart, dayEnd)
if err != nil {
return nil, err
}
known, err := s.knownBuckets(ctx)
if err != nil {
return nil, err
}
imap, err := s.imapExpectedSnapshot(ctx)
if err != nil {
return nil, err
}
// ── Build rows ─────────────────────────────────────────────────────────
rows := make([]Report, 0, len(known))
for k := range known {
r := Report{
Date: dayStart,
TenantID: nilIfNeg(k.tenant),
SourceType: k.sourceType,
SourceID: nilIfNeg(k.sourceID),
ArchivedCount: archived[k], // 0 when the source had no mail that day
}
// IMAP soll/ist: expected = source mailbox size proxy (sum of last_uid),
// delta = cumulative archived for the account expected. delta going
// negative because the user emptied the source mailbox is a "good"
// direction and never alerts (alerting keys off archived_count only).
if k.sourceType == "imap" && k.sourceID >= 0 {
if ie, ok := imap[k.sourceID]; ok {
r.ExpectedCount = ptr(ie.expected)
r.Delta = ptr(ie.cumulArch - ie.expected)
}
}
rows = append(rows, r)
}
// ── Write phase ────────────────────────────────────────────────────────
if err := s.upsertRows(ctx, rows); err != nil {
return nil, err
}
// ── Alert phase ────────────────────────────────────────────────────────
var anomalies []Anomaly
for _, r := range rows {
avg, n, err := s.trailingAverage(ctx, r, dayStart)
if err != nil {
s.logger.Warn("reconciliation: trailing average failed",
"source", SourceKey(r.SourceType, r.SourceID), "err", err)
continue
}
if n < 7 {
continue // not enough history yet
}
limit := avg * (1 - float64(thresholdPct)/100.0)
if float64(r.ArchivedCount) < limit {
a := Anomaly{
Date: dayStart,
TenantID: r.TenantID,
SourceKey: SourceKey(r.SourceType, r.SourceID),
Archived: r.ArchivedCount,
Average: avg,
ThresholdPct: thresholdPct,
}
anomalies = append(anomalies, a)
s.logAnomaly(a)
}
}
return anomalies, nil
}
// archivedForDay returns the count of newly archived mails per source bucket for
// the given day window. Mails with NULL source_type (legacy) bucket as 'import'.
func (s *Store) archivedForDay(ctx context.Context, start, end time.Time) (map[bucketKey]int64, error) {
rows, err := s.pool.Query(ctx, `
SELECT tenant_id, COALESCE(source_type, 'import') AS st, source_id, COUNT(*)
FROM emails
WHERE received_at >= $1 AND received_at < $2
GROUP BY tenant_id, st, source_id
`, start, end)
if err != nil {
return nil, fmt.Errorf("reconciliation: archived-for-day query: %w", err)
}
defer rows.Close()
out := make(map[bucketKey]int64)
for rows.Next() {
var tenant, sourceID *int64
var st string
var cnt int64
if err := rows.Scan(&tenant, &st, &sourceID, &cnt); err != nil {
return nil, fmt.Errorf("reconciliation: archived-for-day scan: %w", err)
}
out[keyOf(tenant, sourceID, st)] = cnt
}
return out, rows.Err()
}
// knownBuckets returns every (tenant, source_type, source_id) combination that
// has ever produced an archived mail. These are the buckets for which a report
// row is written every day — including explicit 0 on inactive days so gaps in
// the cron run are distinguishable from genuine zero-activity days.
func (s *Store) knownBuckets(ctx context.Context) (map[bucketKey]struct{}, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT tenant_id, COALESCE(source_type, 'import') AS st, source_id
FROM emails
`)
if err != nil {
return nil, fmt.Errorf("reconciliation: known-buckets query: %w", err)
}
defer rows.Close()
out := make(map[bucketKey]struct{})
for rows.Next() {
var tenant, sourceID *int64
var st string
if err := rows.Scan(&tenant, &st, &sourceID); err != nil {
return nil, fmt.Errorf("reconciliation: known-buckets scan: %w", err)
}
out[keyOf(tenant, sourceID, st)] = struct{}{}
}
return out, rows.Err()
}
// imapExpectedSnapshot returns the IMAP soll/ist snapshot keyed by account ID.
// expected reuses the per-folder UID high-water marks from imap_folder_state
// (PROJ-45) — no additional IMAP login. cumulArch is the cumulative number of
// archived mails attributed to the account.
func (s *Store) imapExpectedSnapshot(ctx context.Context) (map[int64]imapExpected, error) {
out := make(map[int64]imapExpected)
// Expected proxy + tenant per account. LEFT JOIN so accounts without any
// synced folder yet still appear (expected 0).
rows, err := s.pool.Query(ctx, `
SELECT a.id, a.tenant_id, COALESCE(SUM(fs.last_uid), 0)
FROM imap_accounts a
LEFT JOIN imap_folder_state fs ON fs.account_id = a.id
GROUP BY a.id, a.tenant_id
`)
if err != nil {
return nil, fmt.Errorf("reconciliation: imap expected query: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
var tenant *int64
var expected int64
if err := rows.Scan(&id, &tenant, &expected); err != nil {
return nil, fmt.Errorf("reconciliation: imap expected scan: %w", err)
}
out[id] = imapExpected{tenant: tenant, expected: expected}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Cumulative archived count per IMAP account.
crows, err := s.pool.Query(ctx, `
SELECT source_id, COUNT(*)
FROM emails
WHERE source_type = 'imap' AND source_id IS NOT NULL
GROUP BY source_id
`)
if err != nil {
return nil, fmt.Errorf("reconciliation: imap archived query: %w", err)
}
defer crows.Close()
for crows.Next() {
var id, cnt int64
if err := crows.Scan(&id, &cnt); err != nil {
return nil, fmt.Errorf("reconciliation: imap archived scan: %w", err)
}
ie := out[id]
ie.cumulArch = cnt
out[id] = ie
}
return out, crows.Err()
}
// upsertRows writes all report rows in a single transaction. On any error the
// transaction is rolled back so no partial day is persisted.
func (s *Store) upsertRows(ctx context.Context, rows []Report) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("reconciliation: begin tx: %w", err)
}
defer tx.Rollback(ctx)
for _, r := range rows {
_, err := tx.Exec(ctx, `
INSERT INTO reconciliation_reports
(date, tenant_id, source_type, source_id, expected_count, archived_count, delta)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (date, COALESCE(tenant_id, -1), source_type, COALESCE(source_id, -1))
DO UPDATE SET
expected_count = EXCLUDED.expected_count,
archived_count = EXCLUDED.archived_count,
delta = EXCLUDED.delta,
created_at = NOW()
`, r.Date, r.TenantID, r.SourceType, r.SourceID, r.ExpectedCount, r.ArchivedCount, r.Delta)
if err != nil {
return fmt.Errorf("reconciliation: upsert row: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("reconciliation: commit: %w", err)
}
return nil
}
// trailingAverage returns the mean archived_count of the up-to-7 report rows
// immediately preceding the given day for the same source bucket, plus how many
// prior day rows were found. NULL-safe matching on tenant_id / source_id.
func (s *Store) trailingAverage(ctx context.Context, r Report, day time.Time) (float64, int, error) {
rows, err := s.pool.Query(ctx, `
SELECT archived_count
FROM reconciliation_reports
WHERE source_type = $1
AND tenant_id IS NOT DISTINCT FROM $2
AND source_id IS NOT DISTINCT FROM $3
AND date < $4
ORDER BY date DESC
LIMIT 7
`, r.SourceType, r.TenantID, r.SourceID, day)
if err != nil {
return 0, 0, fmt.Errorf("reconciliation: trailing average query: %w", err)
}
defer rows.Close()
var sum int64
var n int
for rows.Next() {
var c int64
if err := rows.Scan(&c); err != nil {
return 0, 0, err
}
sum += c
n++
}
if err := rows.Err(); err != nil {
return 0, 0, err
}
if n == 0 {
return 0, 0, nil
}
return float64(sum) / float64(n), n, nil
}
// logAnomaly emits a structured log line and, when wired, a tenant-visible
// audit entry for a detected drop.
func (s *Store) logAnomaly(a Anomaly) {
detail := fmt.Sprintf("source=%s date=%s archived=%d avg_7d=%.1f threshold=%d%%",
a.SourceKey, a.Date.Format("2006-01-02"), a.Archived, a.Average, a.ThresholdPct)
s.logger.Warn("reconciliation: anomaly detected", "detail", detail)
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: audit.EventReconciliationAnomaly,
Username: "system",
TenantID: a.TenantID,
Success: false,
Detail: detail,
})
}
}
+177
View File
@@ -0,0 +1,177 @@
package reconciliation
import (
"context"
"fmt"
"time"
)
// DayPoint is one day's figures for a source in the dashboard response.
// ArchivedCount is nil (and Missing true) when no report row exists for that
// date — i.e. the cron job did not run — which is distinct from an archived
// count of 0 on a genuine zero-activity day.
type DayPoint struct {
Date string `json:"date"`
ArchivedCount *int64 `json:"archived_count"`
ExpectedCount *int64 `json:"expected_count"`
Delta *int64 `json:"delta"`
Missing bool `json:"missing"`
}
// SourceSummary aggregates the trailing days plus alert state for one source.
type SourceSummary struct {
SourceType string `json:"source_type"`
SourceID *int64 `json:"source_id"`
SourceKey string `json:"source_key"`
TenantID *int64 `json:"tenant_id"`
Points []DayPoint `json:"points"`
Avg7d float64 `json:"avg_7d"`
EnoughData bool `json:"enough_data"`
Alert bool `json:"alert"`
}
// DashboardData returns the last `days` calendar days of reconciliation figures
// per source, tenant-scoped. When tenantID is nil (superadmin) all tenants are
// included; otherwise only rows for that tenant are returned. thresholdPct is
// used to compute the per-source Alert flag consistently with the cron job.
func (s *Store) DashboardData(ctx context.Context, tenantID *int64, days, thresholdPct int) ([]SourceSummary, error) {
if days <= 0 {
days = 7
}
today := time.Now().UTC().Truncate(24 * time.Hour)
start := today.AddDate(0, 0, -(days - 1))
rows, err := s.queryRange(ctx, tenantID, start, today.Add(24*time.Hour))
if err != nil {
return nil, err
}
// Ordered date labels for the window.
dateLabels := make([]string, days)
for i := 0; i < days; i++ {
dateLabels[i] = start.AddDate(0, 0, i).Format("2006-01-02")
}
type srcAgg struct {
meta Report
byDate map[string]Report
}
agg := map[string]*srcAgg{}
for _, r := range rows {
key := SourceKey(r.SourceType, r.SourceID)
// Distinguish sources of different tenants sharing a key.
if r.TenantID != nil {
key = fmt.Sprintf("t%d/%s", *r.TenantID, key)
}
a, ok := agg[key]
if !ok {
a = &srcAgg{meta: r, byDate: map[string]Report{}}
agg[key] = a
}
a.byDate[r.Date.UTC().Format("2006-01-02")] = r
}
summaries := make([]SourceSummary, 0, len(agg))
for _, a := range agg {
sum := SourceSummary{
SourceType: a.meta.SourceType,
SourceID: a.meta.SourceID,
SourceKey: SourceKey(a.meta.SourceType, a.meta.SourceID),
TenantID: a.meta.TenantID,
Points: make([]DayPoint, 0, days),
}
for _, d := range dateLabels {
if r, ok := a.byDate[d]; ok {
c := r.ArchivedCount
sum.Points = append(sum.Points, DayPoint{
Date: d,
ArchivedCount: &c,
ExpectedCount: r.ExpectedCount,
Delta: r.Delta,
Missing: false,
})
} else {
sum.Points = append(sum.Points, DayPoint{Date: d, Missing: true})
}
}
// Alert against the trailing 7-day average of the most recent day that
// actually has a report row (mirrors the cron job's evaluation).
latest, latestDate, hasLatest := latestPresent(a.byDate, dateLabels)
if hasLatest {
avg, n, err := s.trailingAverage(ctx, a.meta, latestDate)
if err == nil && n >= 7 {
sum.Avg7d = avg
sum.EnoughData = true
limit := avg * (1 - float64(thresholdPct)/100.0)
if float64(latest.ArchivedCount) < limit {
sum.Alert = true
}
}
}
summaries = append(summaries, sum)
}
return summaries, nil
}
// latestPresent returns the most recent report row within the window that has a
// stored row, along with its date.
func latestPresent(byDate map[string]Report, dateLabels []string) (Report, time.Time, bool) {
for i := len(dateLabels) - 1; i >= 0; i-- {
if r, ok := byDate[dateLabels[i]]; ok {
d, _ := time.Parse("2006-01-02", dateLabels[i])
return r, d, true
}
}
return Report{}, time.Time{}, false
}
// ExportRows returns raw report rows for CSV export, tenant-scoped, for the
// last `days` days, ordered by date descending then source.
func (s *Store) ExportRows(ctx context.Context, tenantID *int64, days int) ([]Report, error) {
if days <= 0 {
days = 30
}
today := time.Now().UTC().Truncate(24 * time.Hour)
start := today.AddDate(0, 0, -(days - 1))
return s.queryRange(ctx, tenantID, start, today.Add(24*time.Hour))
}
// queryRange loads report rows in [start, end) filtered by tenant. tenantID nil
// returns all tenants (superadmin scope).
func (s *Store) queryRange(ctx context.Context, tenantID *int64, start, end time.Time) ([]Report, error) {
var (
sql string
args []interface{}
)
if tenantID == nil {
sql = `SELECT date, tenant_id, source_type, source_id, expected_count, archived_count, delta
FROM reconciliation_reports
WHERE date >= $1 AND date < $2
ORDER BY date DESC, source_type, source_id`
args = []interface{}{start, end}
} else {
sql = `SELECT date, tenant_id, source_type, source_id, expected_count, archived_count, delta
FROM reconciliation_reports
WHERE date >= $1 AND date < $2 AND tenant_id = $3
ORDER BY date DESC, source_type, source_id`
args = []interface{}{start, end, *tenantID}
}
rows, err := s.pool.Query(ctx, sql, args...)
if err != nil {
return nil, fmt.Errorf("reconciliation: query range: %w", err)
}
defer rows.Close()
var out []Report
for rows.Next() {
var r Report
if err := rows.Scan(&r.Date, &r.TenantID, &r.SourceType, &r.SourceID,
&r.ExpectedCount, &r.ArchivedCount, &r.Delta); err != nil {
return nil, fmt.Errorf("reconciliation: scan range: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
+120
View File
@@ -0,0 +1,120 @@
// 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
}
+6
View File
@@ -346,6 +346,12 @@ func (s *session) Data(r io.Reader) error {
}
}
// PROJ-52: record the ingestion source for the reconciliation report.
// Non-fatal — a failed metadata write must not reject an already-stored mail.
if err := s.daemon.store.TagSource(context.Background(), id, "smtp", nil); err != nil {
s.daemon.logger.Warn("SMTP: tag source failed", "id", id, "err", err)
}
s.daemon.stats.Received.Add(1)
s.daemon.stats.LastMailAt.Store(time.Now())
s.daemon.logger.Info("SMTP: mail stored", "id", id, "from", s.from,
+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