Skip to content

Blocklist, reverse-DNS and certificate monitoring (alerts)

Purpose. Catch the things that silently ruin deliverability — a DNSBL listing, a broken PTR, an expiring certificate, a DNS record someone changed — and tell the admin within one polling cycle, with the link to fix it.

Behaviour.

  • Six of the worker container’s repeatable BullMQ jobs feed this page (it registers ten on the deliverability queue in all, plus the migration sweep on its own queue — see apps/worker/src/start.ts):
    • DNS drift (every 15 min, DNS_CHECK_INTERVAL_MINUTES): re-verifies every active domain’s records exactly like Verify now (DNS records & live verification) and updates the stored result. A required record that verified before and is now missing, mismatch or error opens a critical alert dns_drift (title example.com: TXT record example.com is mismatch, with the observed values). Records that never verified do not alert — a new domain is not an incident. The alert auto-resolves when the record verifies again.
    • Outbound abuse (every 10 min, OUTBOUND_ABUSE_INTERVAL_MINUTES): groups finished outbound deliveries per sender over a rolling window and opens a critical outbound_abuse alert for a mailbox whose mail is being refused — see Compromised-mailbox containment. Unlike every other alert here it does not auto-resolve while a block stands, because the mailbox goes quiet precisely because it was blocked; clearing the block from the mailbox page closes it.
    • Rate-limit bursts (every 10 min, RATE_LIMIT_BURST_INTERVAL_MINUTES — not passed by compose/compose.yaml, so setting it in .env has no effect; nor is the authentication-ban sweep’s AUTH_BAN_INTERVAL_MINUTES): counts the outbound messages this server held back per sender over a rolling window and opens a warning rate_limit_burst alert naming the sender and the bucket, linked to those messages in the delivery log — see Outbound rate limits. It reads the same delivery log as outbound abuse but is deliberately a separate job, so that containment’s population query never grows a second purpose. This one does auto-resolve: a burst going quiet means the burst ended.
    • Reputation (hourly, REPUTATION_CHECK_INTERVAL_MINUTES): for PUBLIC_IP — DNSBL lookups on zen.spamhaus.org, bl.spamcop.net, b.barracudacentral.org, psbl.surriel.com (critical dnsbl alert per listing, with the delisting URL); PTR / forward-confirmed rDNS / hostname → IP (HELO) check (warning rdns); expiry of the synced certificate /certs/fullchain.pem (warning tls_expiry under 14 days, critical under 3). The full report is stored and shown on the dashboard (Server reputation card) and via GET /server/reputation.
    • Failed TLS sessions (every 30 min, with DMARC report ingest): a TLS-RPT report whose policies carry failed sessions opens a warning tls_rpt alert per domain (tls-rpt:<domainId>), naming the reporter and the failure types. It resolves only on a later clean report that saw traffic — successful sessions, over a window ending after the failures’ did — see MTA-STS and TLS-RPT.
    • Disk space (every 10 min, DISK_CHECK_INTERVAL_MINUTES, not passed by compose/compose.yaml either): measures the filesystem under the worker’s maillog volume, which on a default install is the one holding every volume (mail, queue, database), and opens one disk_low alert (disk_low:volumes): warning below 10 % or 2 GiB free, critical below 5 % or 512 MiB, whichever trips first, so a small VPS disk is not judged by its percentage alone. The same alert escalates rather than a second opening, and it resolves when space is freed. What to do is The disk is full.
  • Unreadable DKIM keys (raised by the API on every configuration apply, not by the worker): a key sealed under a different SECRET_ENCRYPTION_KEY opens a dkim_unreadable alert per domain (dkim_unreadable:<domainId>) — warning while the key file rspamd already had is carried forward, critical when there was none and the domain sends unsigned — and resolves on the next apply that can open it (DKIM signing).
  • Alerts (GET /alerts, the dashboard’s Needs attention card) are deduplicated by a stable key, carry first/last-seen timestamps, and close automatically when the condition clears; an admin can Dismiss one (POST /alerts/{id}/resolve, audited). What happens next depends on the kind: dnsbl, rdns, tls_expiry, outbound_abuse, rate_limit_burst and disk_low reopen at the next check if the condition persists, and dkim_unreadable at the next configuration apply; a dismissed dns_drift stays closed while the record is still broken, because drift alerts only on a record that verified at the previous check, so it returns only after the record recovers and breaks again; a dismissed tls_rpt reopens only when the next report carrying failures arrives.

Configuration. .env: PUBLIC_IP, and IPV6_ENABLED with PUBLIC_IP6 for the IPv6 checks, which Compose passes to the worker (every scheduler registers regardless; without PUBLIC_IP the reputation job still runs but skips the blocklist and rDNS checks; the certificate-expiry check does not need it, so tls_expiry can still be raised). The rDNS checks are also skipped while setup has not recorded the server’s hostname. DNS_CHECK_INTERVAL_MINUTES (default 15, at least 1), REPUTATION_CHECK_INTERVAL_MINUTES (default 60, at least 5). Blocklist zones are a code constant (packages/deliverability/src/reputation.ts).

Limits & edge cases.

  • DNSBL queries go through the container’s system resolver; Spamhaus refuses queries relayed by public resolvers (answer 127.255.x.x) — shown as error with an explanation, not as a listing. Docker Desktop’s embedded resolver forwards to the host’s, which is usually fine; a VPS using 8.8.8.8 as its only resolver will see error for Spamhaus.
  • An IPv6 PUBLIC_IP is checked against zen.spamhaus.org only, queried in the reversed-nibble form. It is the only one of the four lists that documents IPv6 listings; asking the others would answer “not listed” whatever the address’s standing. Its rDNS is forward-confirmed through AAAA records, and addresses are compared in one canonical spelling, so a PTR’s AAAA written differently from PUBLIC_IP still confirms. A dual-stack host’s IPv6 address is checked too: while IPV6_ENABLED=true and PUBLIC_IP6 is set, the same job checks PUBLIC_IP6 against the IPv6-holding list and confirms its rDNS, and the dashboard’s Server reputation card shows it under its own heading. Its findings become alerts (dnsbl6:<zone>, rdns6) only while outbound mail leaves over IPv6 (outboundIpv6 in the server configuration, PUT /api/v1/server/config) — a listing or a missing PTR on an address nothing sends from costs nothing, and is shown without an alert.
  • GET /alerts leaves out a row whose kind is not in AlertKind, and logs it, rather than failing the whole list; dismissing such a row resolves it and then answers 500, because it cannot be returned. Every writer is typed against AlertKind, so neither should happen.
  • No e-mail/webhook notification yet: alerts are visible in the UI and API only. Outbound notification and webhooks are planned.
  • Checks are server-wide except DNS drift, which is per domain; a multi-IP or dedicated-IP setup is post-1.0.
  • The worker needs Redis (job scheduling) and Postgres; if it is down, the dashboard shows the last stored results with their timestamps.