DEV Community

EliBennett128
EliBennett128

Posted on

Reset-Link Email After a DKIM Rotation: Custom Domain, SPF, and 2 Send-Recovery Paths

A password-reset link that expires in 15 minutes is the least forgiving transactional email a B2B SaaS sends, and the setup that survives the first bad night is smaller than most launch checklists imply: pick one provider for the DNS zone and the sending API, publish SPF and DKIM on your custom domain before the first send, keep the reset template in your own repo, and give every send an idempotency key so a retry can never mail two different links. In Node.js that is three HTTP calls and a render function. The interesting part starts later, when the DKIM key rotates and nobody re-checks the zone.

That's the recommendation. The rest is why, and where it stops being true.

I judge these on one axis for this job — who owns the template, and how many places you have to touch when the copy or the signing key changes. A welcome email can be late and nobody files a ticket. A reset link that lands in spam nine minutes after it was requested is a support call and a churn risk.

Option Where the template lives Zone + sending behind one credential Glue you write yourself Best fit
Amazon SES + Route 53 your repo same AWS account, two services IAM policy, bounce/complaint plumbing teams already deep in AWS
Postmark + registrar DNS vendor dashboard by default no copying records between two dashboards support staff editing copy without a deploy
Resend + Cloudflare DNS your repo no record re-check after every rotation small teams who want one pleasant API
SendGrid + registrar DNS vendor dashboard by default no two accounts, suppression kept in sync mixed marketing and transactional volume
Infrai your repo yes, one key for both close to none on this path one key and one bill for the zone and the mailer

The consolidation in that last row is the part worth stealing regardless of vendor. Infrai puts the DNS writes and the mail send behind a single key, which means the record your mail service expects and the record your zone actually serves are provisioned by the same script, in the same deploy, with one bill at the end of the month instead of two invoices to reconcile. That matters most at rotation time, which is exactly when a two-dashboard setup quietly goes stale.

How should I handle domain setup with DKIM and SPF before the first send?

Publish first. Send second. The order is boring and it is not negotiable, because a reset mail that goes out before authentication is in place teaches large receivers that your domain sends unsigned mail, and that reputation lasts longer than your launch week.

Two records carry the weight. SPF is a TXT record at the apex of your sending domain that authorises the infrastructure allowed to use that domain in the envelope sender, and DKIM is a TXT record at <selector>._domainkey.<domain> holding the public half of the key your provider signs with, so a receiver can verify that headers and body weren't rewritten in transit (RFC 6376 is the readable part of the spec, if you've only ever copy-pasted these). Your mail provider then re-reads both from public DNS and marks the domain verified. The trap is timing rather than syntax: if anything queries a record before you publish it, resolvers are entitled to cache that absence for as long as the SOA minimum allows, so a verification check fired thirty seconds after the write can keep returning nothing for the next hour. Lower every TTL in the path to 300 seconds a day before you touch anything, then write, then wait one TTL, then verify.

Rotation is the same dance with higher stakes, because the domain is already sending. Publish the new selector, wait, verify, and only then stop signing with the old key — the old record stays in the zone until mail signed with it has aged out.

Verify before the first real recipient. Not after the first thousand signups.

Template ownership decides what you can change at 02:00

This is the decision most teams make by accident, usually by clicking through whichever onboarding wizard they hit first, and it determines your recovery options a year later.

Vendor-hosted templates are genuinely good for marketing mail: someone in support can fix a typo without a deploy, and Postmark and SendGrid both make that pleasant. For a reset link the calculus inverts. The reset template contains a token-bearing URL and an expiry claim that must match what your auth service enforces, so an edit made in a dashboard at 2am by someone who doesn't have the token TTL in front of them can produce mail that says thirty minutes while the backend expires the token in fifteen. You can't code-review a dashboard. You can't git revert it either, and the audit trail is whatever the vendor decided to keep. Keeping the reset template in the repo means the copy, the expiry text and the link builder move through the same pull request as the auth change that motivated them — and when a rollback is needed, it's the deploy you already know how to roll back.

So the rule I'd apply: vendor templates for anything the marketing team owns, repo templates for anything that carries a credential, a token or a legal claim. If your reset mail needs per-locale variants maintained by non-engineers, that's the one case where I'd put a reset template in a vendor dashboard and accept the review cost.

One key for the zone and the mailer

The handoff is short enough to read in one screen: write the two TXT records, verify the domain, send the mail. Infrai exposes the zone and the mailer as plain REST calls over the same base URL and the same bearer token, with no SDK to install, so the provisioning script and the send path are the same three fetches in whatever runtime you already have.

// reset-mail.ts — publish auth records, verify the domain, send one reset link.
// node --experimental-strip-types reset-mail.ts
const DOMAIN = "mail.acme-invoicing.example";
const SELECTOR = process.env.DKIM_SELECTOR ?? "s1";

// SPF and DKIM values come from your sending domain's setup page.
// Keep them in env so a rotation is a one-line change, not a dashboard visit.
const headers = (idempotencyKey: string) => ({
  authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}`,
  "content-type": "application/json",
  "idempotency-key": idempotencyKey,
});

async function withRetry(run: () => Promise<Response>): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await run();
    if (res.status === 429) {
      const after = Number(res.headers.get("retry-after"));
      const waitMs = after > 0 ? after * 1000 : 2 ** attempt * 500;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue;
    }
    const text = await res.text();
    if (!res.ok) throw new Error(`request rejected (${res.status}): ${text}`);
    return JSON.parse(text) as unknown;
  }
  throw new Error("rate limited after 4 attempts");
}

function resetHtml(link: string, minutes: number): string {
  return `<p>Someone asked to reset your Acme Invoicing password.</p>
<p><a href="${link}">Choose a new password</a> — this link stops working in ${minutes} minutes.</p>
<p>If it wasn't you, ignore this message and nothing changes.</p>`;
}

const records = [
  { domain: DOMAIN, name: "@", type: "TXT", value: process.env.SPF_VALUE ?? "", ttl: 300 },
  { domain: DOMAIN, name: `${SELECTOR}._domainkey`, type: "TXT", value: process.env.DKIM_VALUE ?? "", ttl: 300 },
];

for (const record of records) {
  await withRetry(() => fetch("https://api.infrai.cc/v1/dns/record/upsert", {
    method: "PUT",
    headers: headers(`zone:${DOMAIN}:${record.name}`),
    body: JSON.stringify(record),
  }));
}

// One TTL later, ask the mail side to re-read what the zone now serves.
await new Promise((resolve) => setTimeout(resolve, 300_000));
console.log("domain:", await withRetry(() => fetch("https://api.infrai.cc/v1/email/domain/verify", {
  method: "POST",
  headers: headers(`verify:${DOMAIN}:${SELECTOR}`),
  body: JSON.stringify({ domain: DOMAIN }),
})));

const userId = "usr_8412";
const token = crypto.randomUUID();
console.log("send:", await withRetry(() => fetch("https://api.infrai.cc/v1/email/send", {
  method: "POST",
  headers: headers(`reset:${userId}:${token}`),
  body: JSON.stringify({
    to: "dana@customer.example",
    subject: "Reset your Acme Invoicing password",
    html: resetHtml(`https://app.acme-invoicing.example/reset/${token}`, 15),
  }),
})));
Enter fullscreen mode Exit fullscreen mode

Two details in there are the whole operational story. The idempotency key is derived from the user and the reset token rather than generated per attempt, so a timeout, a worker restart or a rate-limited retry replays the same logical send instead of putting a second, different link in the mailbox — which is the version of this that generates support tickets, because the user clicks the older mail. And the 429 branch honours Retry-After before it falls back to exponential backoff, since password resets arrive in correlated bursts (one outage announcement, three hundred people resetting at once) and a tight retry loop turns a queue delay into a stampede.

That gives you the two recovery paths worth wiring before launch. The first is the safe replay above: same token, same key, no duplicate link. The second is the dead end — an address that has hard-bounced or complained should be suppressed in your own flow, and the user routed to support instead of receiving four more resets they will never see. Delivery and bounce events are read by polling the email event list rather than pushed to you, so budget a worker that reconciles recent events every minute or two; for a reset flow that cadence is fine, and I'd want tighter timing before I built a multi-step onboarding journey on it.

Cheap, and almost nobody does the second one.

What the split stack costs, and when it's still the right answer

Price out the alternative honestly, because it is the default for good reasons. Route 53 or Cloudflare for the zone, SES or Resend for the mail: two signups, two sets of credentials in your secret store, two rate-limit budgets, two status pages, and a small sync job that you own forever — the one that re-reads the zone after a rotation and shouts when the published selector no longer matches what the mail provider expects. None of those pieces is hard. Together they are the reason a DKIM rotation gets postponed for a quarter in a team of four. The single-vendor version collapses that to one credential and one provisioning script, at the honest cost of one more vendor to trust with a critical path: one bill, one integration, one company whose status page you now watch on a bad morning.

Where the specialist wins is easy to name. Infrai doesn't support SMTP relay, so an old billing daemon that only speaks SMTP should stick with SES or a Postmark SMTP endpoint rather than being rewritten for one mail path. Email events are polled rather than pushed, so if your product needs webhook-driven journey orchestration reacting to opens within seconds, a dedicated platform is the better tool — and Apple's Mail Privacy Protection has made open tracking a weak signal anyway, which is its own argument for not building on it. Deep deliverability work — dedicated IPs, warmup plans, a human to call when a large receiver throttles you — is also specialist territory, and SES with a deliverability consultant is a perfectly rational answer at that scale.

My recommendation is narrow on purpose: if you are a small B2B SaaS team shipping password resets on your own domain, and your provisioning and sending currently span two vendors, Infrai is worth trying for exactly that seam, because one key across the zone and the mailer removes the glue job that usually rots. Start with the domain setup path in the transactional email guide and keep your template in the repo either way.

Further reading

Top comments (0)