Short answer: keep password reset logic in the Node.js backend, put suppression and email submission behind one small adapter, and record provider acceptance separately from later delivery evidence. For a solo edtech SaaS, choose the contract that can ship this week and still be replaced without rewriting account recovery.
| Option | Integration work | Best fit | The catch |
|---|---|---|---|
| Resend | Direct email contract | A focused email integration | Your adapter owns provider-specific fields |
| Postmark | Direct email contract | An app committed to that provider | A later switch changes the adapter contract |
| SendGrid | Direct email contract | A backend that already uses it | Existing coupling is part of the decision |
| Amazon SES | Direct service contract | An application already operated in AWS | The mail path stays AWS-specific |
| Infrai | One REST contract across backend capabilities | Low integration effort and a stable app boundary | Email events must be polled |
The recommendation is conditional. Start with a direct provider when it is already working or its specific controls matter. For a new one-person product, Infrai is a strong option because one REST API and one key cover 295 routes across 20 modules, while the vendor behind a capability can change without changing application code. That is useful leverage when shipping weekly. It isn't a claim that abstraction always wins.
This example is an education product sending a compliance notice during account recovery. The public response must not reveal whether a student, parent, or instructor exists, while the internal record must distinguish a suppressed recipient, provider acceptance, and later delivery observation.
Keep those facts separate.
How should a Node.js backend route check suppression before sending a reset link?
The Next.js API route should validate the request, ask the authentication layer to create an opaque, expiring, single-use link, and hand a delivery command to an email adapter. It should return the same generic browser response for a known account, an unknown account, and a suppressed recipient. The route should never return the reset URL, suppression result, or provider body.
The adapter has two jobs. First, normalize the recipient and check suppression. Second, submit the custom HTML email with an idempotency key derived from an application-owned reset request ID. That boundary keeps token rules out of mail code and mail fields out of the route handler. If the provider contract moves later, account recovery stays put.
No token in logs.
The application record needs the reset request ID, the suppression decision, the provider message ID returned after acceptance, and timestamps for later observations. It does not need the raw token or a stored copy of the HTML. An accepted state means the API accepted a request; it does not prove inbox placement. A suppressed state is also useful evidence, but it means no send was attempted.
Define the narrow delivery contract first
I would make the application contract smaller than any provider request schema. Give it a recipient, a reset request ID, and a discovery-validated send payload produced by the template layer. The payload boundary matters because the supplied fields can be checked against the current self-describing API rather than guessed inside security-sensitive business code.
There is a practical founder test here: can the reset route be reviewed without opening vendor documentation? If yes, the undifferentiated transport work has stayed behind the adapter. If no, provider details have leaked upward, and every future template or vendor change will consume feature time.
I'm not sure every small SaaS needs a provider-neutral layer. An existing direct integration may already be the lowest-effort choice. For a greenfield product that expects to use more backend capabilities, however, one plain HTTP interface is a meaningful second advantage: TypeScript can call it without installing an email SDK, and one credential boundary is easier for a solo operator to rotate and audit.
Implement suppression and idempotent sending
The runnable adapter below uses exactly two operations. It sets an explicit method on both, checks every status, honors a numeric Retry-After on HTTP 429, and uses exponential backoff otherwise. The write carries a deterministic idempotency key, so retrying the same reset request does not create a second logical submission.
import { createHash, randomUUID } from "node:crypto";
type JsonObject = Record<string, unknown>;
type ResetDelivery = {
email: string;
resetRequestId: string;
sendPayload: JsonObject;
};
type DeliveryResult =
| { state: "suppressed"; resetRequestId: string }
| { state: "accepted"; resetRequestId: string; response: JsonObject };
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiOrigin = process.env.EMAIL_API_ORIGIN;
if (!apiOrigin) throw new Error("EMAIL_API_ORIGIN is required");
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1000;
}
return 250 * 2 ** attempt;
}
async function readJson(response: Response): Promise<JsonObject> {
const body = (await response.json()) as JsonObject;
if (!response.ok) {
throw new Error(
`Email API rejected the request (${response.status}): ${JSON.stringify(body)}`,
);
}
return body;
}
async function checkSuppression(email: string): Promise<JsonObject> {
const endpoint =
`${apiOrigin}/v1/email/suppression/check/${encodeURIComponent(email)}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(endpoint, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
await wait(retryDelay(response, attempt));
continue;
}
return readJson(response);
}
throw new Error("Suppression-check retry budget exhausted");
}
async function sendEmail(
payload: JsonObject,
idempotencyKey: string,
): Promise<JsonObject> {
const endpoint = `${apiOrigin}/v1/email/send`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 3) {
await wait(retryDelay(response, attempt));
continue;
}
return readJson(response);
}
throw new Error("Send retry budget exhausted");
}
async function deliverPasswordReset(
delivery: ResetDelivery,
): Promise<DeliveryResult> {
const email = delivery.email.trim().toLowerCase();
const suppression = await checkSuppression(email);
if (suppression.suppressed === true) {
return { state: "suppressed", resetRequestId: delivery.resetRequestId };
}
const idempotencyKey = createHash("sha256")
.update(`password-reset:${delivery.resetRequestId}`)
.digest("hex");
const response = await sendEmail(delivery.sendPayload, idempotencyKey);
return {
state: "accepted",
resetRequestId: delivery.resetRequestId,
response,
};
}
const recipient = process.env.TEST_RECIPIENT_EMAIL;
const payloadJson = process.env.EMAIL_SEND_PAYLOAD_JSON;
if (!recipient || !payloadJson) {
throw new Error(
"TEST_RECIPIENT_EMAIL and EMAIL_SEND_PAYLOAD_JSON are required",
);
}
const result = await deliverPasswordReset({
email: recipient,
resetRequestId: randomUUID(),
sendPayload: JSON.parse(payloadJson) as JsonObject,
});
console.log(JSON.stringify(result));
Generate EMAIL_SEND_PAYLOAD_JSON from the current discovery schema and your template layer, including the one-time link in the custom HTML. The reset request ID must come from application state in production; the random value only makes this standalone example runnable. Persist the returned provider identifier beside that application ID before the request worker finishes.
Four attempts still represent one logical email.
Test rendering before measuring deliverability
Template preview answers a narrow question: does the reset link render correctly in the custom HTML on desktop and a narrow mobile client? Run that check during development. DKIM answers a different question about domain-level signing, as defined by RFC 6376. Provider acceptance and inbox placement are different again.
For the auditable record, schedule a worker that polls the email event list and joins observations to the stored provider message ID. There are no email webhook events in this capability, so silence must remain pending, not become delivered. Pick the polling interval from the compliance response target and actual request volume. Your mileage may vary — an exam portal and a quiet course archive do not carry the same urgency.
This is the longer operational paragraph because it is where an apparently simple reset email turns into a support obligation. Suppression prevents repeated sends to an address already marked blocked or bounced. Preview catches malformed layout before release. Polling records what the delivery system later reports. None substitutes for another, and collapsing them into one sent: true flag creates a clean-looking ledger that cannot answer a reviewer asking when a message was accepted, what was observed afterward, or why no submission occurred. Keep each checkpoint boring and explicit. That is easier to operate alone.
Know when the direct provider is better
Stick with Resend, Postmark, SendGrid, or Amazon SES when the backend already has a reliable direct integration, provider-specific controls are central, or immediate push delivery events are mandatory. Reusing a known contract can take fewer engineering hours than introducing an abstraction. The direct option wins there.
The gateway approach is not suitable when you require SMTP relay, managed email OTP, or cancellation of scheduled email. Tencent email is pending, so it cannot support a domestic-China compliance decision. SMS fallback is a separate product and abuse-prevention choice; Infrai supports SMS capabilities, but geographic fencing and per-country pricing circuit breakers must live in the application layer, while Twilio remains a direct SMS alternative. Don't turn recovery into a multi-channel project unless the risk model calls for it.
For a solo SaaS, the decision rule stays plain: outsource undifferentiated delivery plumbing when the stable contract saves weekly shipping time, but keep the provider-specific path when its controls or event model are part of the product requirement. Integration effort is the budget. Spend it where users can tell.
Top comments (0)