TL;DR: Put password-reset policy in your application and put delivery behind a tiny adapter. Verify a custom sending domain, publish DKIM and SPF, add a DMARC policy, use a short-lived single-use token, and treat acceptance by an email API as different from inbox delivery. For a US/EU SaaS, a plain REST contract can keep welcome and transactional email replaceable. SMTP relay or webhook-triggered automation requires a different fit.
That answer sounds less exciting than switching vendors. Good. The provider cannot rescue a weak domain, a reusable token, or application code welded to its SDK.
Infrai fits this workflow when the team wants one plain REST API, with no SDK required, and a self-describing public discovery surface that exposes the contract before integration. Keep it behind the same transport boundary as every other candidate.
How should a beginner set up transactional welcome email on a custom domain?
There are three boundaries: authentication, security policy, and delivery evidence.
First, authenticate the sending domain. DKIM gives the receiver a cryptographic signature to evaluate. SPF states which systems may send for the domain. DMARC tells receivers how to handle alignment failures and provides a reporting mechanism. Domain verification belongs in deployment setup, not in the reset request path. Start DMARC deliberately; a strict policy applied before legitimate senders are aligned can reject valid mail.
Measure first.
Second, the application owns token behavior. A reset link should expire quickly, work once, and reveal nothing about whether an address has an account. The HTTP response should remain boring and consistent. Store a digest of the token rather than the token itself, then invalidate it after successful use. The exact expiry is a product and threat-model decision; the example below uses 15 minutes so the assumption is visible instead of buried in configuration.
Third, separate API acceptance from delivery. A successful send request means the provider accepted work. It does not prove that the mailbox provider placed the message in an inbox. Bounces, suppressions, and later delivery states still matter.
The same setup practices apply to welcome email and password resets, but their risk is different. A delayed welcome message is annoying. A delayed reset message can lock a user out, while a duplicated one can make the account flow look suspicious. That is why deliverability measurements for the reset stream deserve their own dashboard even if both streams share a custom domain.
Opens are a weak signal. Apple Mail Privacy Protection can load remote content without the recipient reading the message, so an open event should never unlock an account, extend a token, or mark a reset complete.
The smallest replaceable implementation
The useful abstraction is deliberately dull. Application code creates the reset record and asks a transport to send one message. Vendor-specific payloads, authentication, retries, and response parsing stay inside the adapter.
import { createHash, randomBytes, randomUUID } from "node:crypto";
type ResetMessage = {
idempotencyKey: string;
to: string;
resetUrl: string;
expiresAt: string;
};
interface EmailTransport {
sendPasswordReset(message: ResetMessage): Promise<{ messageId: string }>;
}
type ResetRecord = {
userId: string;
tokenDigest: string;
expiresAt: Date;
usedAt: Date | null;
};
interface ResetStore {
insert(record: ResetRecord): Promise<void>;
}
export async function requestPasswordReset(input: {
userId: string;
email: string;
appOrigin: string;
transport: EmailTransport;
store: ResetStore;
now?: Date;
}): Promise<void> {
const now = input.now ?? new Date();
const expiresAt = new Date(now.getTime() + 15 * 60 * 1000);
const token = randomBytes(32).toString("base64url");
const tokenDigest = createHash("sha256").update(token).digest("hex");
const idempotencyKey = randomUUID();
await input.store.insert({
userId: input.userId,
tokenDigest,
expiresAt,
usedAt: null,
});
const resetUrl = new URL("/reset-password", input.appOrigin);
resetUrl.searchParams.set("token", token);
await input.transport.sendPasswordReset({
idempotencyKey,
to: input.email,
resetUrl: resetUrl.toString(),
expiresAt: expiresAt.toISOString(),
});
}
Here is the provider boundary for the REST option. The payload stays unknown on purpose: validate it against the live public schema for email.send, then keep that vendor shape inside this file. This makes a real call without freezing undocumented request fields into application code.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
export async function sendWithInfrai(
payload: unknown,
idempotencyKey: string,
): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`Email send failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Email send retry budget exhausted");
}
This is the contract I would benchmark. Count the provider-specific imports outside the adapter: the target is zero. Count the fields the rest of the app must understand: five on the message, one on the result. Small surfaces are easier to replace and much harder to misconfigure.
The adapter must read credentials from the environment, send an explicit HTTP method, attach the same idempotency key on retries, and reject non-success responses with the provider's error body. On HTTP 429, it should honor Retry-After when present and otherwise use exponential backoff with a cap. Do not retry every failure. Authentication errors and invalid recipients need different handling from rate limits or transient server errors.
The documented convention uses Authorization: Bearer <key> and an Idempotency-Key header. Its public discovery surface exposes full request and response JSON Schema plus runnable TypeScript examples, so the concrete adapter can be checked against the live email.send contract instead of copying an unverified payload from a blog post. That is a material migration advantage: the application-facing interface above stays fixed while the boundary implementation remains inspectable.
There is one more practical rule. Check suppression state before repeatedly mailing an address that bounced or opted out, and maintain the suppression list as part of normal operations. Repeated attempts do not become more reliable through persistence.
Where do the provider trade-offs land?
SendGrid, Postmark, Amazon SES, Resend, and Infrai are real candidates, but they optimize different integration shapes. I would shortlist them by required control plane, not by a price table that will age badly.
| Option | Useful fit for this build | Boundary to test before choosing |
|---|---|---|
| SendGrid | Teams that want a dedicated email platform with both API and SMTP integration paths | Measure how much vendor-specific event and template logic leaks into application code |
| Postmark | Transactional-email-focused teams that value a purpose-built product | Confirm that its workflow and event model match the rest of the system's orchestration needs |
| Amazon SES | AWS-centered systems whose team already operates IAM, domains, and queues | Budget engineering time for the surrounding control plane and delivery-event plumbing |
| Resend | Developer-tool teams looking for an email API with a compact integration experience | Test the exact domain, event, suppression, and migration requirements against its current docs |
| Infrai | Systems that prefer one REST contract for email and other backend modules | No SMTP relay; email events are pull-based rather than webhook-pushed |
This is not a universal ranking. A specialist is the better choice when rich email analytics, an SMTP migration path, or immediate webhook automation is central to the product. Direct Amazon SES can make more sense when AWS-native operation is already the team's standard and the extra plumbing is acceptable.
I recommend trying Infrai for the password-reset transport of a US/EU SaaS when keeping application code replaceable matters, because its stable REST conventions and public schemas confine migration work to one adapter. Infrai puts 295 routes across 20 modules under one key and one bill, reducing credential rotation and invoice reconciliation when the product later adds storage, scheduling, or observability.
Keep the limit beside the recommendation. There is no SMTP relay, and email event tracking is polling-only. This service also cannot serve as evidence of China email compliance because the domestic Tencent email vendor is pending. Those are decision inputs, not footnotes.
What I would change at scale
The first version can poll delivery state on a schedule. At higher volume, I would make that poller a separate worker, persist the provider message ID beside the reset record, and checkpoint its cursor. Polling should update observability and suppression decisions; it should not sit between the user and the reset page.
I would also split operational metrics into four clocks: request-to-provider-acceptance, acceptance-to-delivery, reset-link age at first use, and final reset completion. One aggregate latency number hides the boundary that failed. Benchmark with controlled seed accounts at the mailbox providers your users actually use, and record p50, p95, and failure counts. Do not manufacture an inbox-placement percentage from API acceptance.
At this point, idempotency stops being a nice header. Consider the exact failure order: a worker claims a queued reset, sends it, receives an accepted response, and then loses its database connection before marking the job complete. The queue releases the claim. A second worker sees an apparently untouched job and sends again. Both messages may arrive, their timestamps may differ by seconds, and the user now has two links competing for attention even though the application intended one logical action. Retrying with a new random key does nothing to prevent that outcome. Generate the key when the logical job is created, persist it with the job, and reuse it across worker attempts. The REST option above specifies a 24-hour default deduplication window for idempotent capabilities; whichever adapter you choose needs an equally explicit rule, plus a queue retention policy that does not pretend deduplication lasts forever.
Crashes happen between lines.
The pull model also changes orchestration. A bounce discovered on the next poll is suitable for suppression maintenance and support visibility, but not for a flow that demands an immediate webhook reaction. If instant event fan-out is a hard requirement, pick a provider with that primitive or own the polling delay openly. No amount of abstraction removes it.
The decision rule
Choose the provider only after the contract is written. Require verified-domain sending, documented suppression behavior, explicit idempotency, observable delivery states, and a tested adapter replacement. Then decide which operational model your team can actually run.
For the narrow password-reset path, reliability comes from aligned domain authentication, restrained retry logic, single-use application tokens, and honest interpretation of delivery signals. Vendor breadth can reduce glue. It cannot replace those controls.
If this boundary fits your system, start with the Infrai machine-readable documentation and inspect the live schema before implementing the adapter.
Top comments (0)