| Choice | Best fit | Main trade-off |
|---|---|---|
| Direct email API | A new Express or Next.js reset flow with one server-side integration | You own an external API contract and its delivery events |
| SMTP relay | An organization with a monitored relay and established sender operations | Connection, authentication, and relay diagnostics stay in your workload |
| Email plus SMS | A recovery design that genuinely needs two channels | Abuse controls, consent, and audit evidence become two systems |
Short answer: use a server-side email API for a simple Node.js password reset when template ownership and an auditable delivery record matter; keep an SMTP relay when the organization already operates one well.
For a property management product, I would use the same delivery boundary for a password reset email and a tenant compliance notice. The application owns the template, recipient rules, token or notice ID, and audit record. The delivery system owns transport. That split protects revenue-per-hour: outsource the undifferentiated transport work, then spend the saved time on the workflow tenants actually pay for. A compliance notice makes the distinction concrete: a manager may need to prove that the approved “inspection access required” copy was rendered for unit 4B, sent to the right contact, and later associated with the delivery evidence. A reset email has a different audience and threat model, but the record-building discipline is the same.
The email itself is not the audit trail. It is one attempt to deliver a message.
Start with an internal message record before choosing a transport. A useful record has an opaque message ID, purpose, recipient reference, template version, locale, creation time, and a hash of the rendered content. Do not store the reset token in the audit record. Store a hash or a token ID, with a short expiry enforced by the account service.
For each attempt, add the transport name, request id if one is returned, status category, and the last provider event observed. “Queued” is not “delivered.” “Delivered” is not “read.” Those states answer different support questions, and collapsing them makes a compliance export look more certain than the evidence allows.
The failure mode I watch for is a successful HTTP response followed by no durable local record. A process can send the message and die before writing its database row. Reverse the order: create the local outbox row, commit it with the password-reset request or notice creation, then let a worker deliver it. Mark the row with an idempotency key derived from the message ID. A retry must not produce two reset emails.
The outbox also makes ownership explicit. If a property manager edits the notice template, the next message gets a new version. Old audit rows still point to the copy that was rendered at the time.
How should template ownership shape a password reset email implementation?
Keep templates in application-controlled source or a versioned template store when legal wording, locale, and review history belong to your product. A hosted template editor can be useful, but it changes who can alter a compliance notice and how you prove which copy was sent. For a notice such as “inspection access required,” store the template version beside the delivery attempt.
The same rule applies to reset links. Render the message from a named template version, pass only the action URL and safe display data into it, and keep security-sensitive decisions in the account service. A template should never decide whether an address is registered, whether a token is valid, or whether a notice is legally due.
Should Node.js Express and Next.js send password reset email through an API?
Usually, yes, if the app is starting from zero. An API-first implementation is a single HTTPS call from an Express handler or a Next.js server route. The API key stays on the server. The browser receives only a generic response, so an attacker cannot use the reset endpoint to discover whether an address belongs to an account.
SMTP is a reasonable answer when it is already boring inside the company. A working relay, domain authentication process, alerting, and an on-call owner are assets. Replacing them because HTTP looks newer creates migration work without improving the reset experience.
What should the TypeScript delivery adapter guarantee?
The application should depend on a narrow interface, not on an SMTP library or a provider-shaped payload. This example leaves the transport URL and field mapping behind one adapter. It shows the important behavior: generic user-facing responses, server-only secrets, bounded retries for 429, and an outbox ID used as the idempotency key.
type MessagePurpose = "password-reset" | "compliance-notice";
type OutboxMessage = {
id: string;
purpose: MessagePurpose;
to: string;
templateVersion: string;
subject: string;
html: string;
};
type DeliveryResult = {
transportId?: string;
state: "accepted";
};
async function deliverEmail(message: OutboxMessage): Promise<DeliveryResult> {
const endpoint = process.env.EMAIL_API_URL;
const apiKey = process.env.EMAIL_API_KEY;
if (!endpoint || !apiKey) throw new Error("Email transport is not configured");
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": message.id,
},
body: JSON.stringify({
to: message.to,
subject: message.subject,
html: message.html,
metadata: {
messageId: message.id,
purpose: message.purpose,
templateVersion: message.templateVersion,
},
}),
});
if (response.status === 429 && attempt < 2) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
const delayMs = Number.isFinite(retryAfter)
? Math.max(0, retryAfter * 1_000)
: 1_000 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Email transport returned ${response.status}`);
}
const body = (await response.json()) as { id?: string };
return { transportId: body.id, state: "accepted" };
}
throw new Error("Email transport retry limit reached");
}
The adapter does not decide whether a reset request is valid. That belongs before the outbox row is created. It also does not put a reset token in metadata, logs, or a URL that an administrator can casually copy. Keep the token in the one-time action link, hash it at rest, and make the endpoint return the same public response for an existing and a non-existing account.
I treat a 429 as a scheduling signal, not as permission to spin in a tight loop. The worker should retain the outbox row, respect a retry limit, and alert on a growing backlog. I'm not sure any delivery dashboard can compensate for an audit model that cannot distinguish an accepted request from a delivered message.
How should a Node.js API-first implementation handle SMTP, SMS, and limits?
Use a transport-neutral contract, then compare transports against the requirements that are hard to change later.
| Requirement | API-first email | Existing SMTP relay | SMS fallback |
|---|---|---|---|
| Fast server integration | Usually a good fit | Depends on the relay and client setup | Usually a separate integration |
| Template ownership | Keep rendering in the application | Keep rendering in the application or relay, by policy | Shorter copy and different review rules |
| Delivery evidence | Requires event mapping and retention | Requires relay logs and mailbox evidence | Requires channel-specific delivery events |
| Compliance scope | Sender authentication and retention | Sender authentication plus relay operations | Consent, geography, abuse, and phone-number lifecycle |
SPF is only one part of sender authorization. RFC 7208 defines how a receiving system can evaluate which hosts are authorized to use a domain in the SMTP envelope-from identity; it does not guarantee inbox placement or prove that a tenant read a notice. Keep domain authentication, bounce handling, suppression, and event retention in the delivery checklist.
SMS should not be bolted onto the password reset button as a panic fallback. It changes threat modeling and user consent. A phone number can be recycled, a message can be exposed on a lock screen, and country rules vary. The SMS documentation from Twilio is useful as a reference for the channel's API surface, but the architecture still needs its own rate limits and audit policy.
The catch is that an API-first email adapter is not suitable when the organization requires a particular relay, on-premises routing, or SMTP-specific controls. Stick with the existing relay when its operational evidence is already stronger than the new API's event model. Choose a separate SMS design only when recovery or notice delivery truly needs that channel. Sometimes the boring option wins.
A decision rule for shipping weekly
For a one-person SaaS, I would make template ownership and evidence the gate, then integration effort the tie-breaker. Keep the reset and property notice templates in versioned application-owned data. Create an outbox record before delivery. Send through a server-side HTTP adapter if no relay is already well run. Record accepted, delivered, bounced, and expired as separate states.
Do a staging test with a real domain configuration and a test mailbox before launch. Confirm that the audit export can answer five questions: what was sent, to whom, using which template version, through which attempt, and what evidence exists now? If it cannot, the transport choice is premature.
The lowest-maintenance system is the one whose failure states the team can explain at 2 a.m. That is a better criterion than API versus SMTP by itself.
Top comments (0)