<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Sendaro]]></title><description><![CDATA[Sendaro]]></description><link>https://sendaro.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:26:27 GMT</lastBuildDate><atom:link href="https://sendaro.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[SPF, DKIM and DMARC for transactional email: a practical setup for small teams]]></title><description><![CDATA[What each record does, the mistakes that break password-reset mail, and a safe path from p=none to enforcement.]]></description><link>https://sendaro.hashnode.dev/spf-dkim-and-dmarc-for-transactional-email-a-practical-setup-for-small-teams</link><guid isPermaLink="true">https://sendaro.hashnode.dev/spf-dkim-and-dmarc-for-transactional-email-a-practical-setup-for-small-teams</guid><category><![CDATA[email]]></category><category><![CDATA[dns]]></category><category><![CDATA[Devops]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Sendaro]]></dc:creator><pubDate>Mon, 02 Nov 2026 10:58:33 GMT</pubDate><content:encoded><![CDATA[<p>Most developers meet SPF, DKIM and DMARC the same way: password-reset emails start landing in spam, or a mailbox provider starts rejecting them, and someone pastes three TXT records into the DNS panel without knowing what they do. That works until it doesn't. Someone adds a second SPF record, a marketing tool sends from the same domain, or DMARC gets set to <code>reject</code> before anyone has read a single report. This guide explains, in plain terms, what each record proves to the receiving server, how to publish them for a domain that sends transactional mail, and how to roll out DMARC without blocking your own email. The examples use the records Sendaro asks for when you verify a domain (disclosure: I work on Sendaro), but everything here applies whichever provider you use.</p>
<h2>What receiving servers are actually checking</h2>
<p>An email has two "from" addresses. The <strong>envelope sender</strong> (also called <code>MAIL FROM</code> or Return-Path) is used during the SMTP conversation and is where bounces go. The <strong>header From</strong> is what the user sees in their mail app. Many API providers set the envelope sender to a bounce-handling domain of their own, so the two often differ.</p>
<ul>
<li><strong>SPF</strong> asks: <em>is the server that connected to me allowed to send mail for the envelope sender's domain?</em> It looks up a TXT record on that domain and checks the connecting IP against it.</li>
<li><strong>DKIM</strong> asks: <em>was this message signed with a key published in DNS, and did the signed headers and body arrive unaltered?</em> The signature carries a domain (<code>d=</code>) and a selector (<code>s=</code>), and the receiver fetches the public key from <code>&lt;selector&gt;._domainkey.&lt;d&gt;</code>.</li>
<li><strong>DMARC</strong> asks: <em>did SPF or DKIM pass for a domain that</em> <em><strong>aligns</strong></em> <em>with the header From, and what does the domain owner want done if neither did?</em> It also says where to send reports.</li>
</ul>
<p>Alignment is the part people miss. SPF can pass for <code>bounces.yourprovider.net</code> and DKIM can pass for <code>yourprovider.net</code>, and DMARC will still fail for <code>example.com</code>, because neither of those identities matches what the user sees.</p>
<ul>
<li>SPF aligns when the <strong>envelope sender (Return-Path) domain</strong> matches the From domain.</li>
<li>DKIM aligns when the signature's <code>d=</code> <strong>domain</strong> matches the From domain.</li>
<li><strong>Relaxed</strong> alignment (the default, <code>aspf=r</code> / <code>adkim=r</code>) only needs the same organizational domain, so <code>d=mail.example.com</code> aligns with <code>From: noreply@example.com</code>. <strong>Strict</strong> (<code>s</code>) needs an exact match.</li>
</ul>
<p>DMARC passes if <em>either</em> aligned SPF <em>or</em> aligned DKIM passes. For transactional mail sent through an API, DKIM signed with your own domain is usually what carries the result. If your provider's Return-Path stays on its own domain, SPF can pass without aligning, and that's fine as long as DKIM aligns.</p>
<h2>The three records, line by line</h2>
<p>Here is a typical set for <code>example.com</code>. Use placeholder <code>s1</code> for the selector here; in practice, copy the exact names and values your provider gives you.</p>
<p><strong>DKIM</strong> (name <code>s1._domainkey.example.com</code>):</p>
<pre><code class="language-text">s1._domainkey.example.com.  TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkqh...IDAQAB"
</code></pre>
<p><code>p=</code> is the public key. The selector and key are unique to your domain, so never retype them by hand.</p>
<p><strong>SPF</strong> (on the domain itself, shown as <code>@</code> in most DNS panels):</p>
<pre><code class="language-text">example.com.  TXT  "v=spf1 include:amazonses.com ~all"
</code></pre>
<p>This is the record Sendaro's docs show (sending runs on Amazon SES); your provider will give you its own <code>include:</code>. The <code>all</code> at the end decides what happens to everything not listed:</p>
<ul>
<li><code>~all</code> (softfail): "probably not us, treat with suspicion". Receivers usually accept the message but count it against it.</li>
<li><code>-all</code> (fail): "definitely not us". Some receivers reject on that alone, before DMARC is even evaluated.</li>
</ul>
<p><code>~all</code> is the common choice once DMARC is in place, because DMARC then decides what happens to unauthenticated mail and you get reports about it. <code>-all</code> is fine if you're confident your list of senders is complete.</p>
<p><strong>DMARC</strong> (name <code>_dmarc.example.com</code>):</p>
<pre><code class="language-text">_dmarc.example.com.  TXT  "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com"
</code></pre>
<p><code>p=none</code> means "don't act on failures, just report". <code>rua=</code> is where receivers send daily aggregate reports (XML summaries of who sent mail as your domain and whether it passed). If the <code>rua</code> address is on a <em>different</em> domain, that domain has to publish an authorization record, for example <code>example.com._report._dmarc.reports.example.net TXT "v=DMARC1"</code>, or receivers won't send to it.</p>
<h2>Mistake #1: two SPF records</h2>
<p>A domain may have <strong>only one</strong> SPF record. If a DNS lookup returns two TXT records starting with <code>v=spf1</code>, SPF evaluation returns <code>permerror</code>, and receivers treat it as no valid SPF at all. This happens constantly: Google Workspace or Microsoft 365 set one up, then a transactional provider tells you to "add this record", and you add a second.</p>
<p>Merge them instead:</p>
<pre><code class="language-text">example.com.  TXT  "v=spf1 include:_spf.google.com include:amazonses.com ~all"
</code></pre>
<p>The second trap is the <strong>10 DNS-lookup limit</strong>. Every <code>include:</code>, <code>a</code>, <code>mx</code>, <code>ptr</code>, <code>exists</code> and <code>redirect=</code> costs a lookup, and nested includes count too. <code>ip4:</code>, <code>ip6:</code> and <code>all</code> don't. Go over 10 and SPF returns <code>permerror</code>. A domain with Workspace, a CRM, a helpdesk and two email tools can cross it without anyone noticing. Before adding an include, count what you already have, and drop includes for tools you no longer use.</p>
<h2>Mistake #2: records that look right but aren't</h2>
<p>DNS panels are inconsistent about quoting and names:</p>
<ul>
<li>Some panels append your domain automatically, so entering <code>_dmarc.example.com</code> creates <code>_dmarc.example.com.example.com</code>. Enter just <code>_dmarc</code> (or <code>s1._domainkey</code>) if that's what the panel expects.</li>
<li>Long DKIM keys (2048-bit) exceed the 255-character limit for a single TXT string. The fix is several quoted strings <strong>in one record</strong>, which resolvers join with no spaces. Splitting the key across two separate TXT records breaks it.</li>
<li>Smart quotes pasted from a doc, or a stray space inside <code>p=</code>, will also break DKIM.</li>
</ul>
<p>Check what the world actually sees, not what the panel shows:</p>
<pre><code class="language-bash">dig +short TXT example.com                 # expect exactly one "v=spf1 ..." line
dig +short TXT s1._domainkey.example.com   # your DKIM key
dig +short TXT _dmarc.example.com          # your DMARC policy

# ask a public resolver directly, to bypass local caches
dig +short TXT _dmarc.example.com @1.1.1.1
</code></pre>
<p>Then send yourself a real message and read the <code>Authentication-Results</code> header (in Gmail: "Show original"). You want <code>spf=pass</code>, <code>dkim=pass</code> with <code>header.d=example.com</code>, and <code>dmarc=pass</code>.</p>
<h2>Propagation and verification</h2>
<p>DNS changes aren't instant. Depending on the TTL of the old record and your provider, it can take minutes to hours. The common mistake is deleting and re-creating a record that's correct but not yet visible, which resets the wait and sometimes leaves duplicates behind. Check with <code>dig</code>, wait, and check again.</p>
<h2>Keep transactional and bulk mail apart with subdomains</h2>
<p>Reputation is tracked largely per domain. If your newsletter and your password resets share <code>example.com</code>, a complaint spike on a marketing send can push your resets toward spam too. Splitting them is cheap:</p>
<ul>
<li>Receipts and resets on <code>example.com</code> (or <code>mail.example.com</code>)</li>
<li>Product updates and newsletters on <code>news.example.com</code></li>
</ul>
<p>Each sending subdomain needs its own DKIM record (for example <code>s1._domainkey.news.example.com</code>) and its own SPF if the envelope sender uses it. For DMARC, a subdomain without its own <code>_dmarc</code> record falls back to the parent's policy, and the parent's <code>sp=</code> tag, if set, overrides <code>p=</code> for subdomains.</p>
<h2>Rolling out DMARC without blocking yourself</h2>
<ol>
<li><strong>Start at</strong> <code>p=none</code> <strong>with</strong> <code>rua</code><strong>.</strong> Leave it for at least two to four weeks so you see weekly and monthly senders (invoicing tools, CRMs, that one cron job).</li>
<li><strong>Read the aggregate reports.</strong> Raw XML is painful, so use a free report viewer or parser. Look for sources sending as your domain that fail alignment.</li>
<li><strong>Fix or remove each unknown sender.</strong> Add DKIM for legitimate tools, and stop the ones that shouldn't be sending as you.</li>
<li><strong>Move to</strong> <code>p=quarantine</code>, keep reading reports, then <code>p=reject</code> once failures are only things you actually want blocked.</li>
</ol>
<p>A note on <code>pct=</code>: older guides suggest ramping with <code>pct=25</code>, <code>pct=50</code> and so on. The current DMARC spec, RFC 9989 (May 2026), marks <code>pct</code> as historic, because partial percentages were never applied consistently. Its replacement is <code>t=y</code> ("testing"), which asks receivers to apply one level below your policy: <code>p=reject; t=y</code> behaves like quarantine. Receivers still on the old spec ignore <code>t</code>, so during the transition some guides publish <code>pct=0</code> alongside <code>t=y</code>. If that's more nuance than you need, the staged <code>none â†’ quarantine â†’ reject</code> path above works without either tag.</p>
<h2>Unsubscribe headers are for bulk mail, not resets</h2>
<p>Since 2024, Gmail and Yahoo require bulk senders to authenticate with SPF and DKIM, publish DMARC (at least <code>p=none</code>) with the From domain aligned, keep spam complaints low (Google cites a rate under 0.3%), and support one-click unsubscribe via the <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers on marketing mail. Password resets, receipts and sign-in links don't need an unsubscribe link. Don't add one there; nobody should be able to opt out of their own password reset.</p>
<h2>After DNS: keeping reputation clean</h2>
<p>Authentication gets you in the door; behaviour keeps you there.</p>
<ul>
<li><strong>Suppress hard bounces and complaints.</strong> Repeatedly mailing dead addresses or people who marked you as spam is one of the fastest ways to damage reputation. Most providers keep a suppression list; make sure yours is on, or keep one yourself.</li>
<li><strong>Watch bounce and complaint rates per domain</strong>, not just in aggregate, so a problem on <code>news.</code> doesn't hide behind healthy transactional volume.</li>
</ul>
<h2>Checklist</h2>
<pre><code class="language-text">[ ] Exactly one SPF record on each sending domain (dig +short TXT example.com)
[ ] SPF has 10 or fewer DNS lookups, including nested includes
[ ] SPF ends in ~all (or -all if your sender list is complete)
[ ] DKIM record present at &lt;selector&gt;._domainkey.&lt;domain&gt;, copied exactly
[ ] Test message shows dkim=pass with header.d = your domain
[ ] DMARC at _dmarc.&lt;domain&gt; with p=none and a rua= address you actually read
[ ] External rua domain has its _report._dmarc authorization record
[ ] Separate subdomain per mail stream (transactional vs newsletter)
[ ] Each subdomain has its own DKIM (and SPF where needed)
[ ] One-click unsubscribe on bulk mail only
[ ] Hard bounces and complaints suppressed automatically
[ ] Bounce and complaint rates monitored per domain
[ ] DMARC moved to quarantine, then reject, after reviewing reports
</code></pre>
<p>Three TXT records won't fix a bad sending practice, but getting them right, and knowing what each one proves, removes the most common reason legitimate password resets end up in spam.</p>
]]></content:encoded></item></channel></rss>