<?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: Email Campaign LLC</title>
    <description>The latest articles on DEV Community by Email Campaign LLC (@coldemailmarketing).</description>
    <link>https://dev.to/coldemailmarketing</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%2F4133039%2F231824e9-30de-4b3d-acfe-58cf1539b15c.png</url>
      <title>DEV Community: Email Campaign LLC</title>
      <link>https://dev.to/coldemailmarketing</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/coldemailmarketing"/>
    <language>en</language>
    <item>
      <title>Email deliverability in CI: what you can actually automate, and what you can't</title>
      <dc:creator>Email Campaign LLC</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:20:30 +0000</pubDate>
      <link>https://dev.to/emailcampaignai/email-deliverability-in-ci-what-you-can-actually-automate-and-what-you-cant-20i9</link>
      <guid>https://dev.to/emailcampaignai/email-deliverability-in-ci-what-you-can-actually-automate-and-what-you-cant-20i9</guid>
      <description>&lt;p&gt;"Did that email arrive?" is two questions wearing one coat. One of them automates cleanly. The other does not automate at all, and most of the frustration around deliverability testing comes from not separating them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Did we send a well-formed, authenticated message?&lt;/strong&gt; Deterministic. Test it in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did a receiver put it in the inbox?&lt;/strong&gt; A judgement made by someone else's classifier, using your history. Not a test. A measurement, and a lagging one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Automating the first is worth doing and is mostly ignored. Trying to automate the second produces a suite that fails for reasons nobody can action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that belongs in CI
&lt;/h2&gt;

&lt;h3&gt;
  
  
  DNS invariants
&lt;/h3&gt;

&lt;p&gt;These are pure functions of your DNS, so they can assert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_exactly_one_spf_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;txt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TXT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;spf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;txt&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v=spf1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; publishes &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; SPF records; must be 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_spf_lookup_budget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_lookups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# walks includes recursively
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; uses &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; of 10 DNS lookups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is the test that earns its keep. The lookup limit counts the &lt;strong&gt;whole include tree&lt;/strong&gt;, so a record that passes today breaks when a vendor adds an include inside &lt;em&gt;their&lt;/em&gt; record — a change you will never be notified about. That is exactly the class of failure a scheduled job should catch and a human never will.&lt;/p&gt;

&lt;p&gt;Run these nightly against production DNS, not just on deploy. The failure arrives on a day you did not ship anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Message structure
&lt;/h3&gt;

&lt;p&gt;Assert on your own output before it goes anywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;List-Unsubscribe&lt;/code&gt; header, and &lt;code&gt;List-Unsubscribe-Post&lt;/code&gt; for one-click&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Message-ID&lt;/code&gt; with your sending domain in it&lt;/li&gt;
&lt;li&gt;A plain-text alternative that is not empty and not just stripped HTML&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;From:&lt;/code&gt; aligning with the domain you intend to sign with&lt;/li&gt;
&lt;li&gt;No unsubscribe link pointing at &lt;code&gt;localhost&lt;/code&gt; — this ships more often than anyone admits&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Authentication, end to end
&lt;/h3&gt;

&lt;p&gt;The strongest automated check sends one real message to a mailbox you control, then reads the &lt;code&gt;Authentication-Results&lt;/code&gt; header the receiver stamped on it:&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;Authentication-Results&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; mx.google.com;
  spf=pass smtp.mailfrom=yourdomain.com;
  dkim=pass header.i=@yourdomain.com;
  dmarc=pass header.from=yourdomain.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assert all three pass — and assert on the &lt;code&gt;d=&lt;/code&gt; / &lt;code&gt;header.i=&lt;/code&gt; value, not just the verdict. &lt;code&gt;dkim=pass&lt;/code&gt; with your ESP's domain there is a pass that aligns with nothing, and DMARC fails anyway. That assertion catches a misconfiguration that every "is DKIM working?" check reports as fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that does not belong in CI
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Inbox placement.&lt;/strong&gt; Whether Gmail files you under Primary, Promotions or Spam depends on your domain's history, the recipient's own behaviour, and a classifier that changes without notice. Seed-list tools sample it, and the sample is drawn from mailboxes with no real engagement history — which is precisely the input the classifier cares most about. Useful as a trend. Not a pass/fail gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reputation.&lt;/strong&gt; A lagging aggregate. There is no assertion to write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spam-word scoring.&lt;/strong&gt; Content is real but it is the smallest factor, and the scores are not the receiver's scores. A well-written message from an unauthenticated domain still lands in spam, which is why rewriting subject lines is the first thing people try and the last thing that helps.&lt;/p&gt;

&lt;p&gt;Gate your build on none of these. Put them on a dashboard with a date on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dev and staging, without becoming a spammer
&lt;/h2&gt;

&lt;p&gt;The cheapest mistake in this whole area is a staging environment that can reach the open internet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Point non-production at a catching SMTP server — MailHog, Mailpit, or your provider's sandbox. Make it the &lt;strong&gt;default&lt;/strong&gt;, so reaching real recipients requires an explicit override rather than the reverse.&lt;/li&gt;
&lt;li&gt;If production credentials exist anywhere in a non-production environment, one seeded fixture with real addresses is all it takes. Do not rely on the seed data being fake.&lt;/li&gt;
&lt;li&gt;Never warm a domain with traffic generated by tests. Warm-up is a claim about human sending behaviour; synthetic volume against seed mailboxes teaches the receiver something, and it is not what you want it to learn.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A shape that works
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Frequency&lt;/th&gt;
&lt;th&gt;Gates the build?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS invariants — one SPF record, ≤10 lookups, DKIM resolves, DMARC present&lt;/td&gt;
&lt;td&gt;Nightly + deploy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message structure assertions&lt;/td&gt;
&lt;td&gt;Every commit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live send → assert &lt;code&gt;Authentication-Results&lt;/code&gt; incl. &lt;code&gt;d=&lt;/code&gt; alignment&lt;/td&gt;
&lt;td&gt;Nightly&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bounce-code classification tests (fixtures)&lt;/td&gt;
&lt;td&gt;Every commit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbox placement sample&lt;/td&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;td&gt;No — trend only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complaint rate vs the 0.1% threshold&lt;/td&gt;
&lt;td&gt;Continuous&lt;/td&gt;
&lt;td&gt;Alert, not a gate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything in the top four is a property of your own system and fails for reasons you can fix. Everything below is somebody else's verdict on your history, and belongs on a graph rather than in a red build.&lt;/p&gt;

&lt;p&gt;If you want the DNS half checked right now without wiring anything up, &lt;a href="https://emailcampaign.ai/tools/dns-checker" rel="noopener noreferrer"&gt;this runs the same lookups against live DNS&lt;/a&gt;, and &lt;a href="https://github.com/coldemailmarketing/spf-audit" rel="noopener noreferrer"&gt;spf-audit&lt;/a&gt; is the CI-shaped version — it walks the full include tree and exits non-zero over the limit.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>email</category>
      <category>cicd</category>
    </item>
    <item>
      <title>One domain per job: why password resets should not share a reputation with outreach</title>
      <dc:creator>Email Campaign LLC</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:17:36 +0000</pubDate>
      <link>https://dev.to/emailcampaignai/one-domain-per-job-why-password-resets-should-not-share-a-reputation-with-outreach-12e8</link>
      <guid>https://dev.to/emailcampaignai/one-domain-per-job-why-password-resets-should-not-share-a-reputation-with-outreach-12e8</guid>
      <description>&lt;p&gt;Most teams send every kind of mail from one domain, because that is the obvious thing to do and nothing appears to go wrong. Then a marketing send does badly, and two weeks later password reset emails start landing in spam — and nobody connects the two, because those systems share no code, no queue, and no vendor.&lt;/p&gt;

&lt;p&gt;They share the only thing that mattered: the domain in the &lt;code&gt;From:&lt;/code&gt; header.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reputation is attached to
&lt;/h2&gt;

&lt;p&gt;Receivers score several identities independently: the sending IP, the &lt;code&gt;From:&lt;/code&gt; domain, the DKIM signing domain (&lt;code&gt;d=&lt;/code&gt;), and the envelope sender's domain. The one you control most cheaply, and the one that follows you across every provider, is the &lt;strong&gt;domain&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That domain accumulates one history. Your password resets — near-100% open rate, effectively zero complaints, universally wanted — contribute to it. So does the cold campaign that went to a list somebody bought in 2023. The receiver does not know which system emitted which message. It sees one domain with one blended history.&lt;/p&gt;

&lt;p&gt;This is why the failure is always asymmetric. Transactional mail is the best-behaved traffic you have, and it is the thing that gets hurt, because it is the thing that could least afford the hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;Separate by &lt;em&gt;risk profile&lt;/em&gt;, not by team:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Traffic&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yourcompany.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing. Corporate mail only.&lt;/td&gt;
&lt;td&gt;Protect at all costs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mail.yourcompany.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Transactional: resets, receipts, alerts&lt;/td&gt;
&lt;td&gt;Must never fail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;news.yourcompany.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marketing to opted-in lists&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;a-separate-domain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cold outreach&lt;/td&gt;
&lt;td&gt;Expendable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three are subdomains, and that is deliberate — subdomains inherit some of the organizational domain's standing, which is an advantage when the parent is healthy.&lt;/p&gt;

&lt;p&gt;Cold outreach is the exception. It gets a &lt;strong&gt;separate registered domain&lt;/strong&gt;, not a subdomain, precisely because you do not want inheritance running in the other direction. Cold outreach is the highest-variance traffic you will ever send, and the whole point is that when a domain burns, it burns alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tag that decides whether this works
&lt;/h2&gt;

&lt;p&gt;Splitting into subdomains does almost nothing unless you publish &lt;code&gt;sp=&lt;/code&gt; on the organizational domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=DMARC1; p=quarantine; sp=quarantine; rua=mailto:dmarc@yourcompany.com; pct=100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;p=&lt;/code&gt; applies to the organizational domain. &lt;code&gt;sp=&lt;/code&gt; applies to subdomains, and &lt;strong&gt;when it is absent, subdomains inherit &lt;code&gt;p=&lt;/code&gt;&lt;/strong&gt;. Two consequences people discover the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you enforce &lt;code&gt;p=reject&lt;/code&gt; without thinking about subdomains, every subdomain inherits reject — including ones you forgot you had, and including any that are not fully authenticated yet.&lt;/li&gt;
&lt;li&gt;Conversely, &lt;code&gt;sp=none&lt;/code&gt; is a deliberate escape hatch. It lets you enforce the brand domain while leaving one lagging subdomain at monitoring, so a single unfinished migration does not hold up enforcement everywhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are useful. Neither happens by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each domain authenticates on its own
&lt;/h2&gt;

&lt;p&gt;Subdomains do not inherit SPF or DKIM. &lt;code&gt;mail.yourcompany.com&lt;/code&gt; needs its own &lt;code&gt;v=spf1&lt;/code&gt; TXT record and its own DKIM keys — inheritance applies to DMARC &lt;em&gt;policy&lt;/em&gt;, not to authentication records.&lt;/p&gt;

&lt;p&gt;Some common ways this goes wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A second SPF record.&lt;/strong&gt; One domain may publish exactly one &lt;code&gt;v=spf1&lt;/code&gt; record. Two are a permanent error, not a merge, and both look valid inspected separately. This happens most often when a website host and a mail provider each add one, months apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ten-lookup ceiling.&lt;/strong&gt; Every &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; costs a DNS lookup, &lt;em&gt;including the ones nested inside your includes&lt;/em&gt;. &lt;code&gt;include:_spf.google.com&lt;/code&gt; alone is four. Four vendors will break a record that still looks short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM signing with the wrong domain.&lt;/strong&gt; If &lt;code&gt;d=&lt;/code&gt; is your ESP's domain rather than yours, the signature verifies and DMARC still fails, because nothing aligned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth checking the whole tree rather than the first record: &lt;a href="https://emailcampaign.ai/tools/dns-checker" rel="noopener noreferrer"&gt;SPF, DKIM and DMARC checker&lt;/a&gt;, or &lt;a href="https://github.com/coldemailmarketing/spf-audit" rel="noopener noreferrer"&gt;spf-audit&lt;/a&gt; if you would rather run it in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  New domains start with no history, which is not the same as good history
&lt;/h2&gt;

&lt;p&gt;A freshly registered outreach domain has no reputation at all, and receivers treat absence of history with suspicion rather than neutrality. It needs two to three weeks of gradually increasing volume before it carries real traffic.&lt;/p&gt;

&lt;p&gt;The part that catches people: warm-up traffic and real traffic come out of the same daily budget. A mailbox warming at twenty a day that also sends thirty real messages is sending fifty from a mailbox with no history, and it is not warming — it is being pushed past what its history supports while a dashboard reports progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating without a gap
&lt;/h2&gt;

&lt;p&gt;If you are on one domain today, do not cut over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stand up the new subdomain with its own SPF, DKIM and DMARC, and confirm it aligns by reading the &lt;code&gt;Authentication-Results&lt;/code&gt; header on a real test message.&lt;/li&gt;
&lt;li&gt;Move &lt;strong&gt;transactional first&lt;/strong&gt; — it is your most valuable and best-behaved traffic, so it establishes the new subdomain's history quickly and honestly.&lt;/li&gt;
&lt;li&gt;Move marketing next, and watch complaint rate as a leading indicator.&lt;/li&gt;
&lt;li&gt;Move cold outreach to its own registered domain last, after warm-up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keeping the old domain sending throughout is fine. What you are building is history on the new names, and that takes weeks whatever order you do it in.&lt;/p&gt;

&lt;p&gt;The reason to start now rather than when something breaks: reputation recovers slowly, and separation only helps traffic you moved &lt;em&gt;before&lt;/em&gt; the damage.&lt;/p&gt;

</description>
      <category>email</category>
      <category>architecture</category>
      <category>devops</category>
      <category>dns</category>
    </item>
    <item>
      <title>Your bounce handler is probably suppressing the wrong address</title>
      <dc:creator>Email Campaign LLC</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:14:55 +0000</pubDate>
      <link>https://dev.to/emailcampaignai/your-bounce-handler-is-probably-suppressing-the-wrong-address-occ</link>
      <guid>https://dev.to/emailcampaignai/your-bounce-handler-is-probably-suppressing-the-wrong-address-occ</guid>
      <description>&lt;p&gt;Here is a bug I have now seen in four separate codebases, including one I wrote.&lt;/p&gt;

&lt;p&gt;Mail goes out. A bounce comes back. The handler parses the status code, sees a &lt;code&gt;5.x.x&lt;/code&gt;, concludes the address is bad, and adds the recipient to the suppression list. Clean, obvious, and wrong for an entire class of bounce.&lt;/p&gt;

&lt;p&gt;Because not every &lt;code&gt;5.x.x&lt;/code&gt; is about the recipient. Some of them are about &lt;strong&gt;you&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two families, one code space
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;550 5.1.1 The email account that you tried to reach does not exist
550 5.7.1 Our system has detected that this message is likely unsolicited
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are permanent. Both are &lt;code&gt;550&lt;/code&gt;. Suppressing on the first is correct — that mailbox is not there and will not be there tomorrow.&lt;/p&gt;

&lt;p&gt;Suppressing on the second is a mistake that compounds. &lt;code&gt;5.7.x&lt;/code&gt; is a &lt;em&gt;policy&lt;/em&gt; rejection: the receiver evaluated your IP, your domain, your authentication or your reputation, and declined. The recipient is fine. Their mailbox exists. They might be your best customer.&lt;/p&gt;

&lt;p&gt;What you have actually learned is that &lt;strong&gt;you&lt;/strong&gt; cannot currently deliver to that provider. Recording it against the recipient throws away the real signal and permanently burns an address you never had a problem with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sub-code is the part that matters
&lt;/h2&gt;

&lt;p&gt;The three-digit code tells you almost nothing. The enhanced status code after it — RFC 3463 — tells you who the problem belongs to.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Suppress the recipient?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5.1.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Addressing. No such user, bad syntax.&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5.2.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mailbox disabled&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5.2.2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mailbox full&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt; — this is temporary in practice, retry later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5.4.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Routing / network&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5.7.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Policy, authentication, reputation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No — this is about you&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;5.2.2&lt;/code&gt; deserves a note of its own. It is formatted as permanent and behaves as temporary, because mailboxes get emptied. Treating a full mailbox as a dead address is a slow, invisible leak from your list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with a 5.7.x instead
&lt;/h2&gt;

&lt;p&gt;The useful unit is not the recipient, it is the &lt;strong&gt;(sending identity, receiving provider)&lt;/strong&gt; pair.&lt;/p&gt;

&lt;p&gt;Keep a separate ledger of policy refusals recording the sending domain or IP, the receiving provider, the code, and the raw text. Then act on aggregates rather than individual events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rising &lt;code&gt;5.7.x&lt;/code&gt; from one provider against one sending domain means stop sending to that provider from that domain and fix the cause.&lt;/li&gt;
&lt;li&gt;The same code across every provider means the problem is the domain or the IP, not any single relationship.&lt;/li&gt;
&lt;li&gt;A provider-specific code — Google's &lt;code&gt;5.7.26&lt;/code&gt;, for instance, which means unauthenticated — tells you precisely what is broken. &lt;code&gt;5.7.26&lt;/code&gt; is an authentication failure, not a reputation verdict, and it is fixable in DNS this afternoon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical shape is an automatic hold: when policy refusals from one provider cross a threshold, pause sending to that provider from that sender, and keep the recipients untouched. Delivery resumes when the cause is fixed. Nothing permanent was recorded about anyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the enhanced code properly
&lt;/h2&gt;

&lt;p&gt;The enhanced status code is not always where you expect it, and plenty of servers put a different code in the text than in the status field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;ENHANCED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b([245])\.(\d{1,3})\.(\d{1,3})\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;smtp_response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ENHANCED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;smtp_response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# fall back to the basic code; 5xx without an enhanced code
&lt;/span&gt;        &lt;span class="c1"&gt;# is genuinely ambiguous — do not guess
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cls&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bad_recipient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;# safe to suppress
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;            &lt;span class="c1"&gt;# about the SENDER — never suppress
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things that save you later: search the whole response, not just the first line, because multi-line replies routinely carry the enhanced code on a continuation line. And when there is no enhanced code at all, record &lt;code&gt;unknown&lt;/code&gt; rather than defaulting to "bad recipient" — a bare &lt;code&gt;550&lt;/code&gt; from an unfamiliar server is not enough to destroy an address over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this stays invisible
&lt;/h2&gt;

&lt;p&gt;Nothing about this bug looks like a bug. Your bounce rate goes &lt;em&gt;down&lt;/em&gt; after you start suppressing policy refusals, because you stop retrying addresses that were refusing you. The list gets smaller and cleaner-looking. Meanwhile the actual problem — an authentication failure, a reputation issue, a provider that has decided something about your domain — is being quietly converted into permanent suppressions and never surfaced to anyone.&lt;/p&gt;

&lt;p&gt;If you want to know whether the cause is authentication rather than reputation, that part is checkable in about thirty seconds: &lt;a href="https://emailcampaign.ai/tools/dns-checker" rel="noopener noreferrer"&gt;SPF, DKIM and DMARC against live DNS&lt;/a&gt;. Authentication problems are the cheap ones. Reputation is the expensive one, and no DNS change fixes it.&lt;/p&gt;

</description>
      <category>email</category>
      <category>smtp</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>DMARC aggregate reports are XML, in a gzip, in an email. Nobody reads them.</title>
      <dc:creator>Email Campaign LLC</dc:creator>
      <pubDate>Sat, 19 Sep 2026 20:08:59 +0000</pubDate>
      <link>https://dev.to/emailcampaignai/dmarc-aggregate-reports-are-xml-in-a-gzip-in-an-email-nobody-reads-them-1i0i</link>
      <guid>https://dev.to/emailcampaignai/dmarc-aggregate-reports-are-xml-in-a-gzip-in-an-email-nobody-reads-them-1i0i</guid>
      <description>&lt;p&gt;Publishing a DMARC record with &lt;code&gt;rua=&lt;/code&gt; is the easy part. You add the TXT record, reports start arriving, and then you discover what a report actually is: a gzipped XML attachment on an email, sent daily by every receiver that got mail claiming to be from your domain.&lt;/p&gt;

&lt;p&gt;So the reports pile up in a mailbox nobody opens, the domain stays at &lt;code&gt;p=none&lt;/code&gt; forever, and the record that was supposed to stop people spoofing you protects nothing at all. Monitoring mode is not a policy. It is the absence of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually in the file
&lt;/h2&gt;

&lt;p&gt;Under the gzip it is a &lt;code&gt;&amp;lt;feedback&amp;gt;&lt;/code&gt; document. The parts that matter:&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;policy_published&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;domain&amp;gt;&lt;/span&gt;example.com&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;none&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;adkim&amp;gt;&lt;/span&gt;r&lt;span class="nt"&gt;&amp;lt;/adkim&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;aspf&amp;gt;&lt;/span&gt;r&lt;span class="nt"&gt;&amp;lt;/aspf&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/policy_published&amp;gt;&lt;/span&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.10&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;12&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;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;pass&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;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;/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;Two things about this structure trip people up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;policy_evaluated&lt;/code&gt; is not &lt;code&gt;auth_results&lt;/code&gt;.&lt;/strong&gt; The first is alignment, the second is raw authentication. You can have &lt;code&gt;auth_results&lt;/code&gt; showing &lt;code&gt;dkim=pass&lt;/code&gt; and &lt;code&gt;policy_evaluated&lt;/code&gt; showing &lt;code&gt;dkim=fail&lt;/code&gt;, and that combination is common: the message &lt;em&gt;was&lt;/em&gt; validly signed, just by somebody else's domain. Your ESP signs with &lt;code&gt;d=esp.example.net&lt;/code&gt;, the signature verifies perfectly, and it aligns with nothing. DMARC fails.&lt;/p&gt;

&lt;p&gt;If you only read &lt;code&gt;auth_results&lt;/code&gt;, every one of those looks fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;count&lt;/code&gt; is not 1.&lt;/strong&gt; Each &lt;code&gt;&amp;lt;record&amp;gt;&lt;/code&gt; is an aggregate over a source IP, so a single record can represent thousands of messages. Summing records instead of counts will quietly understate the traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question you are actually asking
&lt;/h2&gt;

&lt;p&gt;Nobody reads these reports for fun. There is exactly one question: &lt;strong&gt;can I move off &lt;code&gt;p=none&lt;/code&gt; without quarantining my own mail?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That reduces to: is every source sending as my domain aligned, and if not, which ones are not, and are they mine?&lt;/p&gt;

&lt;p&gt;So I wrote the smallest thing that answers it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dmarc_report.py ~/Maildir/new ~/Maildir/cur

Domain        : example.com
Published p=  : none
Reporters     : google.com
Reports read  : 3  (13 messages)

SOURCE IP        ALIGNED  FAILING  HEADER FROM
203.0.113.199          0        1  mail-1.example.com
                 why: SPF none; no DKIM signature
203.0.113.10          12        0  example.com

Aligned: 12 of 13 (92.3%)

VERDICT: 1 message(s) did not align. Identify each source above
         before enforcing, or you will quarantine your own mail.
         A subdomain in HEADER FROM can be spared with sp=none.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads &lt;code&gt;.xml&lt;/code&gt;, &lt;code&gt;.gz&lt;/code&gt;, &lt;code&gt;.zip&lt;/code&gt;, a raw &lt;code&gt;.eml&lt;/code&gt;, or a whole Maildir, and it is Python standard library only — no dependencies, no network calls, nothing uploaded anywhere. That last point matters more than it sounds: DMARC reports name every IP that sends as you, which is an infrastructure map you probably do not want to paste into a web form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/coldemailmarketing/dmarc-report-parser" rel="noopener noreferrer"&gt;github.com/coldemailmarketing/dmarc-report-parser&lt;/a&gt;&lt;/strong&gt; — MIT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details worth stealing even if you write your own
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Deduplicate by &lt;code&gt;report_id&lt;/code&gt;.&lt;/strong&gt; The same report routinely arrives twice. If you are counting messages to decide whether enforcement is safe, duplicates inflate your confidence in exactly the wrong direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat a subdomain failure differently from an organizational-domain failure.&lt;/strong&gt; Subdomains inherit the organizational policy &lt;em&gt;unless&lt;/em&gt; you publish an &lt;code&gt;sp=&lt;/code&gt; tag. So a stray subdomain failing alignment does not have to block enforcement on your brand domain — you enforce &lt;code&gt;p=quarantine&lt;/code&gt; and set &lt;code&gt;sp=none&lt;/code&gt;, then deal with the subdomain separately.&lt;/p&gt;

&lt;p&gt;That one distinction is the difference between enforcing this month and enforcing next year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit of the whole exercise
&lt;/h2&gt;

&lt;p&gt;A verdict of "safe to enforce" is only as good as the sample. Aggregate reports cover mail that receivers chose to report on. If your invoicing runs through a vendor whose mail happened not to reach a reporting receiver that week, it will not appear, and it will not be in the verdict.&lt;/p&gt;

&lt;p&gt;Before you enforce, write down every system that puts your domain in a &lt;code&gt;From:&lt;/code&gt; header — app, billing, helpdesk, newsletter, anything a vendor sends on your behalf — and confirm each one appears in the reports as aligned. The parser tells you what the reports say. It cannot tell you what they left out.&lt;/p&gt;

&lt;p&gt;If you want the records themselves checked against live DNS rather than after the fact, &lt;a href="https://emailcampaign.ai/tools/dns-checker" rel="noopener noreferrer"&gt;this does SPF, DKIM and DMARC in one pass&lt;/a&gt; — it counts every &lt;code&gt;v=spf1&lt;/code&gt; record on the host rather than the first one, which is its own separate trap.&lt;/p&gt;

</description>
      <category>email</category>
      <category>dns</category>
      <category>security</category>
      <category>python</category>
    </item>
    <item>
      <title>SPF permerror: the two failures that look exactly like a working record</title>
      <dc:creator>Email Campaign LLC</dc:creator>
      <pubDate>Sat, 19 Sep 2026 15:14:46 +0000</pubDate>
      <link>https://dev.to/emailcampaignai/spf-permerror-the-two-failures-that-look-exactly-like-a-working-record-4bp7</link>
      <guid>https://dev.to/emailcampaignai/spf-permerror-the-two-failures-that-look-exactly-like-a-working-record-4bp7</guid>
      <description>&lt;p&gt;Your SPF record can be syntactically perfect, published on the right host, and still fail every check a receiving mail server runs. I have watched it take down three sending domains at once. The record looked correct in the registrar dashboard, it looked correct in three online validators, and mail was being refused anyway.&lt;/p&gt;

&lt;p&gt;Here are the two failures behind almost every "but the record is right there" case I have investigated, and how to actually detect them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The ten-lookup limit, which nothing warns you about
&lt;/h2&gt;

&lt;p&gt;RFC 7208 caps SPF evaluation at &lt;strong&gt;ten DNS lookups&lt;/strong&gt;. Every &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; mechanism costs one, and includes nest, so the cost of your record is the cost of the whole tree it pulls in.&lt;/p&gt;

&lt;p&gt;Cross the limit and the result is &lt;code&gt;permerror&lt;/code&gt;. That is not a soft failure or a partial pass. It means the receiving system could not evaluate SPF at all, and most receivers treat that the same way they treat an outright fail.&lt;/p&gt;

&lt;p&gt;This is a nasty bug class for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is invisible in the record.&lt;/strong&gt; Your TXT record is 120 characters and looks fine. The eleventh lookup is three levels down inside somebody else's include.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It appears without you touching anything.&lt;/strong&gt; A provider adds an include to their own record, your tree grows, and a record that worked for a year starts failing on a Tuesday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It gets worse as you add vendors.&lt;/strong&gt; One provider is usually fine. The third one is where teams land.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Counting properly means walking the tree, not counting the includes you can see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dns.resolver&lt;/span&gt;

&lt;span class="n"&gt;MECHANISMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mx:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ptr:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exists:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spf_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TXT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;txt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v=spf1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;txt&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return the number of DNS lookups an SPF evaluation costs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spf_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MECHANISMS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ptr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;                      &lt;span class="c1"&gt;# this mechanism costs one
&lt;/span&gt;                &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
                    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 11 or more means permerror
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to note. &lt;code&gt;ptr&lt;/code&gt; is one lookup and you should not be using it at all. And the &lt;code&gt;seen&lt;/code&gt; set matters, because providers do occasionally publish records that point at each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is flattening or consolidation, not "add one more include". If you must flatten, automate the re-flattening, because you have now taken on the job of noticing when your provider changes their IP ranges.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Two SPF records, which is an instant fail
&lt;/h2&gt;

&lt;p&gt;This one is simpler and even more common. Publishing two TXT records that both start &lt;code&gt;v=spf1&lt;/code&gt; on the same host is a &lt;code&gt;permerror&lt;/code&gt; by definition. RFC 7208 is explicit: more than one record means the check terminates.&lt;/p&gt;

&lt;p&gt;It happens because each record arrives from a different place and each is individually correct. The web host gives you one during setup. The mail provider gives you another. Neither knows about the other, and the registrar interface happily shows both.&lt;/p&gt;

&lt;p&gt;Detection is one query, and the trap is that you have to count records rather than read the first one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short TXT example.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ci&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;v=spf1"&lt;/span&gt;
&lt;span class="c"&gt;# anything other than 1 is a problem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;-c&lt;/code&gt;. A checker that reads the first matching record and validates it will tell you everything is fine. We had exactly that bug in our own tooling, which is a large part of why I am writing this: our checker took the first &lt;code&gt;v=spf1&lt;/code&gt; it found and never counted the rest, and never counted lookups either. Both are now counted, and a sweep of 274 domains after the fix came back clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; is a merge, not a delete. One record, one &lt;code&gt;v=spf1&lt;/code&gt;, every include inside it, ending in &lt;code&gt;~all&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;v=spf1 include:_spf.google.com include:spf.protection.outlook.com ~all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;~all&lt;/code&gt; rather than &lt;code&gt;-all&lt;/code&gt; on a sending domain. A soft fail leaves room for forwarding paths that legitimately break SPF, and receivers weight DMARC alignment more heavily anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  A third one, if you are on Microsoft 365
&lt;/h2&gt;

&lt;p&gt;Slightly different shape, same class of problem: the record exists, looks right, and does nothing.&lt;/p&gt;

&lt;p&gt;Microsoft 365 DKIM uses two CNAMEs, on &lt;code&gt;selector1._domainkey&lt;/code&gt; and &lt;code&gt;selector2._domainkey&lt;/code&gt;, pointing at a host inside your tenant's &lt;code&gt;onmicrosoft.com&lt;/code&gt; domain. Almost every guide prints that target as a fixed pattern. &lt;strong&gt;It is not fixed.&lt;/strong&gt; The host contains a per-tenant value, so a CNAME copied from a blog post, or from a tenant you configured last month, resolves to nothing at all.&lt;/p&gt;

&lt;p&gt;The symptom is the worst kind: no error anywhere. The records are published, the portal shows them, and DKIM simply never signs.&lt;/p&gt;

&lt;p&gt;Read the target from the tenant you are actually configuring, then confirm the provider reports &lt;em&gt;signing&lt;/em&gt; rather than &lt;em&gt;record present&lt;/em&gt;. Those are different states, and only one of them puts a &lt;code&gt;d=&lt;/code&gt; header on your mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify against a resolver, not a dashboard
&lt;/h2&gt;

&lt;p&gt;Everything above shares one root cause: &lt;strong&gt;the registrar dashboard shows what you saved, and the mail provider sees what a public resolver serves.&lt;/strong&gt; Those are different things, and the gap between them is where silent failures live. A zone that did not reload. A record saved on the wrong host. A CNAME flattened by a proxy. An apex record the registrar quietly rewrote.&lt;/p&gt;

&lt;p&gt;So treat a record as unverified until a resolver outside your own account returns it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig TXT example.com @1.1.1.1
dig TXT selector1._domainkey.example.com @1.1.1.1
dig TXT _dmarc.example.com @1.1.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then send one real message to a mailbox at a different provider and read the &lt;code&gt;Authentication-Results&lt;/code&gt; header. A test tool proves the record. A real message proves the path.&lt;/p&gt;




&lt;p&gt;I write about this because it is the layer underneath cold email, and it is the layer that decides whether anything else you do matters. If you want the longer version, with DMARC alignment and the order to write the records in, it is here: &lt;a href="https://emailcampaign.ai/guides/spf-dkim-dmarc-cold-email" rel="noopener noreferrer"&gt;SPF, DKIM and DMARC for cold email&lt;/a&gt;. There is also a free checker that counts lookups and duplicate records rather than reading the first one it finds: &lt;a href="https://emailcampaign.ai/tools/dns-checker" rel="noopener noreferrer"&gt;emailcampaign.ai/tools/dns-checker&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Disclosure: I build &lt;a href="https://emailcampaign.ai" rel="noopener noreferrer"&gt;emailcampaign.ai&lt;/a&gt;, which provisions and authenticates sending infrastructure. The bug in our own checker described above was real and is fixed.&lt;/p&gt;

</description>
      <category>email</category>
      <category>dns</category>
      <category>devops</category>
      <category>security</category>
    </item>
  </channel>
</rss>
