<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Greg Pabijan-Morawski</title>
    <description>The latest articles on DEV Community by Greg Pabijan-Morawski (@greg_pabijanmorawski).</description>
    <link>https://dev.to/greg_pabijanmorawski</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086301%2F0c2a5471-9da2-4434-ab1d-733495e6866a.png</url>
      <title>DEV Community: Greg Pabijan-Morawski</title>
      <link>https://dev.to/greg_pabijanmorawski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/greg_pabijanmorawski"/>
    <language>en</language>
    <item>
      <title>SPF, DKIM and DMARC, explained by someone who had to make it work</title>
      <dc:creator>Greg Pabijan-Morawski</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:06:30 +0000</pubDate>
      <link>https://dev.to/greg_pabijanmorawski/spf-dkim-and-dmarc-explained-by-someone-who-had-to-make-it-work-2hnb</link>
      <guid>https://dev.to/greg_pabijanmorawski/spf-dkim-and-dmarc-explained-by-someone-who-had-to-make-it-work-2hnb</guid>
      <description>&lt;p&gt;I build a transactional email API, which means I have spent an unreasonable amount of my life reading DMARC aggregate reports and explaining why password reset emails land in spam even though "nothing changed."&lt;/p&gt;

&lt;p&gt;Most email authentication documentation is either a vendor wizard telling you to paste a record, or an RFC. This is the middle thing I wanted when I started: what each mechanism does, what breaks, and the order to do it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why any of this exists
&lt;/h2&gt;

&lt;p&gt;SMTP was designed in 1982 with no concept of authentication. A mail server accepts a connection and believes what it is told. Concretely, a single message carries &lt;strong&gt;two different sender addresses&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;MAIL FROM&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt;&amp;lt;bounces@sendinginfra.example&amp;gt;   &amp;lt;-- the envelope sender (SMTP-level)&lt;/span&gt;
&lt;span class="nt"&gt;RCPT TO&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt;&amp;lt;you@gmail.com&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;DATA
From&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; Support &amp;lt;support@yourbank.com&amp;gt;        &amp;lt;-- the header From (what the user sees)&lt;/span&gt;
&lt;span class="nt"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; Please confirm your account&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The envelope sender is a routing detail: it is where bounces go. The &lt;code&gt;From:&lt;/code&gt; header is just a line of text inside the message. &lt;strong&gt;Nothing in SMTP connects them.&lt;/strong&gt; Your mail client displays the second and ignores the first.&lt;/p&gt;

&lt;p&gt;So the whole attack is: connect to Gmail from any VPS, put &lt;code&gt;From: support@yourbank.com&lt;/code&gt; in the headers, done. Every mechanism below closes part of that gap.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt; authorises the &lt;em&gt;envelope&lt;/em&gt; sender's domain against the connecting IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt; cryptographically signs the &lt;em&gt;message&lt;/em&gt;, independent of the connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt; ties either of those back to the &lt;em&gt;visible&lt;/em&gt; &lt;code&gt;From:&lt;/code&gt; domain and tells receivers what to do when neither matches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need all three. SPF and DKIM on their own authenticate things the user never sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  SPF: which IPs may send for this domain
&lt;/h2&gt;

&lt;p&gt;SPF is one TXT record at the domain apex, listing authorised senders.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com.  IN  TXT  "v=spf1 include:amazonses.com include:_spf.google.com ip4:203.0.113.7 -all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read left to right; the first mechanism that matches wins.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;include:&lt;/code&gt; — pull in another domain's SPF record (your ESP, your CRM, Google Workspace).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip4:&lt;/code&gt; / &lt;code&gt;ip6:&lt;/code&gt; — literal addresses or CIDR blocks.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;a&lt;/code&gt;, &lt;code&gt;mx&lt;/code&gt; — the domain's own A/MX records.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;all&lt;/code&gt; — matches everything; the qualifier on it is your default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The qualifier on &lt;code&gt;all&lt;/code&gt; is the only interesting policy decision:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;th&gt;receiver behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hard fail&lt;/td&gt;
&lt;td&gt;reject or heavily penalise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;soft fail&lt;/td&gt;
&lt;td&gt;accept but mark suspicious&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;?all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;neutral&lt;/td&gt;
&lt;td&gt;no opinion, i.e. pointless&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use &lt;code&gt;~all&lt;/code&gt; while you are still discovering which systems send as you, then move to &lt;code&gt;-all&lt;/code&gt;. Do not stop at &lt;code&gt;~all&lt;/code&gt; forever: once DMARC is at enforcement, &lt;code&gt;-all&lt;/code&gt; is what makes an unauthorised relay actually fail rather than merely look odd.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 10-lookup limit, and how everyone blows it
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4" rel="noopener noreferrer"&gt;RFC 7208 §4.6.4&lt;/a&gt; caps SPF evaluation at &lt;strong&gt;10 DNS-querying mechanisms&lt;/strong&gt;. &lt;code&gt;include&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;mx&lt;/code&gt;, &lt;code&gt;ptr&lt;/code&gt;, &lt;code&gt;exists&lt;/code&gt; and &lt;code&gt;redirect&lt;/code&gt; each count — and they count &lt;em&gt;recursively&lt;/em&gt;. Exceed it and the result is &lt;code&gt;permerror&lt;/code&gt;, which most receivers treat as "SPF did not pass."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ip4:&lt;/code&gt; and &lt;code&gt;ip6:&lt;/code&gt; cost nothing. That is the escape hatch.&lt;/p&gt;

&lt;p&gt;This record looks harmless and is already broken:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"v=spf1 include:_spf.google.com include:amazonses.com include:servers.mcsv.net include:_spf.salesforce.com include:spf.protection.outlook.com ~all"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_spf.google.com&lt;/code&gt; alone expands to three further includes. Five vendor includes routinely resolve to 12–15 lookups. The failure is silent: nothing bounces, deliverability just degrades, and it degrades &lt;em&gt;for every message from the domain&lt;/em&gt;, not just the vendor that pushed you over.&lt;/p&gt;

&lt;p&gt;Fixes, in order of preference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delete includes for services you no longer use. Most SPF records are archaeology.&lt;/li&gt;
&lt;li&gt;Move a vendor onto its own subdomain (see the subdomain section below) so it gets its own SPF record and its own budget of 10.&lt;/li&gt;
&lt;li&gt;Replace an include with the &lt;code&gt;ip4:&lt;/code&gt; ranges it expands to — only for vendors with stable IPs, and accept that you now own keeping it current. Automated "flattening" services do this for you at the cost of a dependency that breaks when a vendor renumbers quietly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check the count before you ship a change. &lt;code&gt;dig +short TXT example.com&lt;/code&gt; shows the record; a validator shows the expansion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why SPF alone is not enough
&lt;/h3&gt;

&lt;p&gt;SPF authenticates the connecting IP against the &lt;em&gt;envelope&lt;/em&gt; domain. Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It says nothing about what the user sees.&lt;/strong&gt; An attacker publishes perfect SPF for &lt;code&gt;evil.example&lt;/code&gt;, uses it as the envelope sender, and still puts &lt;code&gt;From: you@yourbank.com&lt;/code&gt; in the headers. SPF passes. That is exactly why DMARC's alignment check exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It breaks on forwarding.&lt;/strong&gt; A user forwards your mail from a university address to Gmail. That server connects from an IP your record does not list while keeping the original envelope sender, so SPF fails — correctly and uselessly. Some forwarders rewrite the envelope (SRS); many do not. This is the practical reason DKIM matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  DKIM: a signature that travels with the message
&lt;/h2&gt;

&lt;p&gt;DKIM is asymmetric crypto. You hold a private key; the public key lives in DNS. Your outbound server hashes a canonicalised set of headers plus the body, signs it, and adds a &lt;code&gt;DKIM-Signature&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;DKIM-Signature&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=s1;
    h=from:to:subject:date:message-id:mime-version:content-type;
    bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
    b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZVoG4ZHRNiYzR&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;d=&lt;/code&gt; — the signing domain. This is what DMARC will compare against &lt;code&gt;From:&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s=&lt;/code&gt; — the &lt;strong&gt;selector&lt;/strong&gt;, which locates the public key. Multiple selectors per domain is normal and is how rotation works.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;h=&lt;/code&gt; — the list of headers covered by the signature. &lt;code&gt;from&lt;/code&gt; is mandatory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bh=&lt;/code&gt; — hash of the body; &lt;code&gt;b=&lt;/code&gt; — the signature itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public key is a TXT record at &lt;code&gt;&amp;lt;selector&amp;gt;._domainkey.&amp;lt;domain&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s1._domainkey.example.com.  IN  TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv9tZ0hR2m..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Practical notes that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS TXT strings max out at 255 characters.&lt;/strong&gt; A 2048-bit key exceeds that and must be split into multiple quoted strings the resolver concatenates. Most providers handle it; some paste it verbatim and silently produce an invalid key. If DKIM "just doesn't verify," check this first with &lt;code&gt;dig +short TXT s1._domainkey.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1024 vs 2048.&lt;/strong&gt; &lt;a href="https://www.rfc-editor.org/rfc/rfc8301" rel="noopener noreferrer"&gt;RFC 8301&lt;/a&gt; sets 1024 as the floor and 2048 as what signers should use. Use 2048; 1024 is a downgrade with no upside. Ed25519 (&lt;code&gt;k=ed25519&lt;/code&gt;) produces tiny records but verifier support is still uneven — dual-sign if you want it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation&lt;/strong&gt; is the whole reason selectors exist. Publish &lt;code&gt;s2._domainkey&lt;/code&gt; with the new key, wait for propagation, switch signing to &lt;code&gt;s2&lt;/code&gt;, wait past the longest TTL plus anything in retry queues (I give it a week), then delete &lt;code&gt;s1&lt;/code&gt;. There is never a window where valid mail cannot be verified. Rotate annually, or immediately if a key may have leaked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why DKIM survives forwarding
&lt;/h3&gt;

&lt;p&gt;The signature covers headers and body, not the connection. A forwarder that relays the bytes unchanged relays a still-valid signature. That is why DKIM is the load-bearing mechanism in practice and SPF is the one that keeps failing on mailing lists.&lt;/p&gt;

&lt;p&gt;It is not bulletproof. Mailing lists that append a footer or prefix &lt;code&gt;[list]&lt;/code&gt; to the subject invalidate the body hash or the signed subject. That is what ARC (&lt;a href="https://www.rfc-editor.org/rfc/rfc8617" rel="noopener noreferrer"&gt;RFC 8617&lt;/a&gt;) papers over: intermediaries record the authentication result they saw so the final receiver can choose to trust it. You do not implement ARC as a sender — you just need to know it is why some list traffic still gets through.&lt;/p&gt;

&lt;h2&gt;
  
  
  DMARC: alignment, policy, and reports
&lt;/h2&gt;

&lt;p&gt;DMARC connects the previous two to what the user actually sees. It defines &lt;strong&gt;alignment&lt;/strong&gt;, publishes a &lt;strong&gt;policy&lt;/strong&gt; for failures, and gets you &lt;strong&gt;reports&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The spec is now on the IETF Standards Track: &lt;a href="https://dmarc.org/2026/05/ietf-publishes-updated-dmarc-specification/" rel="noopener noreferrer"&gt;RFC 9989, 9990 and 9991 were published on 20 May 2026&lt;/a&gt;, obsoleting the 2015 informational RFC 7489. I flag below where that changed the tags you write.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alignment is the entire point
&lt;/h3&gt;

&lt;p&gt;A message passes DMARC if &lt;strong&gt;at least one&lt;/strong&gt; of the following holds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SPF passes &lt;strong&gt;and&lt;/strong&gt; the envelope &lt;code&gt;MAIL FROM&lt;/code&gt; domain aligns with the &lt;code&gt;From:&lt;/code&gt; domain, or&lt;/li&gt;
&lt;li&gt;DKIM passes &lt;strong&gt;and&lt;/strong&gt; the signature's &lt;code&gt;d=&lt;/code&gt; aligns with the &lt;code&gt;From:&lt;/code&gt; domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Aligns" has two modes, set per-mechanism:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Relaxed&lt;/strong&gt; (&lt;code&gt;adkim=r&lt;/code&gt;, &lt;code&gt;aspf=r&lt;/code&gt; — the default): the &lt;em&gt;organizational&lt;/em&gt; domains must match. &lt;code&gt;mail.example.com&lt;/code&gt; aligns with &lt;code&gt;example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strict&lt;/strong&gt; (&lt;code&gt;adkim=s&lt;/code&gt;, &lt;code&gt;aspf=s&lt;/code&gt;): the FQDNs must match exactly. &lt;code&gt;mail.example.com&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; align with &lt;code&gt;example.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the failure that catches almost everyone on a first integration. You send through an ESP: your &lt;code&gt;From:&lt;/code&gt; is &lt;code&gt;billing@example.com&lt;/code&gt;, but the envelope sender is the ESP's own bounce domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;MAIL FROM&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt;&amp;lt;0100018e-bounces@eu-west-1.amazonses.com&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;From&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; billing@example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SPF &lt;strong&gt;passes&lt;/strong&gt; — for &lt;code&gt;amazonses.com&lt;/code&gt;. It does not align with &lt;code&gt;example.com&lt;/code&gt;, so it contributes nothing to DMARC. If DKIM also signs with &lt;code&gt;d=amazonses.com&lt;/code&gt;, DMARC fails outright and you never see an SMTP error, because &lt;code&gt;p=none&lt;/code&gt; means "deliver anyway and tell me." Fix it with a custom MAIL FROM subdomain (&lt;code&gt;bounce.example.com&lt;/code&gt;, delegated to the ESP) or — easier and sufficient — DKIM signing with &lt;code&gt;d=example.com&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The record
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;_dmarc.example.com.  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@example.com; adkim=r; aspf=r; fo=1"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;p=&lt;/code&gt; — policy for the organizational domain: &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;quarantine&lt;/code&gt;, &lt;code&gt;reject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sp=&lt;/code&gt; — policy for subdomains. If absent, &lt;code&gt;p=&lt;/code&gt; applies to them too.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;np=&lt;/code&gt; — &lt;strong&gt;new in RFC 9989&lt;/strong&gt;: policy for &lt;em&gt;non-existent&lt;/em&gt; subdomains. Set &lt;code&gt;np=reject&lt;/code&gt; early; nothing legitimate sends from a subdomain with no DNS records, so it is free protection against &lt;code&gt;invoices.example.com&lt;/code&gt;-style spoofing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rua=&lt;/code&gt; — where aggregate reports go. &lt;code&gt;fo=1&lt;/code&gt; — request a failure report when &lt;em&gt;any&lt;/em&gt; mechanism fails, not only when all do.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pct=&lt;/code&gt; — &lt;strong&gt;deprecated in RFC 9989&lt;/strong&gt;, replaced by &lt;code&gt;t=y&lt;/code&gt; (testing) / &lt;code&gt;t=n&lt;/code&gt; (default, enforce). Deployed receivers still honour &lt;code&gt;pct=&lt;/code&gt;, so it works during a transition, but write new records with &lt;code&gt;t=&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rolling out without breaking payroll
&lt;/h3&gt;

&lt;p&gt;The order matters, and the whole point of starting at &lt;code&gt;none&lt;/code&gt; is that it is observation-only.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;p=none&lt;/code&gt;.&lt;/strong&gt; Nothing changes for recipients; you just start receiving reports. Leave it two to four weeks — long enough to catch the monthly invoice run and whatever marketing does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix what the reports show.&lt;/strong&gt; Every legitimate source must reach alignment on SPF or DKIM. This is where you discover the CRM nobody told you about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;p=quarantine&lt;/code&gt;,&lt;/strong&gt; initially sampled (&lt;code&gt;t=y&lt;/code&gt;, or the older &lt;code&gt;pct=25&lt;/code&gt;), then full.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;p=reject&lt;/code&gt;,&lt;/strong&gt; once reports show near-100% alignment for a couple of weeks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not jump straight to &lt;code&gt;reject&lt;/code&gt;. I have watched a company do that and take down their own recruiting pipeline for two days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Actually reading an aggregate report
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;rua&lt;/code&gt; reports arrive daily as gzipped XML, one file per reporting receiver. The useful part is the per-source-IP rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;record&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;row&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;source_ip&amp;gt;&lt;/span&gt;203.0.113.7&lt;span class="nt"&gt;&amp;lt;/source_ip&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;count&amp;gt;&lt;/span&gt;412&lt;span class="nt"&gt;&amp;lt;/count&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;policy_evaluated&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;disposition&amp;gt;&lt;/span&gt;none&lt;span class="nt"&gt;&amp;lt;/disposition&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;dkim&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/dkim&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;spf&amp;gt;&lt;/span&gt;fail&lt;span class="nt"&gt;&amp;lt;/spf&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/policy_evaluated&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/row&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;identifiers&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;header_from&amp;gt;&lt;/span&gt;example.com&lt;span class="nt"&gt;&amp;lt;/header_from&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/identifiers&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;auth_results&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dkim&amp;gt;&amp;lt;domain&amp;gt;&lt;/span&gt;example.com&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&amp;lt;result&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/result&amp;gt;&amp;lt;/dkim&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;spf&amp;gt;&amp;lt;domain&amp;gt;&lt;/span&gt;bounces.esp.example&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&amp;lt;result&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/result&amp;gt;&amp;lt;/spf&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/auth_results&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/record&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;policy_evaluated&lt;/code&gt;&lt;/strong&gt; is the &lt;em&gt;aligned&lt;/em&gt; result; &lt;strong&gt;&lt;code&gt;auth_results&lt;/code&gt;&lt;/strong&gt; is the raw one. When &lt;code&gt;auth_results/spf&lt;/code&gt; says &lt;code&gt;pass&lt;/code&gt; but &lt;code&gt;policy_evaluated/spf&lt;/code&gt; says &lt;code&gt;fail&lt;/code&gt;, that is an alignment problem, not an SPF problem — exactly the ESP case above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;count&lt;/code&gt;&lt;/strong&gt; is messages, not recipients. Sort descending; two or three IPs will be 95% of your volume.&lt;/li&gt;
&lt;li&gt;Rows with &lt;code&gt;dkim=fail&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;spf=fail&lt;/code&gt; and a meaningful count are either a source you forgot or someone spoofing you. Look up the IP before assuming which.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not parse these by hand past week one — point &lt;code&gt;rua&lt;/code&gt; at an aggregator and read a dashboard. Understand the XML once so you know what the dashboard is summarising.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bulk-sender rules you now have to meet
&lt;/h2&gt;

&lt;p&gt;Since 2024 the large mailbox providers have made this mandatory rather than advisory, and enforcement has hardened considerably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google and Yahoo&lt;/strong&gt; apply their requirements to senders of &lt;strong&gt;5,000+ messages per day to their users&lt;/strong&gt;. Per &lt;a href="https://support.google.com/a/answer/14229414?hl=en" rel="noopener noreferrer"&gt;Google's sender guidelines FAQ&lt;/a&gt;, messages from the same primary domain — subdomains included — count toward that 5,000, and once you are classified as a bulk sender the classification does not expire. Requirements: SPF and DKIM both set up, a DMARC record at minimum &lt;code&gt;p=none&lt;/code&gt;, and the &lt;code&gt;From:&lt;/code&gt; organizational domain aligned with either the SPF or the DKIM organizational domain. Spam complaint rate should stay &lt;strong&gt;below 0.1%&lt;/strong&gt;, and at &lt;strong&gt;0.3% or above&lt;/strong&gt; you become ineligible for mitigation until you have been back under 0.3% for seven consecutive days. Since &lt;strong&gt;November 2025 Gmail has escalated from temporary deferrals to permanent rejections&lt;/strong&gt; for non-compliant traffic (&lt;a href="https://www.proofpoint.com/us/blog/email-and-cloud-threats/clock-ticking-stricter-email-authentication-enforcements-google-start" rel="noopener noreferrer"&gt;Proofpoint's write-up&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microsoft&lt;/strong&gt; joined on &lt;strong&gt;5 May 2025&lt;/strong&gt; with the same 5,000/day threshold for consumer Outlook/Hotmail/Live addresses, requiring SPF, DKIM and DMARC with alignment on at least one (&lt;a href="https://dmarcian.com/microsoft-enforces-spf-dkim-dmarc/" rel="noopener noreferrer"&gt;dmarcian's summary&lt;/a&gt;). Non-compliant mail is rejected with &lt;code&gt;550 5.7.515 Access denied, sending domain [domain] does not meet the required authentication level&lt;/code&gt; (&lt;a href="https://www.uriports.com/blog/outlook-error-550-5-7-515-and-how-to-fix-it/" rel="noopener noreferrer"&gt;uriports&lt;/a&gt;) — a hard bounce, not a spam-folder problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-click unsubscribe&lt;/strong&gt; (&lt;a href="https://www.rfc-editor.org/rfc/rfc8058" rel="noopener noreferrer"&gt;RFC 8058&lt;/a&gt;) applies to marketing and promotional mail, not to transactional mail like password resets or receipts. Both headers are required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;List-Unsubscribe&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; &amp;lt;https://example.com/u/9f3a2c&amp;gt;, &amp;lt;mailto:unsub@example.com?subject=unsub&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;List-Unsubscribe-Post&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; List-Unsubscribe=One-Click&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL must accept an unauthenticated &lt;code&gt;POST&lt;/code&gt; with body &lt;code&gt;List-Unsubscribe=One-Click&lt;/code&gt; and unsubscribe on that single request — no confirmation page, no login. Requests must be honoured within &lt;strong&gt;two days&lt;/strong&gt;. A &lt;code&gt;mailto:&lt;/code&gt;-only or plain-link unsubscribe does not satisfy it.&lt;/p&gt;

&lt;p&gt;Also expected across all three: TLS on delivery, valid forward-confirmed reverse DNS on sending IPs, and RFC 5322-conformant messages.&lt;/p&gt;

&lt;p&gt;These are floors, not targets — meeting them gets you considered, not delivered. And do not wait until 5,000/day: the threshold is where enforcement begins, but the reputation you build below it is what you arrive with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split transactional and marketing onto different subdomains
&lt;/h2&gt;

&lt;p&gt;Reputation is tracked &lt;strong&gt;per domain&lt;/strong&gt; (and per IP), and it is dominated by complaint rate. Marketing email gets complaints structurally — people who wanted a discount code six months ago now hit "spam" instead of unsubscribe. Password resets get essentially none. Send both from &lt;code&gt;example.com&lt;/code&gt; and the newsletter's complaints degrade the login flow. That is how you end up with users unable to receive a verification code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mail.example.com   -&amp;gt; transactional: receipts, resets, alerts
news.example.com   -&amp;gt; marketing: newsletters, campaigns
example.com        -&amp;gt; corporate mail (Google Workspace / Microsoft 365)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each gets its own SPF record — and therefore its own 10-lookup budget — and its own DKIM keys. DMARC still works: with default relaxed alignment, &lt;code&gt;From: noreply@mail.example.com&lt;/code&gt; aligns against the policy at &lt;code&gt;_dmarc.example.com&lt;/code&gt;, and you can override per-subdomain with a dedicated &lt;code&gt;_dmarc.mail.example.com&lt;/code&gt; record. Publish &lt;code&gt;sp=&lt;/code&gt; and &lt;code&gt;np=&lt;/code&gt; on the parent to cover subdomains you have not delegated.&lt;/p&gt;

&lt;p&gt;The trade-off: a fresh subdomain has no reputation and needs warming — low volume, ramped over two to four weeks. Do this &lt;em&gt;before&lt;/em&gt; you need it, not during a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes, with symptoms
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mail delivers, but DMARC reports show &lt;code&gt;spf=fail&lt;/code&gt; while raw SPF passes.&lt;/strong&gt;&lt;br&gt;
Cause: the envelope sender is your ESP's bounce domain, so SPF authenticates the wrong domain. Fix: DKIM-sign with &lt;code&gt;d=yourdomain.com&lt;/code&gt;, or set a custom MAIL FROM subdomain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliverability degraded gradually, nothing obviously changed.&lt;/strong&gt;&lt;br&gt;
Cause: SPF exceeded 10 lookups after someone added a vendor include, yielding &lt;code&gt;permerror&lt;/code&gt;. Fix: count the expansion, prune dead includes, move vendors to subdomains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DKIM never verifies for a newly added selector.&lt;/strong&gt;&lt;br&gt;
Cause: the 2048-bit key was pasted as a single TXT string over 255 characters, the provider added surrounding quotes, or the &lt;code&gt;p=&lt;/code&gt; value carries line breaks. Fix: &lt;code&gt;dig +short TXT s1._domainkey.example.com&lt;/code&gt;, reassemble, compare byte-for-byte with the signer's key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct mail is fine, mailing-list traffic fails DMARC.&lt;/strong&gt;&lt;br&gt;
Cause: the list modified the subject or body, breaking the DKIM signature; SPF was already broken by the relay. Fix: nothing on your side. Expected, and why ARC exists. Do not weaken your policy over it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;550 5.7.515&lt;/code&gt; from Outlook.&lt;/strong&gt;&lt;br&gt;
Cause: you crossed 5,000/day to consumer Microsoft addresses without full SPF + DKIM + DMARC alignment. Fix: publish DMARC (even &lt;code&gt;p=none&lt;/code&gt;) and confirm at least one aligned mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DMARC passes but mail still goes to spam.&lt;/strong&gt;&lt;br&gt;
Cause: authentication is identity, not reputation — complaint rate, list hygiene and sending history decide placement. Fix: suppress hard bounces and complaints, honour unsubscribes fast, stop mailing people who have not opened anything in a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The afternoon checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Inventory every system that sends as your domain. Billing, CRM, monitoring, the app, HR. This step is longer than you expect.&lt;/li&gt;
&lt;li&gt;Publish one SPF record per sending domain, ending in &lt;code&gt;~all&lt;/code&gt;. Verify the expansion is under 10 lookups.&lt;/li&gt;
&lt;li&gt;Enable DKIM on every source. 2048-bit, unique selector per source. Verify each with &lt;code&gt;dig&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Publish &lt;code&gt;_dmarc&lt;/code&gt; with &lt;code&gt;p=none; rua=mailto:...; fo=1; np=reject&lt;/code&gt;. Point &lt;code&gt;rua&lt;/code&gt; at an aggregator.&lt;/li&gt;
&lt;li&gt;Wait two to four weeks. Read reports. Fix alignment until every legitimate source passes.&lt;/li&gt;
&lt;li&gt;Move SPF to &lt;code&gt;-all&lt;/code&gt;. Move DMARC to &lt;code&gt;p=quarantine&lt;/code&gt;, then &lt;code&gt;p=reject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;List-Unsubscribe&lt;/code&gt; + &lt;code&gt;List-Unsubscribe-Post&lt;/code&gt; to marketing mail and make the POST endpoint work unauthenticated.&lt;/li&gt;
&lt;li&gt;Split transactional and marketing subdomains; warm the new one.&lt;/li&gt;
&lt;li&gt;Set a calendar reminder to rotate DKIM keys in 12 months. You will not remember otherwise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–4 genuinely fit in an afternoon. Step 5 is the one people skip, and it is the only one that tells you whether the rest worked.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure, since it is relevant: I build &lt;a href="https://pulsenote.eu" rel="noopener noreferrer"&gt;Pulsenote&lt;/a&gt;, a transactional email API that handles DKIM signing, bounce/complaint suppression and delivery webhooks so the above is mostly configuration rather than code — free tier is 100 emails/month, no card. Everything in this article applies identically whether you use it, a competitor, or your own Postfix box; the DNS is yours either way.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>email</category>
      <category>node</category>
    </item>
  </channel>
</rss>
