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:
co-authored by
Claude Sonnet 5
parent
b286352d07
commit
be93614c9f
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user