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 Handlebars helper
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: only PATCHes if URL or toggle values differ. Cloudflare records: only POSTs if no matching record exists, only PUTs if content drifted.
  • Errors before it overwrites destructive state. If a different webhook URL is already configured, the script refuses to clobber it without --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; the Event Webhook itself is an account-level setting and is only 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. Account-level, configured once.
--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).
--forceoffAllow overwriting an existing event webhook URL.

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,
  },
})

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

DMARC RUA aggregate reports are the only place you can see who is sending mail claiming to be from your domain — legitimate sources, forgotten SaaS tools, and active spoofers. Most operators publish rua=mailto:... and never read the reports because the raw XML is unreadable. Mailery parses the reports, extracts non-aligned source IPs, and surfaces them in the admin UI.

Set up the DMARC TXT record

If you ran setup-sendgrid, you have working SPF + DKIM but no DMARC by default. Publish a DMARC record with the CLI:

bash
npx mailery setup-dmarc \
  --domain news.example.com \
  --rua-mailbox dmarc-reports@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. Use --policy quarantine --pct 10 after a few weeks of p=none data to start enforcement — see the policy progression suggestion below.

Upload received reports

Receivers (Google, Yahoo, Microsoft, etc.) email one report per day per domain, attached as .zip or .gz. Open the admin Health → DMARC RUA reports card and use the "Upload report(s)" button. Multi-file upload supported.

Mailery decompresses, parses (RFC 7489), and persists:

  • One DmarcReportDoc per received report — total messages, pass / fail counts, policy + pct in effect, reporting window.
  • 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=rejectAlready 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
empty_preheaderinfoNo preheader set

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,
    cacheHours: 24,
  },
})

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.