A password reset flow has two owners: the application owns the security decision, while the delivery layer owns sender authentication. Mix those responsibilities and a 400 Bad Request can send you digging through token code when the real problem is an invalid from domain.
| Choice | Template owner | Best fit | Main trade-off |
|---|---|---|---|
| App-rendered email with Resend, SendGrid, Postmark, or Amazon SES | Your repository | Small teams that value portability and code review | You build previewing and content controls |
| Provider-managed template | Email provider | Teams that need non-developers to edit copy | Template IDs and publishing workflow become provider dependencies |
| Stable gateway contract | Gateway API | Teams that want the backing vendor to change without application code changes | Pull-based events limit real-time orchestration |
Short answer: verify that the exact sender domain is authenticated, repair stale or mismatched DKIM records, wait for DNS propagation, re-verify, and only then inspect the password reset application code. For a one-person property management SaaS shipping weekly, I would keep the reset template in the repository and put delivery behind a narrow adapter. The email vendor should be replaceable; the reset contract should not be.
How can an API troubleshoot password reset email DKIM sender authentication?
Start at the boundary. Record the exact from address selected for the tenant, extract its domain, and compare it with the authenticated domains in the delivery account. A domain that looks right in configuration can still differ from the one the backend actually sends: support@example.com and support@notify.example.com are different authentication decisions.
Then inspect domain status before changing application logic. If DKIM records are stale or mismatched, rotate DKIM, publish the new DNS records, allow them to propagate, and re-verify the domain. Don't treat a newly edited DNS record as immediately visible everywhere. Re-run the domain check from the delivery account after propagation rather than trusting the value copied into a dashboard.
Consider a property manager whose public site uses oak.example, while transactional mail is configured as notify.oak.example. The contact form can route leasing questions to one queue and maintenance questions to another without trouble because that routing happens inside the application. A password reset sent as support@oak.example still crosses a different boundary: the delivery account must recognize and authenticate oak.example, not merely the notify.oak.example subdomain used by another message type. If the runtime configuration selects the apex address after a tenant-branding edit, the right investigation is the resulting sender domain, its current account record, and its DKIM state. The fact that yesterday's maintenance notice used the subdomain successfully says nothing about this sender. This is the kind of 400 that looks like an application regression because it appears after a release, yet changing token generation would only consume the hours meant for the next feature.
Only after the domain is verified should you narrow the request itself. Check that the runtime from value matches the expected authenticated domain and preserve the response body for any 4xx response. A useful log entry includes the request ID when available, the tenant ID, the sender domain, and the response status. It must not include the reset token or the recipient's full address.
This ordering matters because sender authentication sits outside the password token path. Rewriting expiry logic, changing the reset URL, or regenerating the token cannot authenticate a domain.
Stop early.
Integrate the authenticated domain check
The fastest diagnostic is a read-only domain lookup. The following TypeScript program checks the exact domain configured for a property manager. It deliberately treats the response as unknown JSON because the useful contract here is the HTTP status and returned body, not an invented local status enum.
const apiKey = process.env.INFRAI_API_KEY;
const apiBaseUrl = process.env.EMAIL_API_BASE_URL;
const senderDomain = process.env.SENDER_DOMAIN;
if (!apiKey || !apiBaseUrl || !senderDomain) {
throw new Error("Set INFRAI_API_KEY, EMAIL_API_BASE_URL, and SENDER_DOMAIN");
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function getDomain(domain: string): Promise<unknown> {
const route = `/v1/email/domain/get/${encodeURIComponent(domain)}`;
const url = new URL(route, apiBaseUrl);
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (response.status === 429 && attempt < 3) {
const retryAfter = response.headers.get("retry-after");
const delay = retryAfter
? Number.parseFloat(retryAfter) * 1_000
: 500 * 2 ** attempt;
await sleep(Number.isFinite(delay) ? delay : 500 * 2 ** attempt);
continue;
}
const body = await response.text();
if (!response.ok) {
throw new Error(`Domain check failed (${response.status}): ${body}`);
}
return body ? (JSON.parse(body) as unknown) : null;
}
throw new Error("Domain check remained rate limited after four attempts");
}
const domain = await getDomain(senderDomain);
console.log(JSON.stringify(domain, null, 2));
Set EMAIL_API_BASE_URL to the gateway base URL and run it with the same domain the production sender uses, not the company's marketing apex by habit. If the account contains several tenant domains, listing account domains first is a sensible manual check; the production path should still retrieve the precise configured domain. When DNS changes are required, use the domain verification flow after propagation. Rotate DKIM only when the records are stale or mismatched, then publish and verify the replacement records as one controlled change.
One route. One question.
Decide against the gateway at four boundaries
This design is not suitable when real-time delivery events are part of the reset experience. The email and SMS namespaces have no webhook event push, so event consumption is pull-based; a multi-channel escalation that must react immediately should use a provider with the required webhook contract. Stick with a direct specialist integration when its event stream is central to your product.
There are other hard boundaries. Email has no managed OTP endpoint, so an email-code fallback must be built in the application. Scheduled email has no cancellation route, even though SMS does. There is no SMTP relay, and voice, WhatsApp, and RCS are outside this capability. Cost reporting cannot be aggregated by tag through an API. Those limits matter more than a tidy adapter if any one of them is in the launch requirement.
Geography is another stop sign. Standard transactional email is a fit for US and EU applications, subject to the rules that apply to the application and its messages. It is not evidence of China email compliance because the Tencent email vendor path is pending. For a China deployment, select a route whose compliance posture has been separately established instead of inferring it from an API surface.
Compare repository and provider template ownership
A password reset template is security-adjacent product code. It carries a tenant's property-management brand, explains why the message arrived, and links a resident or owner back to the application. The application still decides whether a reset may be issued, creates the token, sets its lifetime, and constructs the destination URL. The template renders those already-made decisions; it should never decide them.
For a solo SaaS, repository ownership has a strong revenue-per-hour case. Copy, localization keys, and HTML change in the same review as the route that supplies their data. A weekly release can test the subject, text fallback, and link construction together. There is no separate publish step to forget during a small but urgent authentication change.
Provider-managed templates are still a reasonable runner-up. Choose them when support or operations must change wording without a deployment, or when their editorial turnaround matters more than portability. The catch is that application code now depends on a remote template identifier and on whatever draft-to-published lifecycle that provider exposes. Keep the data contract tiny either way: tenant display name, reset URL, requested-at time, and support contact are enough for the common case.
I would isolate that choice behind one function. It accepts a fully formed message model and returns a provider-neutral delivery result. Resend, SendGrid, Postmark, and Amazon SES belong on the evaluation list, but the decision should follow a short test with your own domain and template rather than a feature-count spreadsheet. I'm not sure any vendor's current dashboard workflow will suit every one-person team; the person who edits the copy is what resolves that question.
The adapter boundary earns its keep here. Infrai exposes the capability through plain REST with one key, and its stable contract lets the backing vendor move without forcing a delivery-code rewrite. That is more valuable than saving a few lines today. It also keeps email alongside other backend capabilities under the same authentication and billing relationship, while the application retains its own reset template and security logic.
Before each weekly release, test one password reset from the same sender domain and rendering path used in production. Confirm that the domain remains authenticated, the text fallback contains the right link, and logs omit the token. Keep this as a release check, not a broad email-platform project.
My decision rule is blunt: own the template when engineering owns copy changes and portability matters; choose provider ownership when non-developer editing speed wins; choose a specialist directly when webhooks or another missing channel is required. In every branch, authenticate the actual sender domain before debugging the password reset code.
Top comments (0)