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
workercontainer’s repeatable BullMQ jobs feed this page (it registers ten on thedeliverabilityqueue in all, plus the migration sweep on its own queue — seeapps/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 nowmissing,mismatchorerroropens a critical alertdns_drift(titleexample.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 criticaloutbound_abusealert 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 bycompose/compose.yaml, so setting it in.envhas no effect; nor is the authentication-ban sweep’sAUTH_BAN_INTERVAL_MINUTES): counts the outbound messages this server held back per sender over a rolling window and opens a warningrate_limit_burstalert 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): forPUBLIC_IP— DNSBL lookups onzen.spamhaus.org,bl.spamcop.net,b.barracudacentral.org,psbl.surriel.com(criticaldnsblalert per listing, with the delisting URL); PTR / forward-confirmed rDNS /hostname → IP(HELO) check (warningrdns); expiry of the synced certificate/certs/fullchain.pem(warningtls_expiryunder 14 days, critical under 3). The full report is stored and shown on the dashboard (Server reputation card) and viaGET /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_rptalert 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 bycompose/compose.yamleither): measures the filesystem under the worker’smaillogvolume, which on a default install is the one holding every volume (mail, queue, database), and opens onedisk_lowalert (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.
- DNS drift (every 15 min,
- Unreadable DKIM keys (raised by the API on every configuration apply, not by the worker): a key sealed under a different
SECRET_ENCRYPTION_KEYopens adkim_unreadablealert 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_burstanddisk_lowreopen at the next check if the condition persists, anddkim_unreadableat the next configuration apply; a dismisseddns_driftstays 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 dismissedtls_rptreopens 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 aserrorwith 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 seeerrorfor Spamhaus. - An IPv6
PUBLIC_IPis checked againstzen.spamhaus.orgonly, 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 fromPUBLIC_IPstill confirms. A dual-stack host’s IPv6 address is checked too: whileIPV6_ENABLED=trueandPUBLIC_IP6is set, the same job checksPUBLIC_IP6against 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 (outboundIpv6in 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 /alertsleaves out a row whosekindis not inAlertKind, 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 againstAlertKind, 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.