DEV Community

GregorSterling9652
GregorSterling9652

Posted on

Template Ownership for Password Reset Email APIs (Without an SMTP Relay)

A password reset email API implementation for a marketplace has an awkward constraint: the seller-facing words are product behavior, even when another service performs delivery. Handing both the template and transport to an email provider looks quick, but it makes review, testing, and migration depend on a remote editor.

Short answer: use an API-first email transport without an SMTP relay, but keep the password reset template, validation, and event contract in the Node.js application; choose a hosted template only when non-engineers must publish copy without an application deployment.

That is the result of this experiment note. The evaluation constraint was template ownership, not a feature count: could one application render a new-order message for a marketplace seller and a password reset message through the same typed boundary, while keeping security-sensitive copy reviewable in Git? The simplest first attempt was to pass a remote template ID and a loose bag of variables. It was short. It also moved the real interface out of the codebase.

No magic here.

What should a simple password reset email API own in a Node.js implementation?

The application should own four things: the event schema, the template source, the rendering rules, and the decision to send. The delivery adapter should own HTTP authentication, request serialization, timeout handling, and normalization of the provider response. Express or Next.js can initiate the workflow, but neither framework should know the provider's payload shape.

That split matters because “email sent” has several meanings. The application may have accepted a reset request, generated a single-use link, rendered valid content, handed a request to the transport, or received an acceptance result. Those states shouldn't collapse into one Boolean. A public route can still return the same neutral response for known and unknown accounts, while an internal record moves through explicit states such as requested, rendered, and accepted. For the marketplace example, seller.order.created carries an order reference, seller locale, item count, and a link to the order. For password recovery, account.password_reset.requested carries an opaque reset link and expiry copy. These are different events, but the ownership boundary is identical. A renderer consumes a typed event and returns a subject plus HTML and plain text. A transport consumes that rendered message. This keeps a “new order” wording change from leaking into transport code and keeps a delivery change from touching token generation. The correction to the first design is small but important: a template ID is configuration, while a template contract is code. If the remote template expects reset_url and the application emits resetLink, a successful API request can still produce the wrong message. A compiler cannot inspect a template hidden behind a dashboard. It can inspect a TypeScript function.

Concern Application owns Delivery adapter owns
Content Subject, HTML, plain text, locale Request serialization
Identity Event and message keys Provider message ID
Control Send decision and retry policy Authentication and timeout wiring

How should the renderer connect to an email delivery API?

This is the focused implementation. It is intentionally missing an Express route and a Next.js server action because those are entry points, not the durable boundary.

type EmailAddress = {
  email: string;
  name?: string;
};

type RenderedEmail = {
  from: EmailAddress;
  to: EmailAddress[];
  subject: string;
  html: string;
  text: string;
  messageKey: string;
};

type DeliveryReceipt = {
  providerMessageId: string;
  acceptedAt: string;
};

interface EmailTransport {
  send(message: RenderedEmail, signal: AbortSignal): Promise<DeliveryReceipt>;
}

type PasswordResetInput = {
  recipient: EmailAddress;
  resetUrl: string;
  expiresInMinutes: number;
  requestId: string;
};

function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function renderPasswordReset(input: PasswordResetInput): RenderedEmail {
  const resetUrl = escapeHtml(input.resetUrl);
  const minutes = input.expiresInMinutes;

  return {
    from: { email: "account@example.test", name: "Account team" },
    to: [input.recipient],
    subject: "Reset your password",
    text: `Reset your password: ${input.resetUrl}\nThis link expires in ${minutes} minutes.`,
    html: `<p>Reset your password:</p><p><a href="${resetUrl}">Choose a new password</a></p><p>This link expires in ${minutes} minutes.</p>`,
    messageKey: `password-reset:${input.requestId}`,
  };
}

async function deliverPasswordReset(
  transport: EmailTransport,
  input: PasswordResetInput,
): Promise<DeliveryReceipt> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 4_000);

  try {
    return await transport.send(renderPasswordReset(input), controller.signal);
  } finally {
    clearTimeout(timeout);
  }
}
Enter fullscreen mode Exit fullscreen mode

The example has one deliberate pressure point: EmailTransport returns a narrow receipt rather than the raw API response. An adapter can use fetch and direct HTTP JSON, so there is no SMTP client or relay configuration in the application. It can also change later without forcing the reset workflow to understand a new response envelope. Keep the raw response in restricted operational logs if it is useful, but don't make it part of the domain contract.

messageKey is an application correlation key. It is not a claim that every email API implements idempotency. Before retrying an uncertain request, look up the send attempt by that key and apply the selected transport's documented behavior. A blind retry can create two messages. A permanent “never retry” policy can lose a message after a transient client-side timeout. There isn't a universal answer here — the adapter needs a written rule for each outcome it can actually observe.

I would test the renderer with fixtures, including a long display name, an address without a name, a reset URL containing escaped query parameters, and an expiry value at the allowed boundary. The transport gets contract tests against a fake HTTP server: assert the authorization header is present, the timeout signal is passed, and response fields become the narrow receipt. Then one deployment-level test uses a controlled recipient. That test division is less glamorous than swapping template IDs, but it catches the failures the application can control.

The new-order email fits beside this renderer rather than inside it. Its fixture should use concrete marketplace data — for example, order reference ORD-1842, 3 items, and seller locale en-US — because a generic “hello world” fixture will not expose a pluralization or missing-reference mistake. Those numbers are test data, not delivery measurements.

Ship that boundary.

The failed shortcut is remote template state

The remote-template version usually starts with an appealing call: template ID, recipient, variables, done. Its hidden cost appears during ordinary work. Preview data lives in one place, the event schema in another, and the deployed application has no proof that the two versions agree. Rollback can also require coordinating application code with dashboard history.

Still, local ownership is not automatically correct. Hosted templates are the better choice when a support or operations team must change transactional wording immediately and cannot wait for an engineering deployment. They can also fit a system where the provider's editor, approval workflow, and locale management are already the accepted source of truth. The catch is that this is an organizational choice. Treat it as such: name the owner, define who can publish, preserve change history, and run a compatibility check before new application fields reach production.

Local templates have their own bill. Every locale becomes repository work. Designers may need a preview tool. HTML email behavior is constrained enough that a clean browser preview does not prove a clean inbox rendering. If the marketplace has dozens of seller locales and a content team shipping copy daily, keeping every message behind a code review may become the bottleneck. In that case, stick with a hosted content system, but put a validated schema in front of its variables and keep the transport adapter isolated.

This is why “API-first” doesn't settle template ownership. It only settles the delivery protocol. HTTP is comfortable in serverless and backend code, and it avoids operating an SMTP relay, yet either a local renderer or a hosted renderer can sit behind the API call. The team boundary decides more than the wire format.

Failure handling belongs before the send call

A password reset endpoint should not wait indefinitely on email delivery. It should create the reset attempt, enqueue or invoke delivery according to the application's latency budget, and return a response that does not reveal account existence. The exact queue choice is outside this experiment, but the state transition is not: record enough information to connect one public request, one rendered message, and one delivery attempt without putting the reset secret into logs.

Keep observability boring. Log the event type, internal request ID, template version, transport name, duration, normalized outcome, and provider message ID when one exists. Do not log the full reset URL, rendered HTML, authorization header, or recipient address in general-purpose telemetry. A hash or internal account ID can support correlation with less exposure, provided the team has documented how it is used.

Retries need classification rather than optimism. Validation rejection, authentication rejection, and an aborted client request are not interchangeable. The adapter should map documented responses into a small internal set such as accepted, retryable, rejected, and unknown; the workflow can then cap attempts, add delay, and prevent simultaneous workers from sending the same message key. I'm not sure a generic retry count can be defended across providers, because rate limits and idempotency contracts differ. The missing evidence is the chosen API's current documentation plus measurements from the application's own traffic.

Domain authentication is also outside the template renderer. SPF defines a mechanism for a receiving system to check whether a host is authorized to use a domain in the SMTP MAIL FROM or HELO identity. That does not turn SPF into an application delivery receipt, and it should not be modeled as one. Configure sender authentication as deployment infrastructure, verify it independently, and keep its status visible to operations. The distinction prevents a common category error: an API accepting a message is not the same event as a receiving mailbox presenting it to a user.

SMS fallback deserves a separate policy. A programmable SMS API has different addressing, sender, consent, and delivery concerns from email; it should not be smuggled into EmailTransport as another template type. If recovery policy permits SMS, give it its own transport and audit path, then let a channel-selection layer decide. One interface per channel keeps a seller order notification from accidentally inheriting security recovery rules.

Measure this before copying the choice

Start with outcomes, not vendor dashboards. Measure render failures, transport acceptance latency, rejected requests by normalized reason, duplicate message keys, and the time from reset request to the point your system can observe. For marketplace notifications, split the same measures by event type and locale. That will show whether template ownership is reducing defects or merely moving work into the repository.

Cost still matters to a solo builder, but a per-message quote is only one line in the model. Count engineering time for template releases, preview tooling, incident investigation, localization, and provider migration. Also record the volume at which those costs are evaluated. Without volume and labor assumptions, “cheaper” is just a mood.

Use the local-template, API-first boundary when copy changes can follow application deployment, typed review is valuable, and the application needs transport portability. Do not use it when non-engineers require independent publishing or when a mature hosted localization workflow already owns transactional content. For an Express or Next.js application, keep the framework at the edge, keep the reset secret out of telemetry, and make the renderer-to-transport contract the piece you can test without sending anything.

Then watch production evidence. Change the choice when the ownership cost changes.

Sources

Top comments (0)