Skip to content

feat: mail-stack monitoring (postfix/dovecot/rspamd/greylist) - #47

Merged
MalteKiefer merged 19 commits into
mainfrom
feat/mail-stack-monitoring
Jul 17, 2026
Merged

MalteKiefer merged 19 commits into
mainfrom
feat/mail-stack-monitoring

Conversation

@MalteKiefer

Copy link
Copy Markdown
Owner

Adds agent-local mail-stack monitoring. Built subagent-driven from docs/superpowers/plans/2026-07-16-mail-stack-monitoring.md (design: docs/superpowers/specs/2026-07-16-mail-stack-monitoring-design.md). 15 tasks, each TDD + task-reviewed; final whole-branch review: READY TO MERGE.

What

The agent detects postfix/dovecot/rspamd/postgrey and ships a MailReport in the ingest snapshot (nil when no mail stack → non-mail hosts unaffected). Signals: service up/down (systemd via safeexec), postfix queue depth (spool file count via CAP_DAC_READ_SEARCH), rspamd/greylist stats (localhost:11334/stat), local port + TLS-expiry (verified handshake, self-signed surfaced).

Server: migration 0035 (host_mail_status + metrics_mail hypertable, 90d retention); SaveMailReport/MailStatus/MailQueueSeries; gated GET /v1/hosts/{id}/mail.
Web: host-detail Mail tab (shown only when detected) — services, queue, rspamd, ports/TLS + cert-expiry.
Alerts (Phase 2): mail_queue_deferred/mail_queue_total via metric_threshold; new mail_service_down condition; suggested-alert links on the tab.

Verification

go build (+ GOOS=linux), go vet, go test ./..., testcontainers integration tests (store/alerts/api, MON_TEST_DOCKER=1), govulncheck (0 in called code), web lint+build — all green. OpenAPI regenerated. New agent config: mail.enabled, mail.rspamd_stat_url.

Notes

  • mail_service_down is fire-only (consistent with sibling periodic evaluators like security_updates_pending).
  • MailQueueSeries is implemented + tested but not yet surfaced (future queue sparkline).

Add MailConfig struct with Enabled tri-state pointer and RspamdStatURL
field to Config, mirroring the AutoUpdateConfig pattern. MailEnabled()
defaults to true when unset; RspamdStatURL() falls back to the
http://127.0.0.1:11334/stat default constant.
Implement postfixQueue() function that counts regular files recursively
in postfix queue directories (active, deferred, hold, incoming) under
the spool root. Returns nil if spool root does not exist. Uses
filepath.WalkDir for efficient, non-following traversal. Test verifies
correct counting with hashed subdirectories and missing directories.
Implement rspamdStat() function that fetches the rspamd /stat endpoint,
decodes JSON tolerantly, and maps to apitypes.RspamdStat. Returns
Reachable:false on any error. Includes comprehensive test coverage with
httptest servers for success and failure scenarios.
Assembles the mail collector from the existing helpers (serviceState,
postfixQueue, rspamdStat, checkPort) and registers it in buildCollectors
behind cfg.MailEnabled(). Collect probes postfix/dovecot/rspamd/postgrey;
if none are present the batch.Mail field is left nil so non-mail hosts
produce no payload. Queue, Rspamd stat, and port checks are each scoped
to the services that are actually present. New() is isolated to a
linux-tagged file so the package tests compile and pass on darwin.
Wire SaveMailReport into handleIngest (best-effort, logged on error)
so mail telemetry from the agent is persisted on each accepted ingest.
Add GET /v1/hosts/{id}/mail (operationId: get-host-mail) returning
{detected bool, report *MailReport}; gated by the same protected
middleware as the security and logins endpoints. Add integration test
(testcontainers-gated via MON_TEST_DOCKER=1). Regenerate api/openapi.yaml.
Adds the Mail tab to the host-detail view. Fetches GET /v1/hosts/{id}/mail
at layout level (same pattern as security) so the nav entry is gated on
detected without a second round-trip from the tab itself. The tab renders
four panels: Services (up/down pills), Queue (active/deferred/hold/total
with warn colouring on non-zero deferred/hold), Rspamd (scanned/greylisted/
rejected, hidden when not reachable), and Ports (port/proto, open pill, TLS
badge, self-signed note, cert-expiry countdown).

Regenerates api-types.generated.ts to pull MailReport, MailService,
MailPortCheck, PostfixQueue, and RspamdStat from the updated spec.
Adds the mail i18n namespace (en + de) and nav.mail key in hostDetail.
Add mail_queue_deferred and mail_queue_total as metric_threshold metric
kinds so operators can alert on postfix queue depth.

- metricMailQueue() reads the latest metrics_mail row per host via
  DISTINCT ON (host_id) and fires when the selected column exceeds the
  threshold; mirrors the one-shot pattern of metricFail2banBanned /
  metricCrowdSecActive.
- Column restricted to an explicit allowlist inside the helper; op is
  sqlComparator-validated; threshold is parameterised — no SQL injection
  surface.
- Two switch cases added to runMetricRule.
- MetricMailQueueDeferred / MetricMailQueueTotal constants added to
  apitypes.MetricKind; MetricThresholdParams.Metric enum updated so the
  API rejects unknown metric names at rule-creation time.
- TestMetricMailQueueDeferred (testcontainers-gated, MON_TEST_DOCKER=1)
  seeds a metrics_mail row with queue_deferred=15, fires the evaluator,
  and asserts alert_history + alert_state rows are written.
Add a periodic evaluator that scans host_mail_status for services with
active=false and fires one alert per (host, service) pair using dedup key
mail_service_down:<host_id>:<service_name>.

- Add ConditionMailServiceDown const and mail_service_down to all three
  condition_type enum struct tags in apitypes.go
- Add mail_service_down to splitDedupKey host-scope case list
- Dispatch evalMailServiceDown from runPeriodic, mirroring the
  security_updates_pending pattern
- Implement evalMailServiceDown: JOIN host_mail_status with hosts,
  unmarshal apitypes.MailReport, fire per inactive service
- Add TestMailServiceDown integration test (testcontainers-gated):
  postfix inactive → alert fires; all-active host → no alert
- Regenerate api/openapi.yaml with the updated enum
- Add mail_service_down to NotificationConditionType, CONDITION_TYPES,
  CATEGORY_MAP (availability), NO_PARAM_CONDITIONS, conditionIcon, and
  conditionSummary in catalogue.ts
- Add mail_queue_deferred and mail_queue_total metric kinds to METRIC_KINDS
- Add SuggestedAlertsSection to Mail.tsx (gated on detected=true) with two
  link-cards pointing to /notifications for mail_service_down and queue alerts
- Add suggestedAlerts i18n keys to en/mail.json and de/mail.json (no drift)
Add mail-stack monitoring to the agent feature list in README.md and
add section 4b to docs/OPERATIONS.md covering the Mail tab, agent
config keys (mail.enabled, mail.rspamd_stat_url), and alert rules
(mail_service_down, mail_queue_deferred, mail_queue_total).
…, http.NoBody)

- ports.go: replace net.DialTimeout with (&net.Dialer{}).DialContext and
  tls.DialWithDialer with tls.Dialer.DialContext to thread ctx through
  (fixes noctx on plain and TLS dials; also resolves unparam on ctx)
- store/mail.go: use errors.Is(err, pgx.ErrNoRows) instead of == (errorlint)
- rspamd.go: pass http.NoBody instead of nil request body (gocritic httpNoBody)
- queue.go: restructure countRegularFiles to assign WalkDir return to _,
  skip unreadable dirs with fs.SkipDir — removes nilerr finding cleanly
- alerts_engine_test.go: drop unnecessary fmt.Sprintf wrapper (staticcheck S1039)
- gofmt: mail_test.go, mail_test.go (api), store/mail.go, apitypes.go
- gofumpt: queue.go, queue_test.go, config_mail_test.go
@MalteKiefer
MalteKiefer merged commit 20bd931 into main Jul 17, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant