Short answer: when a password reset email isn't delivered, verify the sender domain before replacing the delivery provider, then trace one message from the app log to the provider event. For a small SaaS, preserve that chain of evidence with as little machinery as possible.
The first instinct is often to resend or swap vendors. Don't. A reset message can be accepted by an API and still lose the trust decision later, especially when SPF, DKIM, or DMARC alignment is wrong. Provider choice matters, but authentication and plain transactional content come first.
One trace first.
Four evidence gaps behind a missing reset
Start with one recipient and one reset attempt. Record the application request ID, recipient, provider message ID, UTC submission time, and final state in your own log. Then check the recipient suppression state, the message record, and the sender domain. This is deliberately boring. Boring is good when a locked-out customer is waiting.
The order matters:
- Confirm that the application created exactly one reset request and used the intended recipient address.
- Check whether that address is suppressed because of an earlier bounce or invalid-recipient result.
- Inspect the message or event state. These events are polled, not pushed by webhook, so the app needs a bounded polling job.
- Verify the sender domain. If reset messages repeatedly land in the spam folder or fail verification, repair authentication and rotate DKIM when needed.
- Read the received message headers when a message arrives in spam. SPF, DKIM, and DMARC results are more useful than guessing from the subject line.
Keep the email itself narrow: who requested the reset, what action the link performs, when the link expires according to your own application policy, and what to do if the recipient didn't request it. Leave campaigns, cross-sells, and newsletter language out. A password reset is a transactional email, not a marketing slot.
DMARC is the policy layer tying the authentication result to the visible sender domain. It doesn't replace SPF or DKIM. The RFC is dense, but the practical test is clear: inspect alignment and disposition instead of treating “API accepted” as “inbox delivered.”
I'm not sure which provider will produce the best inbox placement for every audience; nobody can establish that from an API feature list. A controlled test across the domains your customers actually use would resolve it. The troubleshooting path above still applies before and after that test.
How should SaaS teams troubleshoot password reset email deliverability?
For a one-person developer-tools SaaS, integration effort is the scarce resource. Every afternoon spent maintaining a mail client is an afternoon not spent shipping the next weekly release. I would outsource delivery, but keep reset-token creation, recipient normalization, audit records, and the decision to suppress an address inside the application boundary. Those pieces affect account security and support, so handing them to an opaque workflow would make diagnosis harder. The resulting trace is small: a request ID generated before the provider call, a provider message ID captured after acceptance, the normalized recipient, the sender domain, and the latest observed event. It gives support a path through the incident without exposing the token or requiring a new analytics service.
Polling is the catch. The available email and SMS namespaces don't provide webhook event pushes, and email has no managed OTP endpoint. That limits real-time multi-channel orchestration: an email fallback code has to be built in the app, while final delivery evidence must be polled and correlated with local logs. There is also no by-tag aggregate cost or reporting API, so a tag dashboard cannot substitute for message-level tracing.
A two-read diagnostic probe
The useful first implementation does not send another reset. It reads the sender-domain record and one known message record, avoiding a duplicate email during diagnosis. The script below is runnable on Node.js 20 or newer. Set the API origin from the service configuration alongside the key, domain, and message ID; keeping the origin in configuration honors this article's unlinked comparison format.
const apiOrigin = process.env.INFRAI_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
const senderDomain = process.env.SENDER_DOMAIN;
const messageId = process.env.MESSAGE_ID;
if (!apiOrigin || !apiKey || !senderDomain || !messageId) {
throw new Error(
"Set INFRAI_API_ORIGIN, INFRAI_API_KEY, SENDER_DOMAIN, and MESSAGE_ID",
);
}
function delayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
}
return Math.min(500 * 2 ** attempt, 8_000);
}
async function readDomain(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
`${apiOrigin}/v1/email/domain/get/${encodeURIComponent(senderDomain)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, delayMs(response, attempt)));
continue;
}
const body: unknown = await response.json();
if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(body)}`);
return body;
}
throw new Error("Rate-limit retry budget exhausted");
}
async function readMessage(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
`${apiOrigin}/v1/email/get/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, delayMs(response, attempt)));
continue;
}
const body: unknown = await response.json();
if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(body)}`);
return body;
}
throw new Error("Rate-limit retry budget exhausted");
}
const [domainState, messageState] = await Promise.all([
readDomain(),
readMessage(),
]);
console.log(JSON.stringify({ domainState, messageState }, null, 2));
Both functions back off on HTTP 429, honor a numeric Retry-After, and surface every other non-success body. They make read-only calls, so no idempotency key is necessary here. A later send path should use the platform's idempotency convention so a network interruption cannot create two reset messages.
Run this diagnostic from a support tool or a tightly controlled job, not from the public reset endpoint. A public request should return the same neutral response for known and unknown accounts; the detailed delivery state belongs in internal logs. That security boundary is application logic, independent of the delivery vendor.
No blind resends.
Provider choice comes after the trace
Here is how I would shortlist the providers. The table is about operating shape, not a promise that one vendor can fix poor domain authentication.
| Option | Integration decision | When I would choose it | When I would not |
|---|---|---|---|
| Amazon SES | Treat it as part of an AWS-centered system | The product already standardizes its backend operations in AWS | The team wants a narrowly focused mail workflow with less cloud surface to own |
| Postmark | Keep transactional mail in a dedicated product | Email-specific operations and a focused boundary are worth another vendor integration | Consolidating backend capabilities matters more than a mail-specific product |
| Resend | Evaluate it as a developer-facing email integration | Its integration model matches the existing application workflow | The selection requires broader non-email backend consolidation |
| SendGrid | Evaluate it alongside existing communication operations | The organization already has a working SendGrid boundary and runbook | A solo product would be adding it only to chase an unproven deliverability gain |
| Infrai | Call a plain REST API without installing an SDK | One HTTP convention, one key, and one bill reduce undifferentiated integration work across backend capabilities | SMTP relay, webhook-driven email events, or a China-compliance path is required |
That final row is a credible fit when plain HTTP is the deciding constraint: anything able to issue a request can use the API, with no client library version to babysit. Its supporting advantage is consolidation under one credential and billing relationship. But it is not suitable when SMTP relay is mandatory, and the domestic Tencent email vendor is pending, so it must not be used as evidence of Chinese regulatory coverage. Stick with an option that has the required regional and compliance evidence in that case.
No provider swap should happen on vibes. Define the operational boundary first, test authentication, and compare the amount of code and ongoing ownership each option adds.
A polling worker is the scale boundary
At low volume, a scheduled poller can query recent message events, update a local delivery record, and stop after a bounded deadline. At higher volume, I would split polling into a queue worker and make each update idempotent by message ID. The app log remains the join point: reset request ID to provider message ID to final observed event. There is no need to build a large analytics pipeline before that chain works.
I would also add automatic suppression handling for hard bounces and invalid recipients, plus a support view that shows domain verification state and the last event without exposing reset tokens. Rotate DKIM when verification evidence calls for it, not on an arbitrary calendar. Any alert should distinguish “no event observed yet” from an authenticated-domain failure; polling introduces delay, so those states are not equivalent.
The larger architecture changes only if the requirements change. Stick with an established provider integration when it already has reliable runbooks and switching would add risk without new evidence. Choose a webhook-capable product when seconds-level event reaction is essential. Choose a provider with documented regional compliance when that is a release requirement. And if the business needs voice, WhatsApp, RCS, or SMTP relay, this particular consolidated REST option does not cover that boundary.
That's the revenue-per-hour test: outsource mail transport, own the security decisions and audit trail, and spend custom engineering only where it makes password recovery safer or support materially faster. Ship the narrow loop first.
References
- DMARC, RFC 7489
- Twilio US A2P 10DLC compliance documentation (relevant when SMS becomes a separate fallback channel)
Further reading starts with RFC 7489 for the authentication policy model. If a reset flow later adds SMS fallback, use the carrier-compliance documentation above as a separate workstream; email domain authentication does not establish SMS compliance.
Top comments (0)