Deliverability
Email deliverability is mostly DNS. mailery handles tracking, suppression, compliance, and unsubscribe — but if your sender domain isn't authenticated, your emails go to spam (or get bounced outright by recipient servers).
This guide walks through the single biggest deliverability lever: authenticating your sender domain with SendGrid (SPF + DKIM + DMARC).
Skip the manual DNS dance
If your DNS is on Cloudflare, you can run npx mailery setup-sendgrid and the entire walkthrough below (domain auth, DNS records, Signed Event Webhook, Event Webhook URL) becomes a single command. See Automated setup at the bottom of this page.
What "authenticating a domain" means
Three DNS records prove to recipient servers that you are who you say you are:
| Record | Purpose | Failure mode |
|---|---|---|
| SPF (TXT) | Lists which servers may send mail "from" your domain | Recipients reject as "unauthorized origin" |
| DKIM (TXT) | Cryptographic signature on each outbound message | Recipients can't verify message integrity → spam folder |
| DMARC (TXT) | Policy telling recipients what to do if SPF + DKIM fail | Without it: ambiguous handling. With p=reject: protects against spoofing |
Without DKIM specifically, expect <10% inbox placement at Gmail. With DKIM properly set, expect 90%+. The lift from adding DKIM is the biggest single-step improvement you can make.
SendGrid setup
1. Authenticate the domain
SendGrid dashboard → Settings → Sender Authentication → Domain Authentication → "Authenticate Your Domain":
- Enter your sender domain (e.g.
yourdomain.com). - Choose whether to use a dedicated link branding subdomain (recommended). SendGrid will give you a CNAME for it (e.g.
mail._domainkey.yourdomain.com). - SendGrid shows you 3-5 DNS records to add. Copy these.
2. Add the DNS records
Go to your DNS provider (Cloudflare, Route53, Google Domains, etc.):
Type Host Value
CNAME em1234.yourdomain.com u1234.wl.sendgrid.net
CNAME s1._domainkey.yourdomain.com s1.domainkey.u1234.wl.sendgrid.net
CNAME s2._domainkey.yourdomain.com s2.domainkey.u1234.wl.sendgrid.netHosts and values are specific to your SendGrid account — use what SendGrid generates.
3. Verify in SendGrid
Back in SendGrid → click "Verify". DNS propagation can take a few minutes to a few hours. Once green, your domain is authenticated.
4. Add SPF
If you already have an SPF record for your domain, add SendGrid's include:
yourdomain.com. TXT "v=spf1 include:sendgrid.net include:_spf.google.com ~all"If you don't have one yet:
yourdomain.com. TXT "v=spf1 include:sendgrid.net ~all"The ~all is "softfail" — recommended starting point. Move to -all (hardfail) only after you're confident SendGrid is the only sender for the domain.
5. Add DMARC
DMARC tells recipient servers what to do when SPF or DKIM fail. Start with monitor-only:
_dmarc.yourdomain.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com; ruf=mailto:dmarc@yourdomain.com; fo=1"p=none— monitor only, no enforcementrua— aggregate reports emailruf— failure reports email (some providers ignore this)
Watch reports for 2-4 weeks. If your authenticated mail is passing consistently, move to:
_dmarc.yourdomain.com. TXT "v=DMARC1; p=quarantine; pct=10; rua=mailto:dmarc@yourdomain.com"p=quarantine; pct=10 — 10% of failing mail goes to spam. Bump to 25%, 50%, 100% as you gain confidence, then move to p=reject.
Verify it's working
After 24-48 hours of sending:
- Use mail-tester.com. Send a test email from your app to the address it shows, then check the score. 8+ / 10 is healthy.
- Use Gmail's "Show original". In a delivered Gmail message, click ⋮ → "Show original". You should see:
SPF: PASS with IP ... DKIM: PASS with domain yourdomain.com DMARC: PASS - MXToolbox has free SPF + DKIM + DMARC lookups. Search "MXToolbox SPF Check" or "DKIM Lookup".
Common pitfalls
- Multiple SPF records. Only one
v=spf1record per domain. If you have multiple, merge their includes into a single record. include:chain limit. SPF has a 10-lookup limit per query. Eachinclude:counts. If youinclude:11+ providers (mailgun, sendgrid, gsuite, …), the SPF record breaks silently. Tools likedmarcian.comshow your current depth.- CNAME flattening at the apex. Some DNS providers don't allow CNAME at the apex (
yourdomain.com) — you can only use it on subdomains. SendGrid's records are typically on subdomains, but watch for this. - Missing the subdomain. SendGrid's CNAMEs use specific subdomains like
em1234.yourdomain.com. Make sure you create them at the exact subdomain SendGrid specified, not at the apex. - Cloudflare proxy on the CNAMEs. Disable the orange-cloud proxy for SendGrid CNAMEs — proxying breaks DKIM validation.
Other levers
After domain auth, the next deliverability levers in order of impact:
- Sender reputation warmup. New SendGrid IPs / accounts send small volumes first. Ramp up over weeks. SendGrid handles this for you on shared IPs; on dedicated IPs you have to warm them yourself.
- List hygiene. mailery's suppression + soft→hard bounce promotion (
softBouncePromotionThreshold) keeps the list clean automatically. Re-import old lists at your peril — they're full of dead addresses. - Engagement. Recipient interaction (opens, replies, archiving) signals "wanted mail" to Gmail's filters. The Apple MPP problem makes opens noisy, but reply rate is gold.
- Content quality. Avoid spammy words ("free!!!", excessive caps), broken links, image-only emails. mailery's MJML templates + plain-text auto-derivation handle the structural part.
- One-click unsubscribe. mailery includes the RFC 8058 headers automatically on marketing sends. Gmail/Yahoo/Apple Mail show in-inbox unsubscribe buttons. Users prefer this to "report as spam" — and spam reports tank reputation.
- Complaint rate < 0.3%. Mailery's circuit breaker trips above this. If yours is climbing, your audience is wrong, not your domain. Stop sending until you fix the audience.
What mailery does automatically
| List-Unsubscribe + List-Unsubscribe-Post headers on marketing sends | ✓ |
| Suppression check at every send (hard bounce → permanent block) | ✓ |
| Soft→hard bounce promotion (configurable threshold) | ✓ |
| Complaint cascade (FBL webhook → suppression + subscription marked) | ✓ |
| Per-(sender domain × kind) circuit breaker auto-trip on high bounce / complaint rates | ✓ |
| GDPR-forget hashed suppression so re-imports don't email deleted users | ✓ |
| Plain-text auto-derivation sent alongside HTML (spam filters check both) | ✓ |
CAN-SPAM postal address {{senderAddress}} render variable + setup-status check | ✓ |
| DNSBL monitoring of sender domains + dedicated IPs (Spamhaus, SURBL, URIBL, Barracuda, SORBS, SpamCop) | ✓ |
| Google Postmaster Tools pull (when configured) — daily reputation tier + spam rate per domain | ✓ |
| Microsoft SNDS pull (when configured) — per-IP filter verdict + complaint rate | ✓ |
| DMARC RUA aggregate-report ingestion with per-source-IP failure breakdown + policy-progression suggestion | ✓ |
| Content linter at publish + live in the template editor (missing plain-text, URL shorteners, missing unsubscribe tag, etc.) | ✓ |
| Mail-Tester deliverability gate (when configured) — score-based publish block | ✓ |
| List-hygiene report — engagement-window breakdown + sunset-cohort impact projection | ✓ |
You handle: DNS records, sender reputation, content, segmentation.
Reputation isolation: separate domains for marketing vs transactional
The single most damaging deliverability mistake is sending both kinds of email from the same domain. Marketing emails attract complaints and soft bounces. Transactional emails (password resets, OTP codes, receipts) need to land in the inbox every single time. Share a domain, and a bad newsletter takes your password resets down with it.
The fix is to use separate verified sender domains:
news.yourapp.com— marketing / newsletters / lifecycle dripsmail.yourapp.com— transactional (auth, billing, security)
Each domain gets its own DKIM key, its own SPF record, and its own reputation at mailbox providers. A complaint on news.yourapp.com doesn't touch mail.yourapp.com's standing at Gmail.
Wire both into mailery and let it enforce the split:
await Mailer.init({
// ...
fromDefaults: { name: 'YourApp Newsletter', email: 'hello@news.yourapp.com' },
transactionalFromDefaults: { name: 'YourApp', email: 'noreply@mail.yourapp.com' },
senderDomains: {
'news.yourapp.com': { kind: 'marketing' },
'mail.yourapp.com': { kind: 'transactional' },
},
})With this registry set, a kind: 'marketing' template can't be published with a fromEmail on mail.yourapp.com, and vice versa. See Configuration → Sender domains.
Optional but recommended: route the two kinds through different providers as well (defaultTransactionalProvider: 'postmark' with defaultProvider: 'sendgrid' is a common pairing). Postmark's IP pools are optimized for transactional inbox placement; SendGrid handles marketing volume well.
Automated setup (Cloudflare)
If your DNS is hosted on Cloudflare, mailery ships a one-shot CLI that wires up everything covered above — domain authentication, DNS records, Signed Event Webhook key, Event Webhook URL — without clicking through dashboards.
npx mailery setup-sendgrid \
--domain news.example.com \
--webhook-url https://example.com/m/webhooks/sendgrid \
--cloudflareOr run it with no args for an interactive walkthrough that asks for each value:
npx mailery setup-sendgridThe wizard prompts for domains (one line, comma-separated for multiple), webhook URL (defaulting to https://<apex>/m/webhooks/sendgrid), whether to use Cloudflare, the API tokens themselves if missing from your environment, and the force flag. It echoes back a summary and asks to confirm before any API call. Pass --no-interactive to suppress the prompts (CI / scripts).
What it does
| Step | Source → target |
|---|---|
| Look up the Cloudflare Zone ID | Cloudflare GET /zones?name=... |
| Create or reuse the SendGrid domain authentication | SendGrid POST /whitelabel/domains (or finds an existing one with the same domain) |
| Publish each CNAME to Cloudflare DNS | Cloudflare POST /zones/:id/dns_records (with proxied: false — critical, DKIM breaks otherwise) |
| Trigger SendGrid's validation check | SendGrid POST /whitelabel/domains/:id/validate |
| Enable Signed Event Webhook + fetch the ECDSA public key | SendGrid PATCH /user/webhooks/event/settings/signed + GET |
| Configure Event Webhook URL + event toggles (delivered, open, click, bounce, dropped, spamreport, unsubscribe) | SendGrid PATCH /user/webhooks/event/settings |
Print the env-var line to copy into your .env or secret manager | stdout |
Setup credentials
The script reads two env vars. Put them in your shell rc (e.g. ~/.zshrc) so they're available wherever you run the script:
# ~/.zshrc
export SENDGRID_API_KEY="SG.xxx" # full access, or at least Sender Authentication + Mail Settings
export CLOUDFLARE_API_TOKEN="cf-xxx" # see below for the exact permissionsThe Cloudflare token needs Zone:Read + DNS:Edit for the zone you're publishing into. Generate one in the Cloudflare dashboard → My Profile → API Tokens → Create Token → "Edit zone DNS" template → restrict to the specific zone (e.g. example.com).
Idempotency
Both the SendGrid and Cloudflare halves are fully idempotent. The script:
- Reads before it writes. Domain auth: fetched first; only created when missing. Signed webhook: only PATCHes if signing is off. Event webhook: matched by URL against the account's existing webhooks, then only PATCHed if toggle values differ. Cloudflare records: only POSTs if no matching record exists, only PUTs if content drifted.
- Touches only its own webhook. Other webhooks on the account — a second mailery install, a staging environment, an unrelated app — are listed but never modified. If none match
--webhook-url, a new webhook is created alongside them. - Errors before it overwrites destructive state. On older accounts that only expose SendGrid's single-webhook API, repointing the one webhook slot away from another consumer requires
--force. - Is safe to re-run. A second invocation against a fully-configured install issues only GET requests and exits 0.
Use the same command in a deploy script, a Makefile, or just whenever DNS / SendGrid setup feels off — re-running converges to the desired state without surprises.
Multiple sender domains in one run
If you isolate marketing from transactional (recommended — see Reputation isolation), pass --domain more than once and the script handles all of them in a single invocation. Domain auth + DNS publish runs per-domain; one Event Webhook — the one matching --webhook-url — is configured once at the end.
npx mailery setup-sendgrid \
--domain news.example.com \
--domain mail.example.com \
--webhook-url https://example.com/m/webhooks/sendgrid \
--cloudflareYou can also comma-separate: --domain news.example.com,mail.example.com.
Flags reference
| Flag | Default | Purpose |
|---|---|---|
--domain | required | The domain to authenticate (e.g. news.example.com). Repeat or comma-separate to authenticate multiple. |
--subdomain | em | The sub-label SendGrid uses for the link branding CNAME. |
--webhook-url | required | Public URL where SendGrid POSTs event webhooks. Updated in place if the account already has a webhook with this URL, otherwise created alongside the existing ones. |
--webhook-name | mailery <host> | friendly_name for a newly created webhook, so the SendGrid dashboard shows which app owns it. |
--cloudflare | off | Publish DNS records via the Cloudflare API. Requires CLOUDFLARE_API_TOKEN. |
--cloudflare-zone | inferred per domain | Override the parent zone (for multi-label public suffixes like .co.uk). |
--force | off | Legacy single-webhook accounts only: repoint the account's one webhook at --webhook-url, stopping event delivery to whatever consumed the old URL. Ignored on accounts with the multi-webhook API. |
One SendGrid account, several apps
Running mailery for two products, or staging next to production, works without --force: each install matches its own --webhook-url and gets its own signing key from SendGrid's per-webhook endpoint. Set SENDGRID_WEBHOOK_VERIFICATION_KEY per environment from that run's output — keys are not interchangeable between webhooks.
Without Cloudflare
Drop the --cloudflare flag and the script still does the SendGrid half — it prints the CNAMEs you need to publish to your DNS provider, then enables the webhook on the SendGrid side. Once your DNS is up, re-run the same command and it'll detect the records and trigger SendGrid's validation step.
npx mailery setup-sendgrid \
--domain news.example.com \
--webhook-url https://example.com/m/webhooks/sendgridPer-domain circuit breaker
The circuit breaker tracks hard-bounce, complaint, and combined-bounce rates over a rolling window. When any threshold is exceeded, marketing sends are held until manually resumed (transactional always flows through).
Rates are tracked per (sender domain × template kind) so one bad subdomain doesn't hold mail for the others. If news.example.com (marketing) trips, transactional sends from mail.example.com continue normally — and marketing sends from a different marketing subdomain also continue.
Defaults — see Configuration → Circuit breaker:
| Threshold | Default | What it means |
|---|---|---|
hardBounceRatePctTrip | 2% | Hard bounces over the window |
complaintRatePctTrip | 0.3% | Spam complaints — Gmail/Yahoo's actual cutoff |
combinedBounceRatePctTrip | 5% | Hard + soft bounces combined |
failedToSendRatePctDegrade | 10% | Provider errors — sets degraded state, doesn't block sends |
In the admin UI, the Health screen shows one row per bucket with rates colored against trip thresholds. Tripped buckets get a "Resume" button. The "Resume all" button at the top resumes every tripped bucket at once.
You can also resume programmatically:
# Resume one bucket
curl -X POST https://example.com/admin/mailer/api/health/resume \
-H 'content-type: application/json' \
-d '{"senderDomain": "news.example.com", "kind": "marketing"}'
# Resume all tripped buckets
curl -X POST https://example.com/admin/mailer/api/health/resume \
-H 'content-type: application/json' \
-d '{}'DNS block-list monitoring
Once a day mailery resolves each of your sender domains (and any dedicated IPs) against major DNS block lists. If a domain or IP shows up on a list, that signal lands in the admin Health screen and in setup-status as an error.
Enabled by default for any operator with sender domains configured — no extra setup. Customize the lists or interval via MailerConfig.dnsbl:
await Mailer.init({
// ...
dnsbl: {
// Default lists if you don't override:
domainLists: [
{ host: 'dbl.spamhaus.org', label: 'Spamhaus DBL' },
{ host: 'multi.surbl.org', label: 'SURBL' },
{ host: 'multi.uribl.com', label: 'URIBL' },
],
// Only meaningful if you have a dedicated sending IP:
dedicatedIps: ['203.0.113.5'],
ipLists: [
{ host: 'zen.spamhaus.org', label: 'Spamhaus ZEN' },
{ host: 'b.barracudacentral.org', label: 'Barracuda' },
{ host: 'dnsbl.sorbs.net', label: 'SORBS' },
{ host: 'bl.spamcop.net', label: 'SpamCop' },
],
intervalHours: 24,
// Free Spamhaus DQS key — needed when mailery runs behind a public or
// cloud resolver (see below):
spamhausDqsKey: process.env.SPAMHAUS_DQS_KEY,
},
})Spamhaus refuses queries that arrive through public or shared resolvers — Google, Cloudflare, and cloud VPC resolvers such as AWS's — and answers 127.255.255.254 instead of a verdict. Mailery records that as an error row reading "not a listing — the list refused the query", never as a listing. To get real Spamhaus verdicts from such a host, register for a free Data Query Service key and set spamhausDqsKey; every *.spamhaus.org list is then queried as <key>.<zone>.dq.spamhaus.net. Rows and the UI keep the public list name, and the key is never written to the database.
In the admin UI, the Health → DNS block lists card shows one row per (target × list) with the latest verdict and a "Recheck now" button. Each list publishes its own removal procedure — visit the list's website (linked from the list label) to start delisting.
Google Postmaster Tools
Postmaster is Google's authoritative view of how Gmail handles your mail — reputation tier (HIGH / MEDIUM / LOW / BAD), spam rate, SPF/DKIM/DMARC pass rates per day. Only meaningful at >100 sends/day to Gmail; smaller senders see empty responses.
Setup requires an OAuth client in your own Google Cloud project + a refresh token from the consent flow with https://www.googleapis.com/auth/postmaster.readonly scope. Once configured, mailery pulls daily snapshots and auto-trips the (domain × marketing) breaker when a domain falls to BAD.
await Mailer.init({
// ...
postmaster: {
clientId: process.env.GOOGLE_POSTMASTER_CLIENT_ID!,
clientSecret: process.env.GOOGLE_POSTMASTER_CLIENT_SECRET!,
refreshToken: process.env.GOOGLE_POSTMASTER_REFRESH_TOKEN!,
// Defaults to senderDomains + fromDefaults domain
domains: ['news.example.com', 'mail.example.com'],
intervalHours: 24,
},
})The admin Health → Google Postmaster Tools card shows the latest snapshot per domain — reputation pill, user-reported spam %, SPF/DKIM/DMARC pass rates. Manual refresh button + auto pull daily via the tick.
Microsoft SNDS
SNDS (Smart Network Data Services) is Microsoft's equivalent for Outlook / Hotmail / Live IP reputation. IP-level — only useful if you send from a dedicated IP. Visibility-only: RED filter results surface as a setup-status error but don't auto-trip the breaker.
await Mailer.init({
// ...
snds: {
accessKey: process.env.SNDS_ACCESS_KEY!,
ips: ['203.0.113.5'], // optional — filter to your IPs
intervalHours: 24,
},
})Get an access key by signing up at sendersupport.olc.protection.outlook.com/snds and enrolling each of your IPs. The same site is where you enrol in JMRP — the feedback loop that emails you when an Outlook user marks your mail as junk. JMRP enrolment is manual and outside mailery's scope, but the setup-status check reminds you.
The admin Health → Microsoft SNDS card shows per-IP filter result (GREEN / YELLOW / RED), complaint rate, trap message count, recipient count, and activity window.
DMARC RUA report ingestion
Why this matters
Every email says who it is from, and anyone can type any address in that field. Three DNS-based mechanisms let a receiving server (Gmail, Yahoo, Outlook) check:
- SPF lists the servers allowed to send mail for your domain. A receiver compares the sending server's IP to that list.
- DKIM puts a cryptographic signature on each message. A receiver checks it against a public key you publish in DNS.
- DMARC ties the two together. It says the visible From domain must match (be aligned with) the domain that passed SPF or DKIM, and it tells receivers what to do with mail that fails: a policy of
p=none(deliver it anyway, just report),p=quarantine(send it to spam) orp=reject(refuse it).
Alignment is the part that trips people up. A message can pass SPF for mail.vendor.com and still fail DMARC for you, because vendor.com is not your domain. Most "my mail goes to spam" mysteries are an alignment failure nobody saw.
DMARC also asks receivers to send you a daily aggregate report (the rua= address in your DMARC record). It is an XML file listing every IP address that sent mail claiming to be from your domain, how many messages, and whether they passed. It is the only place you can see:
- Silent breakage. A rotated DKIM key, an SPF edit, or a new tool sending as your domain makes your own mail fail. Nothing errors; it goes to spam.
- Spoofing. Until your policy is
p=reject, anyone can send as your domain and receivers will deliver it.
The goal for each sending domain is p=none, then p=quarantine, then p=reject, without blocking your own mail. Reports are the evidence that tightening is safe, and the early warning when something breaks later. Gmail, Yahoo and Microsoft now require DMARC from bulk senders.
Raw reports are unreadable XML that nobody opens. DMARC Monitoring has mailery receive them, parse them, check your DNS, and call your code (onDmarcAlert) when something needs attention.
15-minute setup
You need a mailbox that can receive the reports and turn each email into an HTTP request. The documented path is SendGrid Inbound Parse.
Pick an inbound host, a subdomain used only for this, such as
dmarc-in.example.com. Reports will be addressed toreports@dmarc-in.example.com.Point its MX at SendGrid. Add
dmarc-in.example.com MX 10 mx.sendgrid.net.Add the Inbound Parse destination in SendGrid (Settings → Inbound Parse): host
dmarc-in.example.com, URL with the shared secret as the basic-auth password:https://mailery:<secret>@your-app/m/inbound/dmarcGenerate the secret yourself (
openssl rand -hex 32). The username is ignored.Mount the route with the same secret. Without a secret the route does not exist.
tsapp.use( '/m', createPublicRouter(mailer, { dmarcInbound: { secret: process.env.MAILERY_DMARC_SECRET }, // absent → route not mounted }), )Tell mailery the mailbox so it can check your DNS against it:
tsawait Mailer.init({ // ... dmarc: { reportAddress: 'reports@dmarc-in.example.com', adminUrl: 'https://your-app/admin/mailer', // optional; copied into every alert }, })Publish
rua=for each sending domain:bashnpx mailery setup-dmarc \ --domain example.com \ --rua-mailbox reports@dmarc-in.example.com \ --policy none \ --cloudflareSee Publishing the DMARC record for the flags. Start at
p=none.Publish the external authorization record when the report mailbox is on a different organizational domain from the domain being reported on (for example,
example.comreports go toreports@dmarc-in.other.com). Receivers refuse to send reports to another domain unless that domain says it agrees. Publish at the mailbox's domain:example.com._report._dmarc.dmarc-in.other.com. TXT "v=DMARC1"The general form is
<reported domain>._report._dmarc.<inbound host>. One wildcard record,*._report._dmarc.<inbound host> TXT "v=DMARC1", authorizes every domain at once. If the mailbox is on the same organizational domain as the domain being reported on (reports@dmarc-in.example.comforexample.com), you do not need this record.setup-dmarcprints, and with--cloudflarepublishes, the records you need.Verify in the admin UI. Open DMARC Monitoring → Setup. Each domain shows the DNS check result with the exact record to publish for anything wrong. Then press Send test alert: your
onDmarcAlertshould receive an alert of kindtest.
Reports start arriving within a day or two, one per receiver per day. Until the first one lands, the screen says so; that is normal.
What triggers an alert
Alerts are grouped per domain and kind, never per IP, so one noisy week is one alert, not fifty. Thresholds are defaults; every one is editable in DMARC Monitoring → Settings or via dmarc.alerts. "Window" is the last 7 days of report data (by the period the report covers, not when it arrived, so uploading a backlog does not alert).
| Kind | When it fires | Default threshold | Severity |
|---|---|---|---|
unknown_source_failing | An IP you have not labeled sent mail as your domain and failed DMARC. Either a tool you forgot to set up, or someone spoofing you. | 10 or more failing messages from untagged IPs in the window | warning; critical if one IP sent 10 times the threshold |
known_source_failing | An IP you labeled as yours is failing. Your own mail is at risk. | 5 or more failing messages from tagged IPs | critical |
alignment_drop | The share of messages passing DMARC fell below your bar. Failures from IPs you tagged as ignored do not count against it. | Pass rate under 98%, with at least 100 messages in the window | warning; critical under 90% |
reports_stopped | A domain that used to get reports has had none for a while: it stopped sending, rua= changed, or the inbound webhook is failing. | No report ending in the last 7 days | warning |
policy_ready | The data supports moving to a stricter policy (the progression gates pass). | Same gates as the progression suggestion | info |
dns_misconfigured | The DNS check found an error: no DMARC record, two DMARC records, a malformed one, no rua=, a missing external authorization record, or a rua domain with no MX. | Any error-level issue | warning; critical if the DMARC record is missing |
An alert fires as opened the first time, updated when a new source IP joins an open alert, reminder if it is still open after realertAfterHours (default 168, one week; 0 turns reminders off), and resolved when the condition clears. policy_ready never sends resolved. A test event comes from the Send test alert button.
Delivering alerts
Mailery never sends Slack messages or emails itself. It calls onDmarcAlert and you forward. alert.text is a finished plain-text message (title, summary, sources with reverse DNS, DNS fixes, what to do, link to the admin screen), so forwarding it as is works.
Report contents are attacker-supplied: anyone can email your report mailbox. Mailery strips control characters and angle brackets from report-derived text in alert.text (IPs, reverse DNS, reporter names, labels and DNS messages), so hosts can post it as plain text.
Slack, using an incoming-webhook URL:
onDmarcAlert: async (alert) => {
const res = await fetch(process.env.SLACK_DMARC_WEBHOOK_URL!, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: alert.text }),
})
if (!res.ok) throw new Error(`slack ${res.status}`)
},Email, using any transport you already have:
onDmarcAlert: async (alert) => {
if (alert.severity === 'info') return // skip policy_ready if you only want problems
await transporter.sendMail({
from: 'alerts@example.com',
to: 'ops@example.com',
subject: `[${alert.severity}] ${alert.title}`,
text: alert.text,
})
},If the hook throws or takes longer than 10 seconds, mailery swallows the error and records it on the alert (lastDelivery, visible as the alert's last delivery in the admin UI). A failed or timed-out delivery is retried hourly while it keeps failing; fix the hook to stop it. With no hook configured, alerts still appear in the admin UI and the Setup card says so.
DmarcAlert fields
| Field | Type | Meaning |
|---|---|---|
id | string | Stable key kind|domain|subject. The same id means the same ongoing problem. |
kind | string | One of the six kinds above, or test. |
event | string | opened, updated, reminder, resolved or test. |
severity | string | info, warning or critical. |
domain | string | The domain the alert is about. |
title, message, recommendation | string | Short headline, what happened, and what to do. recommendation is empty on resolved and test. |
text | string | All of the above plus sources and DNS issues, as plain text ready to post. |
detectedAt | Date | When this event was produced. |
firstDetectedAt | Date | When this problem was first seen. |
window | { start, end } | The period of report data the alert covers. |
summary | object | domain, policy, pct, windowDays, reportCount, totalMessages, passCount, failCount, alignmentRate (0 to 1, or null), lastReportAt. |
sources | array | Up to 25 sending IPs by message count, each with ip, ptr (reverse DNS, or null), label, messages, daysSeen, firstSeenDay, lastSeenDay, headerFrom, dkimResult, spfResult, disposition, reporters. Empty except for the two source kinds. |
newSourceIps | string[] | On updated: the IPs that were not in the alert before. Otherwise empty. |
threshold | object or null | { name, limit, actual } for the rule that fired. |
suggestedPolicy | object or null | { policy, pct, reason }, set only for policy_ready. |
dnsIssues | array | For dns_misconfigured: each issue's code, severity, message and the fix record to publish. |
adminUrl | string or null | dmarc.adminUrl, if set. |
What the screen edits and what stays in config
| Setting | Edit in the GUI | Config |
|---|---|---|
| Alert thresholds, window, disabled kinds, reminder interval, DNS check interval | Yes (Settings) | dmarc.alerts is the baseline |
reportAddress, extraDomains, ignoredDomains | Yes (Settings) | dmarc.* is the baseline |
| Source tags | Yes (report tables) | dmarc.knownSources is the baseline |
dmarcInbound.secret and path | No | createPublicRouter options |
onDmarcAlert hook | No | MailerConfig.onDmarcAlert |
adminUrl | No | dmarc.adminUrl |
| DNS records | No (the screen shows what to publish) | npx mailery setup-dmarc |
Config is the baseline; one saved document overrides it key by key; resetting to config deletes the document. Secrets and the hook are config-only, so nothing sensitive is stored in the database or editable through the admin UI.
Securing the inbound endpoint
Read this before you enable it
SendGrid Inbound Parse does not sign its payloads. The SendGrid Event Webhook does, and mailery verifies that signature (plus a replay window) on /m/webhooks/sendgrid. Inbound Parse is a different product with no signature, no HMAC and no verifiable identity, so there is nothing to verify.
This endpoint is authenticated by a shared secret and nothing else. It is off unless you set that secret, it is never mounted by default, and it will not appear on an upgrade.
- Use basic auth in the destination URL, as in step 3. The secret travels in an
Authorizationheader.Authorization: Bearer <secret>also works. A secret in the URL path works too, but paths are written to every access, proxy and load-balancer log. - The secret is compared in constant time before any of the body is read, so an anonymous caller cannot make the process buffer an upload.
- Size and count limits apply, only
.zip,.gzand.xmlattachments are taken, and a message with none (an auto-reply, a human) is a200withingested: 0. - Reports go through the same parser as the admin upload: decompression caps, declared-versus-actual size checks, a compression-ratio check, a zip-slip guard.
- A report whose
policy_published.domainis not a domain you send from is rejected. If the secret leaks, the worst case is bogus report rows about your own domains: no mail is sent, no contact data is read, nothing is deleted. Rotate by changing the config value and the Inbound Parse URL. - Do not use a source-IP allowlist as the only control. SendGrid publishes no stable Inbound Parse egress range, and behind a proxy
req.ipis the proxy's address. It is a fine second layer in your ingress.
| Option | Default | Purpose |
|---|---|---|
secret | none | Shared secret. Absent or empty means the route does not exist. |
path | /inbound/dmarc | Sub-path on the public router. |
maxFileSizeBytes | 10 * 1024 * 1024 | Per-attachment cap, matching the admin upload. |
maxFiles | 10 | Attachments accepted per message. |
allowedDomains | derived | Domains whose reports are accepted. Defaults to senderDomains plus the From defaults and each one's organizational domain. |
parseInbound | SendGrid shape | Seam for other inbound parsers. Only the SendGrid shape is implemented. |
Responses: 200 ingested (or nothing to ingest), 400 nothing usable in the message, 401 bad or missing secret, 413 over the size or count limit.
Not the admin upload route
Do not point Inbound Parse at /admin/mailer/api/dmarc/upload. Versions before 0.15 recommended that and it never worked: the route sits behind your requireAdmin guard, which Inbound Parse cannot satisfy.
Not supported
- Forensic (
ruf) reports. - TLS-RPT reports.
- BIMI.
- DKIM selector checks (the DNS check covers DMARC, SPF, the report address and its MX, not individual DKIM keys).
- CIDR source tags (tag one IP at a time).
- Mailbox polling (IMAP or the Gmail API).
- Mailgun and Postmark inbound parsers.
- Editing DNS from the admin UI.
- Built-in Slack or email delivery (use
onDmarcAlert).
Publishing the DMARC record
If you ran setup-sendgrid, you have working SPF and DKIM but no DMARC by default. Publish a DMARC record with the CLI:
npx mailery setup-dmarc \
--domain news.example.com \
--rua-mailbox reports@dmarc-in.example.com \
--policy none \
--cloudflare| Flag | Default | Purpose |
|---|---|---|
--domain | required | The domain to publish DMARC for. |
--rua-mailbox | required | Mailbox that receives RUA aggregate reports. |
--ruf-mailbox | unset | Mailbox for forensic reports (rarely used). |
--policy | none | none / quarantine / reject. Start with none. |
--pct | 100 | Percent of failing mail subject to the policy. |
--aspf | r | SPF alignment mode (r relaxed / s strict). |
--adkim | r | DKIM alignment mode (r / s). |
--cloudflare | off | Publish via the Cloudflare API. |
Without --cloudflare the command prints the TXT record for manual publish. When the report mailbox is on a different organizational domain, it also prints (or publishes) the external authorization record from step 7. Use --policy quarantine --pct 10 after a few weeks of p=none data to start enforcement; the policy progression suggestion tells you when.
Fallback: upload reports by hand
If you cannot receive email at a domain you control, receivers' daily reports can be uploaded instead. They arrive as .zip or .gz attachments to whatever rua= mailbox you used. Open the admin DMARC Monitoring screen (or Health → DMARC RUA reports) and use "Upload report(s)". Multi-file upload is supported. Alerts, DNS checks and the report tables work the same; only the arrival path differs, and each report records whether it came by inbound or upload.
Mailery decompresses, parses (RFC 7489), and persists:
- One
DmarcReportDocper received report: total messages, pass / fail counts, policy and pct in effect, reporting window, and how it arrived (via). - One
DmarcFailureDocper non-aligned source IP per report. These are the actionable rows.
Re-uploading the same report is idempotent (keyed on reportId × orgName).
Tag known sources
After a week or two of reports, the Top failing source IPs table lists every IP sending as your domain that didn't pass DMARC alignment, sorted by message count. Click "Tag" on each row and label it:
SendGridfor your transactional + marketing providerHubspot/Calendly/Stripefor any SaaS sending as you- Untagged rows are either misconfigured legitimate sources (fix their auth) or spoofers (ignore them)
Tags persist in the mailer_dmarc_source_tags collection and merge with MailerConfig.dmarc.knownSources at read time. Precedence: a DB-set tag overrides a config tag for the same IP, so an operator can re-label or ignore a source through the admin UI without redeploying. Removing a DB tag via the UI restores the config baseline for that IP.
await Mailer.init({
// ...
dmarc: {
knownSources: [
{ ip: '149.72.45.10', label: 'SendGrid (transactional)' },
{ ip: '203.0.113.99', label: 'Old marketing platform', ignored: true },
],
retentionDays: 90, // failures older than this are pruned (housekeeping runs hourly)
},
})Policy progression suggestion
When enough clean data accumulates, the admin UI surfaces a per-domain suggestion to advance your DMARC policy. Every transition checks all of these gates — failing any one blocks the suggestion:
| From | To | All gates required |
|---|---|---|
p=none | p=quarantine pct=10 | ≥30 reports in last 30d, ≥1000 total messages, ≥99% alignment rate, zero failing messages from untagged sources |
p=quarantine pct=10 | pct=25 | Same gates as above, plus alignment ≥99.5% |
p=quarantine pct=25 | pct=50 | As above |
p=quarantine pct=50 | pct=100 | As above |
p=quarantine pct=100 | p=reject | Same gates, plus alignment ≥99.9% |
p=reject | — | Already at strictest |
The "no untagged failing sources" gate is the most common blocker — fix the source (deploy DKIM, or tag it as known/ignored) before the suggestion will fire.
List hygiene
Inactive contacts disproportionately bounce, complain, or sit in spam folders harming engagement metrics. The admin List hygiene screen (under Audience) buckets your subscribed contacts by how recently they last opened or clicked any mail:
- Engaged (last 30 days)
- Engaged (31-60 / 61-90 / 91-180 days)
- Inactive (>180 days)
- Never engaged
For the long-inactive cohort, the report shows:
- Lifetime metrics: total sends to this cohort, their historical bounce / complaint rates.
- Projected impact: "Of the last 90 days' N sends, the cohort generated X bounces (Y% of total). Sunsetting them would drop overall bounce rate from A% to B%."
The screen is read-only — sunset decisions are explicit. To suppress an inactive cohort, add them to the suppressions collection through the existing UI or your own script. Use the bucket counts to pick a window (typically >180 days) and decide whether the projected impact justifies the action.
Content linter
Every template is run through a content linter at publish time. Errors block publish; warnings + infos surface in the editor's Issues panel and the publish response.
| Rule | Severity | Trigger |
|---|---|---|
missing_plain_text | error | No plain-text alternative |
image_only_body | error | Rendered text < 20 chars and ≥1 image |
url_shortener | error | Any link uses bit.ly / t.co / tinyurl.com / goo.gl / ow.ly |
missing_unsubscribe_tag | error | Marketing template lacks {{unsubscribeUrl}} |
sender_domain_invalid | error | fromEmail doesn't match the senderDomains registry |
bare_url | warning | URL appears in body text without an anchor wrapper |
spam_phrases | warning | Contains "FREE", "ACT NOW", "100% guaranteed", !!!+ |
all_caps_subject | warning | Subject >50% uppercase |
subject_too_long | warning | Subject >60 chars (mobile truncation) |
too_many_links | warning | >10 links in body |
offdomain_links | warning | More than half the resolvable links point away from the fromEmail domain |
insecure_link | warning | A link uses http:// rather than https:// |
image_missing_alt | warning | An <img> has no alt text (or alt="") |
image_only_link | warning | A link wraps an image with no text label |
empty_preheader | info | No preheader set |
offdomain_links compares hostnames loosely — exact match, subdomain either way, or a shared last-two-label suffix — so mail.example.com and www.example.com count as the same site. Merge-tag hrefs ({{unsubscribeUrl}}), mailto:, and relative URLs are skipped since they carry no domain to compare. insecure_link is a scheme check only; link liveness (200 vs 404) needs network I/O and stays out of the pure lint pass.
The template editor sidebar shows live results as you type. Saved drafts are debounced (500ms after last keystroke) and the Publish button is disabled while errors are present.
Mail-Tester integration (optional)
For an objective deliverability score, configure Mail-Tester's paid API. Once enabled, the template editor grows a "Run deliverability check" button:
- Click → mailery provisions a test address from Mail-Tester, sends the rendered draft to it via your default provider, polls for the score.
- Score (0-10) + per-rule feedback render in the editor sidebar.
- Score is cached for
cacheHours(default 24) keyed on(bodyHash, subject, fromEmail)so re-clicking publish doesn't burn credits. - Publish is blocked when
score < minScore(default 8.0); override withbypassMailTester: trueon the publish call.
await Mailer.init({
// ...
mailTester: {
apiKey: process.env.MAIL_TESTER_API_KEY!,
minScore: 8.0,
requireScore: true, // refuse to publish content that was never checked
cacheHours: 24,
},
})How strict the gate is
By default the gate only stops content already known to be bad. Because the cache key is (bodyHash, subject, fromEmail), editing a template after a failing score produces a new key, misses the cache, and publishes — a 4.2 is one whitespace change away from irrelevant.
Set requireScore: true to close that: an unchecked content revision is refused with 422 { error: 'mail_tester_blocked', code: 'no_score' } and the operator must run a check first. Budget one credit per content revision you intend to publish. A score below minScore still returns code: 'low_score' with the cached score attached, and bypassMailTester: true overrides both.
Mailery never auto-triggers a check during publish — that would send real mail as a side effect of a publish request.
Each check sends one real email through your provider — audit-logged. Skip the integration if you don't have a Mail-Tester paid plan; the publish path is silent when not configured.