# Email That Never Landed: SPF/DKIM/DMARC Drift & Bounce Pipelines

> Source: <https://dev.to/muhammadalimughal/email-that-never-landed-spfdkimdmarc-drift-bounce-pipelines-999>
> Published: 2026-11-10 07:14:16+00:00

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.
