Skip to content

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:

RecordPurposeFailure mode
SPF (TXT)Lists which servers may send mail "from" your domainRecipients reject as "unauthorized origin"
DKIM (TXT)Cryptographic signature on each outbound messageRecipients can't verify message integrity → spam folder
DMARC (TXT)Policy telling recipients what to do if SPF + DKIM failWithout 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":

  1. Enter your sender domain (e.g. yourdomain.com).
  2. Choose whether to use a dedicated link branding subdomain (recommended). SendGrid will give you a CNAME for it (e.g. mail._domainkey.yourdomain.com).
  3. 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.net

Hosts 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 enforcement
  • rua — aggregate reports email
  • ruf — 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:

  1. Use mail-tester.com. Send a test email from your app to the address it shows, then check the score. 8+ / 10 is healthy.
  2. 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
  3. MXToolbox has free SPF + DKIM + DMARC lookups. Search "MXToolbox SPF Check" or "DKIM Lookup".

Common pitfalls ​

  • Multiple SPF records. Only one v=spf1 record per domain. If you have multiple, merge their includes into a single record.
  • include: chain limit. SPF has a 10-lookup limit per query. Each include: counts. If you include: 11+ providers (mailgun, sendgrid, gsuite, …), the SPF record breaks silently. Tools like dmarcian.com show 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 drips
  • mail.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:

ts
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.

bash
npx mailery setup-sendgrid \
  --domain news.example.com \
  --webhook-url https://example.com/m/webhooks/sendgrid \
  --cloudflare

Or run it with no args for an interactive walkthrough that asks for each value:

bash
npx mailery setup-sendgrid

The 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 ​

StepSource → target
Look up the Cloudflare Zone IDCloudflare GET /zones?name=...
Create or reuse the SendGrid domain authenticationSendGrid POST /whitelabel/domains (or finds an existing one with the same domain)
Publish each CNAME to Cloudflare DNSCloudflare POST /zones/:id/dns_records (with proxied: false — critical, DKIM breaks otherwise)
Trigger SendGrid's validation checkSendGrid POST /whitelabel/domains/:id/validate
Enable Signed Event Webhook + fetch the ECDSA public keySendGrid 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 managerstdout

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:

bash
# ~/.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 permissions

The 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.

bash
npx mailery setup-sendgrid \
  --domain news.example.com \
  --domain mail.example.com \
  --webhook-url https://example.com/m/webhooks/sendgrid \
  --cloudflare

You can also comma-separate: --domain news.example.com,mail.example.com.

Flags reference ​

FlagDefaultPurpose
--domainrequiredThe domain to authenticate (e.g. news.example.com). Repeat or comma-separate to authenticate multiple.
--subdomainemThe sub-label SendGrid uses for the link branding CNAME.
--webhook-urlrequiredPublic 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-namemailery <host>friendly_name for a newly created webhook, so the SendGrid dashboard shows which app owns it.
--cloudflareoffPublish DNS records via the Cloudflare API. Requires CLOUDFLARE_API_TOKEN.
--cloudflare-zoneinferred per domainOverride the parent zone (for multi-label public suffixes like .co.uk).
--forceoffLegacy 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.

bash
npx mailery setup-sendgrid \
  --domain news.example.com \
  --webhook-url https://example.com/m/webhooks/sendgrid

Per-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:

ThresholdDefaultWhat it means
hardBounceRatePctTrip2%Hard bounces over the window
complaintRatePctTrip0.3%Spam complaints — Gmail/Yahoo's actual cutoff
combinedBounceRatePctTrip5%Hard + soft bounces combined
failedToSendRatePctDegrade10%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:

bash
# 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:

ts
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.

ts
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.

ts
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) or p=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:

  1. 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.
  2. 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.

  1. Pick an inbound host, a subdomain used only for this, such as dmarc-in.example.com. Reports will be addressed to reports@dmarc-in.example.com.

  2. Point its MX at SendGrid. Add dmarc-in.example.com MX 10 mx.sendgrid.net.

  3. 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/dmarc

    Generate the secret yourself (openssl rand -hex 32). The username is ignored.

  4. Mount the route with the same secret. Without a secret the route does not exist.

    ts
    app.use(
      '/m',
      createPublicRouter(mailer, {
        dmarcInbound: { secret: process.env.MAILERY_DMARC_SECRET }, // absent → route not mounted
      }),
    )
  5. Tell mailery the mailbox so it can check your DNS against it:

    ts
    await Mailer.init({
      // ...
      dmarc: {
        reportAddress: 'reports@dmarc-in.example.com',
        adminUrl: 'https://your-app/admin/mailer', // optional; copied into every alert
      },
    })
  6. Publish rua= for each sending domain:

    bash
    npx mailery setup-dmarc \
      --domain example.com \
      --rua-mailbox reports@dmarc-in.example.com \
      --policy none \
      --cloudflare

    See Publishing the DMARC record for the flags. Start at p=none.

  7. Publish the external authorization record when the report mailbox is on a different organizational domain from the domain being reported on (for example, example.com reports go to reports@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.com for example.com), you do not need this record. setup-dmarc prints, and with --cloudflare publishes, the records you need.

  8. 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 onDmarcAlert should receive an alert of kind test.

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).

KindWhen it firesDefault thresholdSeverity
unknown_source_failingAn 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 windowwarning; critical if one IP sent 10 times the threshold
known_source_failingAn IP you labeled as yours is failing. Your own mail is at risk.5 or more failing messages from tagged IPscritical
alignment_dropThe 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 windowwarning; critical under 90%
reports_stoppedA 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 dayswarning
policy_readyThe data supports moving to a stricter policy (the progression gates pass).Same gates as the progression suggestioninfo
dns_misconfiguredThe 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 issuewarning; 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:

ts
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:

ts
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 ​

FieldTypeMeaning
idstringStable key kind|domain|subject. The same id means the same ongoing problem.
kindstringOne of the six kinds above, or test.
eventstringopened, updated, reminder, resolved or test.
severitystringinfo, warning or critical.
domainstringThe domain the alert is about.
title, message, recommendationstringShort headline, what happened, and what to do. recommendation is empty on resolved and test.
textstringAll of the above plus sources and DNS issues, as plain text ready to post.
detectedAtDateWhen this event was produced.
firstDetectedAtDateWhen this problem was first seen.
window{ start, end }The period of report data the alert covers.
summaryobjectdomain, policy, pct, windowDays, reportCount, totalMessages, passCount, failCount, alignmentRate (0 to 1, or null), lastReportAt.
sourcesarrayUp 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.
newSourceIpsstring[]On updated: the IPs that were not in the alert before. Otherwise empty.
thresholdobject or null{ name, limit, actual } for the rule that fired.
suggestedPolicyobject or null{ policy, pct, reason }, set only for policy_ready.
dnsIssuesarrayFor dns_misconfigured: each issue's code, severity, message and the fix record to publish.
adminUrlstring or nulldmarc.adminUrl, if set.

What the screen edits and what stays in config ​

SettingEdit in the GUIConfig
Alert thresholds, window, disabled kinds, reminder interval, DNS check intervalYes (Settings)dmarc.alerts is the baseline
reportAddress, extraDomains, ignoredDomainsYes (Settings)dmarc.* is the baseline
Source tagsYes (report tables)dmarc.knownSources is the baseline
dmarcInbound.secret and pathNocreatePublicRouter options
onDmarcAlert hookNoMailerConfig.onDmarcAlert
adminUrlNodmarc.adminUrl
DNS recordsNo (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 Authorization header. 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, .gz and .xml attachments are taken, and a message with none (an auto-reply, a human) is a 200 with ingested: 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.domain is 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.ip is the proxy's address. It is a fine second layer in your ingress.
OptionDefaultPurpose
secretnoneShared secret. Absent or empty means the route does not exist.
path/inbound/dmarcSub-path on the public router.
maxFileSizeBytes10 * 1024 * 1024Per-attachment cap, matching the admin upload.
maxFiles10Attachments accepted per message.
allowedDomainsderivedDomains whose reports are accepted. Defaults to senderDomains plus the From defaults and each one's organizational domain.
parseInboundSendGrid shapeSeam 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:

bash
npx mailery setup-dmarc \
  --domain news.example.com \
  --rua-mailbox reports@dmarc-in.example.com \
  --policy none \
  --cloudflare
FlagDefaultPurpose
--domainrequiredThe domain to publish DMARC for.
--rua-mailboxrequiredMailbox that receives RUA aggregate reports.
--ruf-mailboxunsetMailbox for forensic reports (rarely used).
--policynonenone / quarantine / reject. Start with none.
--pct100Percent of failing mail subject to the policy.
--aspfrSPF alignment mode (r relaxed / s strict).
--adkimrDKIM alignment mode (r / s).
--cloudflareoffPublish 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 DmarcReportDoc per received report: total messages, pass / fail counts, policy and pct in effect, reporting window, and how it arrived (via).
  • One DmarcFailureDoc per 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:

  • SendGrid for your transactional + marketing provider
  • Hubspot / Calendly / Stripe for 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.

ts
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:

FromToAll gates required
p=nonep=quarantine pct=10≥30 reports in last 30d, ≥1000 total messages, ≥99% alignment rate, zero failing messages from untagged sources
p=quarantine pct=10pct=25Same gates as above, plus alignment ≥99.5%
p=quarantine pct=25pct=50As above
p=quarantine pct=50pct=100As above
p=quarantine pct=100p=rejectSame 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.

RuleSeverityTrigger
missing_plain_texterrorNo plain-text alternative
image_only_bodyerrorRendered text < 20 chars and ≥1 image
url_shortenererrorAny link uses bit.ly / t.co / tinyurl.com / goo.gl / ow.ly
missing_unsubscribe_tagerrorMarketing template lacks {{unsubscribeUrl}}
sender_domain_invaliderrorfromEmail doesn't match the senderDomains registry
bare_urlwarningURL appears in body text without an anchor wrapper
spam_phraseswarningContains "FREE", "ACT NOW", "100% guaranteed", !!!+
all_caps_subjectwarningSubject >50% uppercase
subject_too_longwarningSubject >60 chars (mobile truncation)
too_many_linkswarning>10 links in body
offdomain_linkswarningMore than half the resolvable links point away from the fromEmail domain
insecure_linkwarningA link uses http:// rather than https://
image_missing_altwarningAn <img> has no alt text (or alt="")
image_only_linkwarningA link wraps an image with no text label
empty_preheaderinfoNo 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:

  1. Click → mailery provisions a test address from Mail-Tester, sends the rendered draft to it via your default provider, polls for the score.
  2. Score (0-10) + per-rule feedback render in the editor sidebar.
  3. Score is cached for cacheHours (default 24) keyed on (bodyHash, subject, fromEmail) so re-clicking publish doesn't burn credits.
  4. Publish is blocked when score < minScore (default 8.0); override with bypassMailTester: true on the publish call.
ts
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.

Released under the MIT License.