<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Greg Pabijan-Morawski</title>
    <description>The latest articles on DEV Community by Greg Pabijan-Morawski (@greg_pabijanmorawski).</description>
    <link>https://dev.to/greg_pabijanmorawski</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086301%2F0c2a5471-9da2-4434-ab1d-733495e6866a.png</url>
      <title>DEV Community: Greg Pabijan-Morawski</title>
      <link>https://dev.to/greg_pabijanmorawski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/greg_pabijanmorawski"/>
    <language>en</language>
    <item>
      <title>The infrastructure I run alone for a transactional email API</title>
      <dc:creator>Greg Pabijan-Morawski</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:56:25 +0000</pubDate>
      <link>https://dev.to/greg_pabijanmorawski/the-infrastructure-i-run-alone-for-a-transactional-email-api-1l9i</link>
      <guid>https://dev.to/greg_pabijanmorawski/the-infrastructure-i-run-alone-for-a-transactional-email-api-1l9i</guid>
      <description>&lt;p&gt;I build and run Pulsenote by myself. It's a multi-tenant transactional email API — you POST a payload, it gets delivered, you get told what happened to it. Behind that sentence sits a NestJS monorepo, a Kubernetes cluster, ArgoCD, Vault, Terragrunt and two client SDKs. It sat on two git hosts until recently, which is one of the reductions described at the end.&lt;/p&gt;

&lt;p&gt;That's a lot of surface for one person, and this isn't a post that presents it as a triumph. Some of it is load-bearing. Some of it is me enjoying infrastructure more than I enjoy marketing. This is my attempt to separate the two. If you're deciding how much platform to build before you have customers, the useful part is probably the section near the end where I list what I'd cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the product actually has to do
&lt;/h2&gt;

&lt;p&gt;Every decision below traces back to five requirements. Writing them down is the only way to judge whether the stack is justified or indulgent.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accept a send request fast and never lose it,&lt;/strong&gt; even when the provider is slow or throttling. Accept path and send path must be separate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send through a real provider with real reputation management.&lt;/strong&gt; Domains, DKIM, SPF, bounces, suppression — the product, not plumbing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track what happened after the send.&lt;/strong&gt; Webhooks arrive on the outside world's schedule and have to be ingested independently of everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be multi-tenant from day one.&lt;/strong&gt; API keys, per-tenant domains and templates, data isolation. Retrofitting tenancy is a rewrite; building it in is a schema decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Have a dashboard, an admin surface, and a public site.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Requirements 1–4 are why there is a queue, a worker, a tracker and a domain model. Requirement 5 is why there are three frontends. None of that is Kubernetes yet — hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app layer: one monorepo, several deployables
&lt;/h2&gt;

&lt;p&gt;Pulsenote is a NestJS &lt;strong&gt;native monorepo&lt;/strong&gt;: one repo, one toolchain, several independently deployable apps sharing two libraries.&lt;/p&gt;

&lt;p&gt;Apps: &lt;code&gt;api-gateway&lt;/code&gt;, &lt;code&gt;email-worker&lt;/code&gt;, &lt;code&gt;delivery-tracker&lt;/code&gt;, &lt;code&gt;dashboard-api&lt;/code&gt;, &lt;code&gt;admin-api&lt;/code&gt;, &lt;code&gt;web&lt;/code&gt; (Next.js 16), &lt;code&gt;backoffice&lt;/code&gt; (Next.js 14), &lt;code&gt;landing&lt;/code&gt;.&lt;br&gt;
Libs: &lt;code&gt;common&lt;/code&gt; (shared DTOs, guards, config) and &lt;code&gt;database&lt;/code&gt; (TypeORM entities and migrations against Postgres).&lt;/p&gt;

&lt;p&gt;This is deliberately &lt;em&gt;not&lt;/em&gt; microservices: no service mesh, no per-service database, no independent versioning, no internal contract tests. Nor is it one monolith handling both a burst of inbound API calls and a long tail of send retries. The split follows failure and scaling boundaries, nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   client
     │  POST /v1/notifications   (X-API-Key)
     ▼
┌──────────────┐   validate, authenticate tenant,
│ api-gateway  │   persist, enqueue, return 202
└──────┬───────┘
       │ publish
       ▼
┌──────────────┐
│   LavinMQ    │  durable queue — the buffer between
└──────┬───────┘  "accepted" and "actually sent"
       │ consume
       ▼
┌──────────────┐   render template, apply tenant
│ email-worker │   domain/identity, hand off to provider
└──────┬───────┘
       │ send
       ▼
┌──────────────┐
│   AWS SES    │ ──────► recipient
└──────┬───────┘
       │ delivery / bounce / complaint events
       ▼
┌──────────────────┐   ingest webhooks, reconcile
│ delivery-tracker │   status back onto the message
└──────┬───────────┘
       │
       ▼
┌──────────────┐        ┌───────────────┐   ┌─────────────┐
│  Postgres    │◄───────│ dashboard-api │   │  admin-api  │
└──────────────┘        └───────┬───────┘   └──────┬──────┘
                                │                  │
                             web (N16)        backoffice (N14)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monorepo's value is that &lt;code&gt;common&lt;/code&gt; and &lt;code&gt;database&lt;/code&gt; are &lt;em&gt;one&lt;/em&gt; source of truth. Add a column, and one migration plus one entity change propagate to every app at build time, with the type checker telling me what broke. In a polyrepo I'd be publishing an internal npm package to myself at 11pm. Cross-repo coordination cost is what kills a solo maintainer.&lt;/p&gt;

&lt;p&gt;Separate deployables matter because &lt;code&gt;api-gateway&lt;/code&gt; and &lt;code&gt;email-worker&lt;/code&gt; have different profiles. The gateway is latency-sensitive and bursty; the worker is throughput-oriented and must back off, retry and stall without ever making an API call slow. As one process, a single SES throttling episode would degrade signup, dashboard and API traffic simultaneously.&lt;/p&gt;

&lt;p&gt;The two Next.js versions (16 for &lt;code&gt;web&lt;/code&gt;, 14 for &lt;code&gt;backoffice&lt;/code&gt;) are not a design decision. That's just what an unmigrated internal tool looks like. Leaving it in rather than tidying it up for the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The platform layer: DOKS, ArgoCD and why GitOps as one person
&lt;/h2&gt;

&lt;p&gt;The cluster is DigitalOcean Kubernetes in AMS3, with managed Postgres and LavinMQ alongside it. It ran in LON1 until recently; moving it was a day of work and is a story of its own. AWS provides the organization structure and SES; Cloudflare does DNS; Sentry does errors.&lt;/p&gt;

&lt;p&gt;On top sits an ArgoCD &lt;strong&gt;app-of-apps&lt;/strong&gt; setup: a bootstrap &lt;code&gt;argocd&lt;/code&gt; chart, an &lt;code&gt;argo-config&lt;/code&gt; chart holding ApplicationSets, and a &lt;code&gt;core-apps&lt;/code&gt; umbrella chart bundling external-secrets, Traefik, cert-manager, Vault, external-dns and a CI runner. The application deploys through its own ApplicationSet using a published &lt;code&gt;nestjs&lt;/code&gt; Helm chart.&lt;/p&gt;

&lt;p&gt;Why GitOps when there is no team to coordinate with? It's usually sold as a collaboration mechanism, and I have nobody to collaborate with. The honest answer: I'm not defending against a colleague's mistake, I'm defending against &lt;strong&gt;my own memory&lt;/strong&gt;. Six weeks after touching something I am effectively a new team member with no context. Declarative cluster state in git means "what is running and why" is a file, not an archaeology session with &lt;code&gt;kubectl&lt;/code&gt;, and recovery is a rebuild rather than a recall exercise — which matters far more when nobody else can rebuild it for you.&lt;/p&gt;

&lt;p&gt;Secrets go through external-secrets backed by Vault, with the app reading paths like &lt;code&gt;pulsenote/{db,mq,app,aws}&lt;/code&gt;. Two simpler options lost out. Plain env vars in the manifests: no, manifests live in git and I want them boring. Sealed secrets: tempting, one fewer component to run, but a sealed secret is ciphertext committed to a repo, so rotating means re-sealing and re-committing every consumer. With provider credentials and per-tenant sending identities in play, I wanted rotation to be a Vault operation, not a git commit.&lt;/p&gt;

&lt;p&gt;That reasoning holds. Whether it justifies operating a Vault instance solo is a different question, and I'll come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Terragrunt: what it buys, and the bug I should confess
&lt;/h2&gt;

&lt;p&gt;Infrastructure is a Terragrunt monorepo over about fifteen reusable Terraform modules — AWS organization, DOKS, SES pieces, Vault, LavinMQ, Cloudflare DNS. State lives in S3, provider config derives from the directory path, and each cloud gets its own tree of units.&lt;/p&gt;

&lt;p&gt;What Terragrunt buys over plain Terraform modules is DRY-ness of &lt;em&gt;config&lt;/em&gt;, not of code. Backend blocks, provider blocks and shared inputs are declared once and inherited down the tree, so every new unit is a small &lt;code&gt;terragrunt.hcl&lt;/code&gt; naming a module and its inputs — no copy-pasted backend stanza that drifts. With one AWS org and a second account for production, that inheritance made "add prod later" a plausible sentence rather than a rewrite — which is what it turned out to be when production went live.&lt;/p&gt;

&lt;p&gt;The cost is a second layer of indirection and a second tool's failure modes. And here's the concrete embarrassment, because a post like this is worthless without one.&lt;/p&gt;

&lt;p&gt;Some of my DigitalOcean units point their &lt;code&gt;source&lt;/code&gt; at a &lt;strong&gt;local absolute path on my old laptop&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/Users/&amp;lt;me&amp;gt;/&amp;lt;some-other-project&amp;gt;/infra/tf_modules/..."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A machine-specific path, in an otherwise portable repo, sitting next to a perfectly good in-repo &lt;code&gt;tf_modules/&lt;/code&gt; directory. Those units will not plan on any other machine — a CI runner, a fresh laptop, or mine after a reinstall. It's what happens when you move fast during a migration and it works locally, so the feedback loop that would catch it never fires.&lt;/p&gt;

&lt;p&gt;Two lessons. First: &lt;strong&gt;if your infra only plans on one machine, it isn't infrastructure-as-code, it's a very elaborate shell history.&lt;/strong&gt; Second: nobody caught it because nobody else was ever going to run it. That's the tax of solo work — no second machine means no second opinion. A trivial CI job running &lt;code&gt;terragrunt plan&lt;/code&gt; on every unit from a clean checkout would have caught it in a day.&lt;/p&gt;

&lt;p&gt;The same migration left ArgoCD manifests and DO units still pointing at an old GitLab home for the infra repo. Which brings me to the git hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two git hosts, and whether the split is worth it
&lt;/h2&gt;

&lt;p&gt;The application lived on GitLab; the infrastructure and both SDKs on GitHub, provisioned by Terraform — the repos themselves are Terraform resources, which I like: a new SDK repo is a module instantiation, not a click-through.&lt;/p&gt;

&lt;p&gt;How did this happen? Historically. The app came from a GitLab-centric consulting context with GitLab CI already wired up and a runner in the cluster. That pipeline is migration-first — database migrations run as their own stage before any app image is rolled out, so a deploy can never land code that expects a column the database doesn't have yet. It's the single CI rule I'd port to any stack. The SDKs are public developer artifacts, and GitHub is where that audience searches, stars and files issues, and where the npm and Packagist links point. Infra landed on GitHub as part of a migration that isn't finished.&lt;/p&gt;

&lt;p&gt;The part I'd defend: &lt;strong&gt;public developer artifacts belong where developers are.&lt;/strong&gt; Nobody discovers a PHP SDK in a private GitLab group.&lt;/p&gt;

&lt;p&gt;The part I wouldn't: everything else is friction. Two CI systems, two permission models, two places to look, and cross-references silently pointing at a repo location that moved. Starting today I'd put the app and infra on the same host as the SDKs and eat a one-time migration. The split only pays rent on the SDK repos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; I took my own advice. The application repository and its CI have since moved to GitHub alongside everything else. One thing did not move — container images still push to the GitLab registry, because that part was working and the migration had a scope. So the split survives in exactly one place, at the layer where it costs nothing, which is roughly where it should have been all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SDK strategy: generated TypeScript, hand-written PHP, and a drift test
&lt;/h2&gt;

&lt;p&gt;This is the part of the stack I'm happiest with, and the most reusable idea here.&lt;/p&gt;

&lt;p&gt;Both SDKs cover the same surface: the data plane, the &lt;code&gt;X-API-Key&lt;/code&gt; endpoints for notifications, templates and domains — 17 operations. JWT account-management endpoints are deliberately out of scope; they belong to the dashboard, not to a customer's integration. Deciding that explicitly stopped a lot of scope creep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Node SDK is generated.&lt;/strong&gt; &lt;code&gt;openapi-typescript-codegen&lt;/code&gt; runs against the OpenAPI spec via &lt;code&gt;npm run generate&lt;/code&gt;. Everything under the client is machine output; only &lt;code&gt;src/client.ts&lt;/code&gt; and &lt;code&gt;src/main.ts&lt;/code&gt; are hand-written wrappers. When the spec moves I regenerate and the types change. Cost: ergonomics are whatever the generator gives you, and the diffs are large and unreadable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PHP SDK is hand-written.&lt;/strong&gt; PSR-18 transport, named arguments in, typed models out, PHP 8.1+, plus an auto-discovered Laravel integration — service provider, facade, and a &lt;code&gt;pulsenote&lt;/code&gt; notification channel so Laravel users can do the idiomatic thing instead of learning my client. Generated PHP wouldn't have given me any of that. In an ecosystem where most of your users are on one framework, the framework integration &lt;em&gt;is&lt;/em&gt; the SDK.&lt;/p&gt;

&lt;p&gt;The risk with a hand-written client is drift: the API grows an endpoint and the SDK quietly doesn't have it. So the PHP repo carries a spec-coverage test. Each public operation is annotated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Operation('POST', '/v1/notifications')]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;?string&lt;/span&gt; &lt;span class="nv"&gt;$templateId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Notification&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the test reflects over the SDK, collects every &lt;code&gt;#[Operation]&lt;/code&gt;, loads the committed &lt;code&gt;openapi/pulsenote-api.json&lt;/code&gt;, and diffs the two sets in both directions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;testSdkCoversEverySpecOperation&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;operationsFromSpec&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// method+path from openapi JSON&lt;/span&gt;
    &lt;span class="nv"&gt;$sdk&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;operationsFromAttributes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// method+path from #[Operation]&lt;/span&gt;

    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sdk&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'Endpoints in the spec but missing from the SDK'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSame&lt;/span&gt;&lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sdk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$spec&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'SDK methods pointing at endpoints the API no longer has'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second assertion matters as much as the first: it catches methods that outlived the endpoint they call. &lt;code&gt;make spec&lt;/code&gt; refreshes the committed JSON, so updating the spec is a deliberate act that shows up in a diff and turns the test red until the SDK catches up.&lt;/p&gt;

&lt;p&gt;The pattern is cheap — one test file and one attribute — and it converts "I hope the SDK is current" into a build failure. If you maintain a hand-written client against your own API, this is the highest-leverage thing in this post. You don't need codegen to get codegen's main guarantee; you need a test that fails when the two drift.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(The one plug in this post: all of the above serves &lt;a href="https://pulsenote.eu" rel="noopener noreferrer"&gt;Pulsenote&lt;/a&gt;, which has a free tier if you'd rather see the output than the diagram.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The self-critical part: is this too much platform for one person?
&lt;/h2&gt;

&lt;p&gt;Yes. Parts of it plainly are, and I want to be precise about which parts rather than doing a performative "maybe I over-engineered it" and moving on.&lt;/p&gt;

&lt;p&gt;The free tier is 1,000 emails a month, and the product is newly launched with no traction to speak of. Against that, I operate a Kubernetes cluster, ArgoCD with an app-of-apps hierarchy, Vault plus external-secrets, cert-manager, Traefik, external-dns, a self-hosted CI runner, and a Terragrunt monorepo over a multi-account AWS organization. It was one component longer until I deleted the self-hosted auth server and moved authentication into the application — the single biggest reduction in this list, and the one I should have made sooner. Every one has upgrades, CVEs, breaking chart changes and a 2am failure mode. There is no rotation. I am the rotation.&lt;/p&gt;

&lt;p&gt;Here's the distinction I've landed on, and it's the one thing from this post I'd keep: &lt;strong&gt;complexity that is the product pays for itself; complexity that is around the product usually doesn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For an email API, the things that look like infrastructure to an outsider &lt;em&gt;are&lt;/em&gt; the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-tenancy — API keys, per-tenant domains and templates, isolation — is a feature customers buy, not a deployment detail.&lt;/li&gt;
&lt;li&gt;Deliverability plumbing — SES identities, DKIM/SPF, bounce and complaint ingestion, suppression — is exactly what someone pays to not build. &lt;code&gt;delivery-tracker&lt;/code&gt; exists because "did it arrive" is the question the product answers.&lt;/li&gt;
&lt;li&gt;The durable queue between accept and send is a correctness requirement. Without it, a provider hiccup becomes lost mail.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is over-engineering. Strip it out and I have a thin wrapper over SES with no reason to exist.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;hosting&lt;/em&gt; of it is another matter. Kubernetes, ArgoCD, Vault and Terragrunt are not the product, they're the substrate — and they're where most of my operational hours have gone. The absolute-path bug above cost me nothing in customer value and a real evening of my life. Labelled clearly as an impression rather than measured data: &lt;strong&gt;the platform layer has taken meaningfully more of my time than the app layer, and it's not the part customers can see.&lt;/strong&gt; The recurring bill is dominated by the cluster's node pool and managed Postgres, both of which exist whether or not anyone sends mail — my costs are provisioned, not usage-driven, which is exactly backwards pre-traction.&lt;/p&gt;

&lt;p&gt;The counter-argument, which I do believe: I knew this stack cold before I started. My background is platform work, so standing up DOKS and ArgoCD was faster for me than learning a PaaS's opinions. The trap in build-in-public posts is that the author's prior expertise silently subsidises the recommendation. So: &lt;strong&gt;don't copy this stack because it worked for me — copy it only if you already have these scars.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd cut if I started over
&lt;/h2&gt;

&lt;p&gt;Concretely, unsentimentally, in the order I'd cut them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes, initially.&lt;/strong&gt; For a pre-traction product with a handful of long-running processes, managed containers (App Platform, Fly, ECS, whatever) would run the same eight deployables with a fraction of the operational surface. Move to Kubernetes when a real constraint demands it, not on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted Vault.&lt;/strong&gt; Rotation is a genuine requirement and I'd keep external-secrets as the interface, but point it at a managed secrets store. Same guarantee, no upgrade path to own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted auth (FusionAuth).&lt;/strong&gt; For a B2B email API, auth is table stakes, not a differentiator. Running my own identity provider is the definition of undifferentiated heavy lifting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The second git host.&lt;/strong&gt; Consolidate app and infra onto the SDKs' host. Keep the SDK repos where developers find them; stop paying for two CI systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The self-hosted CI runner.&lt;/strong&gt; It exists because of the cluster. Remove the cluster and it removes itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One of the two admin surfaces, for now.&lt;/strong&gt; &lt;code&gt;admin-api&lt;/code&gt; plus &lt;code&gt;backoffice&lt;/code&gt; on an older Next.js is real maintenance for an audience of exactly one person: me. Database access and a couple of scripts would have covered it early on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two of those have since happened: the self-hosted identity provider is gone, replaced by auth inside the application, and the app repository has moved to sit with the SDKs. Neither was hard once written down — which is an argument for writing the list rather than for having better judgement.&lt;/p&gt;

&lt;p&gt;What I'd keep without hesitation: the NestJS monorepo with separate deployables, the accept/queue/send/track split, Terraform for anything cloud-shaped, and the SDK drift test.&lt;/p&gt;

&lt;p&gt;And the least glamorous line here: I built the platform &lt;em&gt;before&lt;/em&gt; I had users to justify it, because the platform was the part I already knew how to do and shipping to strangers is the part I didn't. That's not an architecture mistake. It's a procrastination pattern that happens to compile.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>saas</category>
      <category>node</category>
    </item>
    <item>
      <title>Why your transactional email needs a queue, not a try/catch</title>
      <dc:creator>Greg Pabijan-Morawski</dc:creator>
      <pubDate>Mon, 31 Aug 2026 06:04:25 +0000</pubDate>
      <link>https://dev.to/greg_pabijanmorawski/why-your-transactional-email-needs-a-queue-not-a-trycatch-4di4</link>
      <guid>https://dev.to/greg_pabijanmorawski/why-your-transactional-email-needs-a-queue-not-a-trycatch-4di4</guid>
      <description>&lt;p&gt;Almost every codebase I've inherited sends email the same way: somewhere inside a POST handler, between the database write and the response, there's an &lt;code&gt;await&lt;/code&gt; on the mail provider's SDK. It works, for months. Then one afternoon your signup endpoint starts timing out, and it takes an hour to work out that the cause is your email provider having a bad day three thousand kilometres away.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://pulsenote.eu" rel="noopener noreferrer"&gt;Pulsenote&lt;/a&gt;, a transactional email API, so I've spent an unreasonable amount of time in the space between "your API call returned 200" and "the message is in the inbox". This post is what lives in that gap, why a try/catch doesn't cover it, and where the line sits between "you need a pipeline" and "you're overengineering a side project".&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version
&lt;/h2&gt;

&lt;p&gt;Here's the code. You've written this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// users.controller.ts&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SignupDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Confirm your email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;renderConfirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine lines, obvious intent, no infrastructure. For a lot of applications this is genuinely the right answer, and I'll come back to that at the end. But let's be precise about what it costs, because "it's fine" and "I haven't measured it" are different statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It puts a third party in your request path.&lt;/strong&gt; Your p99 for &lt;code&gt;POST /signup&lt;/code&gt; is now your p99 plus the provider's p99. Not their median — their tail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A slow provider becomes a slow endpoint, then no endpoint.&lt;/strong&gt; This is the failure mode that actually takes services down. If the provider degrades to five seconds per call, every signup request holds a connection and an event-loop continuation for five seconds. Your connection pool fills, your load balancer queues, health checks fail, the pod gets restarted, and now you're down — because of email. The blast radius of a non-critical dependency became the whole endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A provider 5xx loses the mail entirely.&lt;/strong&gt; What does your &lt;code&gt;catch&lt;/code&gt; do? Realistically one of two things. It rethrows, so the user sees a 500 for a signup that already succeeded in the database — now you have a user row with no confirmation email and a client that will retry and hit a unique constraint. Or it swallows the error, returns 200, and the email is simply gone. No record, no retry, no way to answer "did we ever send that?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no retry.&lt;/strong&gt; SES will return a &lt;code&gt;ThrottlingException&lt;/code&gt; with &lt;code&gt;Maximum sending rate exceeded&lt;/code&gt; when you exceed your account's send rate (&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html" rel="noopener noreferrer"&gt;AWS docs&lt;/a&gt;) — AWS's own guidance is to wait and retry the send request. Inline, inside an HTTP handler, you have nowhere to wait. Your only options are to block the user or drop the message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no idempotency.&lt;/strong&gt; The client's HTTP retry — after a timeout your code caused — sends the email twice, or the user twice, or both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no audit trail.&lt;/strong&gt; When support asks "did the password reset go out to this customer at 14:12?", the honest answer is "there's a log line if the log retention hasn't rolled over".&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix people reach for, which is worse
&lt;/h2&gt;

&lt;p&gt;The instinct, once the latency problem shows up, is to stop awaiting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SignupDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// don't block the response&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;- this line is a landmine&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The latency problem does go away. Everything else gets strictly worse.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.catch(console.error)&lt;/code&gt; is &lt;em&gt;silent loss with extra steps&lt;/em&gt;. The failure is now a log line nobody reads instead of a stack trace someone would have seen. You've converted a loud bug into a quiet one, which is the wrong direction.&lt;/p&gt;

&lt;p&gt;There's no backpressure. Awaiting at least served as an accidental rate limiter — one in-flight send per request. Fire-and-forget lets a traffic spike launch ten thousand concurrent sends at a provider that will throttle you, and the throttling errors land in &lt;code&gt;console.error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And it's lost on restart. A floating promise lives in one process's heap. On Kubernetes — which is where Pulsenote runs, DOKS in LON1 — pods restart constantly: deploys, node drains, evictions, OOM kills, scale-downs. Every rollout silently drops whatever was in flight, and "we rolled out at 14:10" is never the first hypothesis when a customer reports a missing email.&lt;/p&gt;

&lt;p&gt;Fire-and-forget doesn't solve the problem. It moves it somewhere you can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enqueue, then process
&lt;/h2&gt;

&lt;p&gt;The actual fix is to split the operation at the point where you make a promise to the caller.&lt;/p&gt;

&lt;p&gt;An HTTP handler should do exactly the work needed to &lt;em&gt;accept responsibility&lt;/em&gt; for a message, then return. Delivering it happens elsewhere, on its own schedule, with its own failure handling. That boundary — accept vs. deliver — is the whole idea. Everything else is implementation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   client
     │  POST /v1/notifications
     ▼
┌──────────────┐  1. authenticate tenant
│ api-gateway  │  2. INSERT message (status=queued)  ◄── source of truth
└──────┬───────┘  3. publish {messageId}
       │          4. return 202 Accepted
       ▼
┌──────────────┐
│   LavinMQ    │  durable queue — a pointer, not the payload
└──────┬───────┘
       │ consume
       ▼
┌──────────────┐  load row, check status, render,
│ email-worker │  send, record attempt, ack
└──────┬───────┘
       │            ┌──────────────┐
       │  on give-up │ dead letter │
       │ ───────────►└──────────────┘
       ▼
┌──────────────┐
│   AWS SES    │ ──────► recipient's mail server
└──────┬───────┘
       │ Delivery / Bounce / Complaint  (SNS)
       ▼
┌──────────────────┐  reconcile status onto the row,
│ delivery-tracker │  auto-suppress bad recipients
└──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Pulsenote that's &lt;code&gt;api-gateway&lt;/code&gt; → LavinMQ → &lt;code&gt;email-worker&lt;/code&gt;, with &lt;code&gt;delivery-tracker&lt;/code&gt; as a separate ingest path for provider callbacks. The accept side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// notifications.controller.ts&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ApiKeyGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Tenant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TenantContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendEmailDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// 202&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// messages.service.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TenantContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendEmailDto&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// replay, not a new send&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;             &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;templateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="nx"&gt;MessageStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QUEUED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// publish AFTER the row is committed&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;broker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email.send&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there matter more than the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The row is committed before the message is published.&lt;/strong&gt; If you publish first and the transaction rolls back, the worker consumes a message ID that doesn't exist. Commit first: worst case the publish fails and you have a &lt;code&gt;queued&lt;/code&gt; row nobody picked up, which a sweeper query finds in seconds. An orphaned row is recoverable; a phantom job is not. (To close that window entirely, the transactional outbox pattern is the next step up — worth it eventually, not on day one.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The queue carries a pointer, not the payload.&lt;/strong&gt; The database row is the source of truth. The queue is a &lt;em&gt;transport&lt;/em&gt; — no schema evolution, no query interface, no history. If the broker loses a message you can requeue from the table; if the payload only existed in the message, it's gone. And when support asks what happened to a specific email, you need &lt;code&gt;SELECT&lt;/code&gt;, not a queue browser.&lt;/p&gt;

&lt;p&gt;The caller gets a 202 and an ID. That's an honest response — "I have durably accepted this and I will tell you what happens to it" — and a much stronger promise than a 200 that meant "an SDK call didn't throw".&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency, and why "exactly once" is a lie
&lt;/h2&gt;

&lt;p&gt;Every real broker gives you &lt;em&gt;at-least-once&lt;/em&gt; delivery. LavinMQ, RabbitMQ, SQS, Kafka — the guarantee is the same, because the alternative requires a distributed transaction between your broker and your side effect, and the side effect here is an HTTP call to Amazon.&lt;/p&gt;

&lt;p&gt;Your worker crashes after SES accepts the message but before the ack. The broker sees an unacked message and redelivers. That's not a bug, that's the design. &lt;strong&gt;Your worker will see duplicates. Plan for it.&lt;/strong&gt; So dedupe in two places.&lt;/p&gt;

&lt;p&gt;At the edge, an idempotency key from the client — the &lt;code&gt;accept()&lt;/code&gt; above, with a unique index on &lt;code&gt;(tenant_id, idempotency_key)&lt;/code&gt;. A client retrying a timed-out POST gets the original message back instead of a second send. Make the key required for anything expensive, or derive one and document it.&lt;/p&gt;

&lt;p&gt;In the worker, a status check inside a row lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// email.worker.ts&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;RabbitSubscribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pulsenote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;routingKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email.send&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pessimistic_write&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;MessageStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QUEUED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// already handled&lt;/span&gt;

    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MessageStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SENDING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&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;return&lt;/span&gt; &lt;span class="nx"&gt;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// duplicate delivery — ack and move on&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deliver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock is what makes this safe when two consumers get the same message concurrently, which happens whenever you scale the worker past one replica. Without it you have a check-then-act race and you'll ship the occasional double email.&lt;/p&gt;

&lt;p&gt;"Exactly-once delivery" as a product claim generally means exactly-once &lt;em&gt;processing&lt;/em&gt; — at-least-once transport plus deduplication at the consumer. Which is what you just built. There's no version of this where the network stops being able to lose an ack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries: not all failures are equal
&lt;/h2&gt;

&lt;p&gt;The single most valuable thing the worker does is classify errors. Retrying a hard bounce is worse than useless — it inflates the bounce rate that your provider judges you on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;$metadata&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;httpStatusCode&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;$metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;httpStatusCode&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="c1"&gt;// retryable: throttling, provider 5xx, transport failures&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ThrottlingException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TooManyRequestsException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ETIMEDOUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ECONNRESET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EAI_AGAIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// terminal: the request itself is wrong, or the recipient is unreachable&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MessageRejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AccountSuppressionListException&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;terminal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// unknown → retry, and alert on it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retryable means the same request might succeed later: throttling, 5xx, connection resets, DNS blips. Terminal means it will never succeed: malformed address, unverified sending identity, a recipient on the suppression list. Terminal failures go straight to &lt;code&gt;failed&lt;/code&gt; — one attempt, no backoff, immediate status the customer can see.&lt;/p&gt;

&lt;p&gt;For retryable failures, exponential backoff &lt;strong&gt;with jitter&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;nextDelayMs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// cap at 15 min&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;     &lt;span class="c1"&gt;// full-ish jitter&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The jitter is not decoration. When a provider throttles you it throttles everything at once, so every failed message becomes due for retry at the same instant. Without jitter your retries arrive as a synchronised thundering herd and get throttled again, in lockstep, forever. Spreading them out is the difference between draining a backlog and oscillating.&lt;/p&gt;

&lt;p&gt;The cap matters too. AWS's guidance for a throttling error is to wait — their docs suggest an interval of up to 10 minutes before retrying (&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html" rel="noopener noreferrer"&gt;SES quota errors&lt;/a&gt;). Backing off for hours on a transactional email is pointless; a password reset that arrives 90 minutes late has already failed at its job. Pick a max attempt count (I use 5) and a delay ceiling in the low tens of minutes, then stop.&lt;/p&gt;

&lt;p&gt;When attempts are exhausted, the message goes to a &lt;strong&gt;dead-letter queue&lt;/strong&gt; and the row goes to &lt;code&gt;failed&lt;/code&gt;. The DLQ is not a graveyard — it's an inbox for a human. Something is broken if it's non-empty, and the message body is enough to replay once you've fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 250 is not delivery
&lt;/h2&gt;

&lt;p&gt;Here is the part that a queue alone doesn't solve, and the reason email is genuinely harder than most async work.&lt;/p&gt;

&lt;p&gt;SES accepting your message — a &lt;code&gt;MessageId&lt;/code&gt; on the API, a &lt;code&gt;250&lt;/code&gt; on SMTP — means only that SES will &lt;em&gt;attempt&lt;/em&gt; delivery. AWS states it plainly: the &lt;code&gt;Send&lt;/code&gt; event means "the send request was successful and Amazon SES will attempt to deliver the message to the recipient's mail server" (&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity.html" rel="noopener noreferrer"&gt;monitoring sending activity&lt;/a&gt;). Actual &lt;code&gt;Delivery&lt;/code&gt;, &lt;code&gt;Bounce&lt;/code&gt; and &lt;code&gt;Complaint&lt;/code&gt; are separate events that arrive later, over SNS or an event destination, on the receiving world's schedule.&lt;/p&gt;

&lt;p&gt;So your message has a state machine, not a boolean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queued ──► sending ──► sent ──┬──► delivered
   │           │              ├──► bounced      (hard → suppress)
   │           └──► failed    ├──► complained   (→ suppress)
   └──► failed                └──► delayed ──► delivered | bounced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sent&lt;/code&gt; and &lt;code&gt;delivered&lt;/code&gt; are different columns and different truths. A system that only models "did the API call succeed" will report 100% success while a domain silently rejects every message you send it.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;delivery-tracker&lt;/code&gt; exists for: a separate ingest path that verifies SNS signatures, writes the event, and reconciles status onto the message row. Separate because it's driven by an external system with its own retry behaviour — you do not want provider webhook traffic sharing a deployment with your customer-facing API.&lt;/p&gt;

&lt;p&gt;And suppression has to be &lt;strong&gt;automatic&lt;/strong&gt;. On a hard bounce or a complaint, the recipient goes on a suppression list and future sends to that address are rejected at accept time, before they ever reach the provider. The reason is commercial, not aesthetic. SES will place your account under review if your bounce rate reaches 5%, and may pause your sending entirely at 10%; for complaints those numbers are 0.1% and 0.5% (&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/reputationdashboardmessages.html" rel="noopener noreferrer"&gt;SES reputation metrics&lt;/a&gt;). Those are small numbers. Manual suppression is not a control that operates at that resolution — by the time a human notices, the rate is already set. (SES maintains its own account-level suppression list, but you want your own too: yours is per-tenant, queryable, and lets you reject at the API boundary instead of burning a send.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy: one noisy tenant, everyone's problem
&lt;/h2&gt;

&lt;p&gt;If you're building this for one application you can skip ahead. If you're building a platform, fairness is a first-class concern, because your provider quota is a &lt;strong&gt;shared, finite resource&lt;/strong&gt;. A new SES account starts in the sandbox at 200 messages per 24 hours and 1 message per second (&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html" rel="noopener noreferrer"&gt;production access docs&lt;/a&gt;); production limits are higher but still an account-wide ceiling you can hit.&lt;/p&gt;

&lt;p&gt;A single FIFO queue means one tenant dumping 50,000 messages puts every other tenant's password reset behind 50,000 of them. The queue is fair in ordering and grossly unfair in outcome. Two mechanisms fix most of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Admit at the edge.&lt;/strong&gt; Enforce a per-tenant rate limit and plan quota at accept time, in the gateway — a token bucket in Redis keyed by tenant. Rejecting with a 429 the client can back off from is far better than accepting work you'll deliver hours late. Backpressure the caller can &lt;em&gt;see&lt;/em&gt; is a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't let one tenant own the consumers.&lt;/strong&gt; Prefetch of 1 per consumer plus a bounded per-tenant in-flight count stops a single tenant occupying every worker. Separate queues by priority class if you have a real distinction between transactional and bulk — and if you're a transactional API, you should.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The global send rate also needs a limiter in front of the provider, shared across worker replicas, set below your actual SES rate. Retrying throttles works; not being throttled works better.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to log, what to alert on
&lt;/h2&gt;

&lt;p&gt;Structured, on every attempt: &lt;code&gt;messageId&lt;/code&gt;, &lt;code&gt;tenantId&lt;/code&gt;, &lt;code&gt;templateId&lt;/code&gt;, &lt;code&gt;attempt&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, provider &lt;code&gt;MessageId&lt;/code&gt;, error class, latency. Recipient address hashed or redacted depending on where the logs go. The provider's message ID is the join key for every "what happened to this email" investigation — without it you cannot correlate your row with their events.&lt;/p&gt;

&lt;p&gt;Alert on a much shorter list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DLQ depth &amp;gt; 0.&lt;/strong&gt; Not a threshold. Any dead-lettered message means something needs a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounce rate approaching 5%, complaint rate approaching 0.1%&lt;/strong&gt; — per tenant &lt;em&gt;and&lt;/em&gt; account-wide. Alert well below the provider's line, because by the time you cross it the damage is a trailing average you can't undo quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue depth trending up over N minutes.&lt;/strong&gt; Depth is meaningless as an instant value and diagnostic as a derivative: consumers are slower than producers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oldest message age in &lt;code&gt;queued&lt;/code&gt;.&lt;/strong&gt; The one metric that maps directly to what a user experiences. Queue depth can look healthy while one message sits stuck for an hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I would not alert on: individual send failures. That's what retries are for, and alerting on transient noise trains you to ignore the channel — which is how you miss the DLQ page.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you genuinely don't need any of this
&lt;/h2&gt;

&lt;p&gt;I'd rather be useful than sell you architecture, so: &lt;strong&gt;most applications don't need this pipeline.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're sending ten emails a day from a side project, &lt;code&gt;await mailer.send()&lt;/code&gt; in the handler is correct. Fewer moving parts, no broker to operate, no worker to deploy, and the failure mode — you notice an email didn't arrive and click resend — costs you a minute. Building a durable pipeline for that volume is a way of avoiding the harder work of getting users. Roughly where I'd draw the lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inline await&lt;/strong&gt; — low volume, email isn't load-bearing, you'd notice a failure yourself. Add a &lt;code&gt;messages&lt;/code&gt; table anyway, purely for the audit trail. That's a one-hour change with permanent value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue + worker&lt;/strong&gt; — the email is part of a flow a user is waiting on (signup, reset, receipt), or a provider outage would mean silent loss, or you have enough volume to hit rate limits. Note that this doesn't require Kafka. A single durable queue and one worker process is most of the benefit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full pipeline&lt;/strong&gt; (idempotency keys, classified retries, DLQ, webhook ingest, automatic suppression, per-tenant fairness) — you're sending on behalf of other people, or the mail is regulated, or you're big enough that your bounce rate is your provider relationship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest test is a question: &lt;em&gt;if your provider returned 503 for the next thirty minutes, what would happen?&lt;/em&gt; If the answer is "some emails wouldn't arrive and I'd resend them" — you're fine, stop reading. If it's "I don't know" or "we'd lose them and never find out" — that's a queue-shaped hole, and no amount of try/catch fills it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;One product note, clearly marked:&lt;/strong&gt; everything above is the architecture behind &lt;a href="https://pulsenote.eu" rel="noopener noreferrer"&gt;Pulsenote&lt;/a&gt;, which is what I do instead of asking you to build it — a transactional email API with the queue, retries, suppression and delivery tracking already wired up, free tier at 100 emails/month. If you'd rather own the pipeline yourself, the post above is the whole design, and I'd genuinely rather you build it well than not build it at all.&lt;/p&gt;

&lt;p&gt;—&lt;/p&gt;

</description>
      <category>node</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>backend</category>
    </item>
    <item>
      <title>SPF, DKIM and DMARC, explained by someone who had to make it work</title>
      <dc:creator>Greg Pabijan-Morawski</dc:creator>
      <pubDate>Fri, 21 Aug 2026 14:06:30 +0000</pubDate>
      <link>https://dev.to/greg_pabijanmorawski/spf-dkim-and-dmarc-explained-by-someone-who-had-to-make-it-work-2hnb</link>
      <guid>https://dev.to/greg_pabijanmorawski/spf-dkim-and-dmarc-explained-by-someone-who-had-to-make-it-work-2hnb</guid>
      <description>&lt;p&gt;I build a transactional email API, which means I have spent an unreasonable amount of my life reading DMARC aggregate reports and explaining why password reset emails land in spam even though "nothing changed."&lt;/p&gt;

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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




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

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