Email That Never Landed: SPF/DKIM/DMARC Drift & Bounce Pipelines A principal software engineer at Loop Origin documented how a marketing team's addition of a newsletter tool to the SPF record pushed DNS lookups past the ten-lookup limit, triggering a permanent SPF error and DMARC failures that sent password-reset emails to spam while provider dashboards still reported every message as delivered. The engineer recommends scheduled monitoring of SPF, DKIM and DMARC records, reading DMARC aggregate reports for real pass rates, feeding bounce and complaint events into a suppression table, and running synthetic sends to a seed inbox to confirm arrival and authentication. The support ticket said: "I never got the password reset email." Then three more like it. Our mail provider's dashboard showed every message as "delivered". The application logs showed every send as successful. What had actually happened was a marketing team adding a new newsletter tool to the SPF record two weeks earlier. The record now needed eleven DNS lookups. The limit is ten. SPF started returning a permanent error, DMARC failed for messages that did not also pass DKIM alignment, and a large mailbox provider began putting our resets in spam. Nothing in our stack raised an alert. I wrote this with help from AI tools and checked every DNS record, command and code sample myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and at Loop Origin we set up and maintain servers and SaaS backends where transactional email is part of the critical path. This is Part 3 of the series Silent Failures in Production SaaS. Part 1 covered cron jobs that never ran https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6 and Part 2 covered webhooks that stopped arriving https://dev.to/muhammadalimughal/webhooks-that-stopped-arriving-gap-detection-reconciliation-jo7 . Email is the outbound version of the same problem: "sent" is not the same as "received". 📚 Series Roadmap — Silent Failures in Production SaaS Part 1: Cron Jobs That Never Ran: Heartbeats & Dead-Man Switches for SaaS https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6 Part 2: Webhooks That Stopped Arriving: Gap Detection & Reconciliation https://dev.to/muhammadalimughal/webhooks-that-stopped-arriving-gap-detection-reconciliation-jo7 Part 3 This Post : Email That Never Landed: SPF/DKIM/DMARC Drift & Bounce Pipelines Part 4: Config & Feature Flags That Quietly Diverged Across Environments Part 5: Backups That Never Restored: Automated Restore Drills Part 6: Synthetic Checks & SLOs for the Paths Nobody Clicks Related earlier posts: Production Observability on a Budget https://dev.to/muhammadalimughal/production-observability-on-a-budget-opentelemetry-traces-structured-logs-tail-sampling-360a · Subdomain Takeover & Dangling DNS Reconnaissance https://dev.to/muhammadalimughal/subdomain-takeover-dangling-dns-reconnaissance-auditing-cname-ns-txt-tls-certificates-hoh · Observability for Async Work: Correlation IDs & Job Traces https://dev.to/muhammadalimughal/observability-for-async-work-correlation-ids-job-traces-16f3 Direct answer: Monitor DNS authentication records on a schedule and alert when SPF, DKIM or DMARC change or fail validation. Read DMARC aggregate reports to see real pass rates by sending source. Feed provider bounce and complaint events into a suppression table, and run a synthetic send to a seed inbox that checks the message arrived and authenticated. Your application hands a message to an SMTP server or an API, gets a 200 or a 250 OK , and moves on. From that point everything that can go wrong happens outside your logs: include , a , mx , ptr , exists and redirect costs a DNS lookup, including nested ones. More than ten gives permerror . v=spf1 TXT record instead of editing the first. That is also a billing@yourapp.com but signs with its own domain, so DMARC fails even though SPF and DKIM each "pass". Since early 2024, Gmail and Yahoo require bulk senders to authenticate with SPF and DKIM, publish DMARC, support one-click unsubscribe on marketing mail and keep spam complaint rates low, and Microsoft has introduced similar rules for Outlook consumer domains. Authentication drift that used to cost a bit of inbox placement now gets mail rejected. Start with a quick manual audit: DOMAIN=yourapp.com dig +short TXT "$DOMAIN" | grep -i 'v=spf1' dig +short TXT " dmarc.$DOMAIN" One query per DKIM selector your providers use for s in s1 s2 resend google; do printf '%-8s ' "$s"; dig +short TXT "$s. domainkey.$DOMAIN" | head -c 80; echo done A healthy baseline looks something like: yourapp.com. TXT "v=spf1 include:amazonses.com include: spf.google.com -all" dmarc.yourapp.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourapp.com; adkim=r; aspf=r" s1. domainkey.yourapp.com. CNAME s1.domainkey.u123.wl.sendgrid.net. Then count SPF lookups recursively. This small script walks includes and redirects: python spf lookups.py -- pip install dnspython import sys, dns.resolver def spf domain : for r in dns.resolver.resolve domain, 'TXT' : txt = b''.join r.strings .decode if txt.lower .startswith 'v=spf1' : return txt return '' def count domain, seen=None : seen = seen or set n = 0 for term in spf domain .split 1: : t = term.lstrip '+-~?' .lower if t.startswith 'include:', 'redirect=' : target = t.split ':', 1 -1 .split '=', 1 -1 n += 1 if target not in seen: seen.add target n += count target, seen elif t.startswith 'a', 'mx', 'ptr', 'exists:' and not t.startswith 'all' : n += 1 return n d = sys.argv 1 total = count d print f"{d}: {total} DNS lookups", "OK" if total <= 10 else "PERMERROR RISK" Run the audit on a schedule and compare against a stored baseline. Any change should notify a human, because a legitimate change still needs a review of alignment and lookup counts. In Laravel: // app/Console/Commands/CheckMailDns.php public function handle : int { $domain = config 'mail.auth domain' ; $checks = 'spf' = $this- txt $domain, 'v=spf1' , 'dmarc' = $this- txt " dmarc.$domain", 'v=DMARC1' , 'dkim' = collect config 'mail.dkim selectors' - mapWithKeys fn $s = $s = bool dns get record "$s. domainkey.$domain", DNS TXT | DNS CNAME - all , ; $hash = hash 'sha256', json encode $checks ; $previous = Cache::get 'mail dns hash' ; $missing = collect $checks 'dkim' - filter fn $ok = $ok - keys ; if $checks 'spf' || $checks 'dmarc' || $missing- isNotEmpty || $previous && $previous == $hash { Log::critical 'mail dns drift', 'checks' = $checks, 'missing dkim' = $missing ; // notify on-call channel here } Cache::forever 'mail dns hash', $hash ; Http::get config 'services.heartbeats.mail dns' ; // dead-man switch from Part 1 return self::SUCCESS; } private function txt string $host, string $prefix : ?string { foreach dns get record $host, DNS TXT ?: as $r { if str starts with strtolower $r 'txt' , strtolower $prefix return $r 'txt' ; } return null; } Schedule it hourly. The heartbeat ping at the end means you also hear about it when the check itself stops running. The rua address in your DMARC record receives daily XML reports from large mailbox providers. Each row says which IP sent mail as your domain, how many messages, and whether SPF and DKIM passed and aligned. This is the only place you see what receivers actually observed. You can parse them yourself or use a DMARC reporting service. Either way, alert on: dkim=fail for a provider that previously passed, which usually means a missing or rotated key. Move from p=none to p=quarantine and then p=reject only after reports show every legitimate source aligned. Every serious provider can send bounce and complaint events by webhook or a notification topic. Treat them like the webhook receipts from Part 2: verify, store, process. php Schema::create 'email suppressions', function Blueprint $table { $table- id ; $table- string 'email' - unique ; $table- string 'reason', 32 ; // hard bounce | complaint | manual $table- string 'provider', 32 ; $table- timestampTz 'suppressed at' - useCurrent ; } ; Hard bounces and complaints go straight into the table. Soft bounces only after repeated failures over several days. Then block sends at the framework level, so every mailable respects it: php // app/Listeners/BlockSuppressedRecipients.php use Illuminate\Mail\Events\MessageSending; public function handle MessageSending $event : bool { $to = collect $event- message- getTo - map- getAddress ; if DB::table 'email suppressions' - whereIn 'email', $to - exists { Log::info 'mail suppressed', 'to' = $to- all ; return false; // returning false cancels the send } return true; } Track bounce and complaint rates per day as metrics. A sudden jump in hard bounces is often a signup form being abused, and a jump in complaints is often one template going to the wrong list. Finally, close the loop. A scheduled job sends a real password reset to seed inboxes at major providers. A second job reads those inboxes over IMAP or an API, checks the message arrived within a few minutes, and parses the Authentication-Results header for spf=pass , dkim=pass and dmarc=pass . Missing or failing messages page someone. | Signal | Catches | Delay | Effort | Blind spots | |---|---|---|---|---| | Provider "delivered" status | Server-to-server handoff | Seconds | None | Spam folder placement | | Scheduled DNS drift check | SPF/DKIM/DMARC record changes | Up to 1 hour | Low | Receiver-side policy changes | | DMARC aggregate reports | Real pass/fail by source | ~24 hours | Medium | Small receivers that do not report | | Bounce and complaint events | Bad addresses, reputation risk | Minutes | Medium | Silent spam filtering | | Seed inbox synthetic send | End-to-end inbox delivery | Minutes | Medium | Per-user filters | | User support tickets | Everything, eventually | Days | None | Customer trust | When we add this to a client system at Loop Origin, the order is: audit and baseline the DNS records, wire the drift check with a heartbeat, route DMARC reports somewhere readable, then add suppressions and seed tests for the templates that matter most. Our server setup and maintenance https://looporigin.com/services/server-setup-maintenance?utm source=devto&utm medium=series&utm campaign=email-never-landed-spf-dkim-dmarc-drift work covers DNS, mail authentication and monitoring, and you can contact us https://looporigin.com/contact?utm source=devto&utm medium=series&utm campaign=email-never-landed-spf-dkim-dmarc-drift for a deliverability audit. rua address someone actually reads. dmarc=pass for critical templates. With DMARC enforcement, receivers mostly act on the DMARC result, so either works. Use -all once you are confident every sender is listed. Many teams rotate every six to twelve months. Use 2048-bit keys and keep the old selector published until mail signed with it has stopped arriving. Yes. Major mailbox providers expect it, and it is the only feedback channel that shows how receivers judge your domain. Use a subdomain for marketing, such as news.yourapp.com . Its reputation problems then hurt password resets less. They protect and report on inbound TLS for mail sent to you. Worth adding, but they will not fix outbound authentication drift. Retry transient failures, never hard bounces. Retrying bad addresses damages reputation for everyone on your domain. Next in this series, Part 4 covers config and feature flags that quietly diverged across environments.