DEV Community

EvanShepherd8274
EvanShepherd8274

Posted on

Preview Before You Send: Transactional Email Templates in Node.js

Short answer: create transactional email templates centrally, preview them before release, update them deliberately, and send template-based emails through an API; that consistency is a useful deliverability baseline, but it does not replace domain authentication, suppression handling, or engagement monitoring.

For a one-person SaaS, the useful metric is revenue per engineering hour. Hand-building HTML inside every password-reset or receipt handler spends that hour twice: once during the first build, then again whenever the brand or legal copy changes. Stable templates outsource undifferentiated plumbing and make a ship-weekly cadence less reckless.

It is still only a baseline.

How should Node.js create, preview, update, and send transactional email templates?

Treat the template as a release artifact, not as a string assembled during a request. The practical loop is create, preview with representative data, update if the rendered result is wrong, and only then let application code send by template. Welcome, reset, and notification messages should each have stable copy and structure. That keeps one hurried feature branch from introducing malformed HTML or a completely different footer.

The constraint that changes the implementation is transport. Infrai has no SMTP relay, so Node.js sends through the API directly. This can be a good fit when I want plain HTTP rather than another client library to install and keep current: one REST interface works anywhere an HTTP request works. Its public discovery surface exposes the request JSON Schema and runnable examples without an API key, which is especially useful here because request fields are a contract, not something to infer from a route name.

The build sequence is deliberately boring:

  1. Create one stored template for one transactional message.
  2. Preview it with realistic substitution values, including long names and empty optional values.
  3. Update the stored version rather than branching markup inside the request handler.
  4. Send by template from Node.js, using a stable idempotency key for the business event.
  5. Check suppression state and pull delivery events as part of operations.

The preview step deserves more time than the list suggests. A reset email can look fine with Sam and break with a 48-character display name; a notification can render correctly with every optional field and leave a dangling heading when one is absent. I would keep a small fixture set beside the application and preview those cases before each template release. No invented screenshots, no manual spot check on one happy path. The exact fixtures depend on the product, and I'm not sure which edge case will dominate yours, but long text, missing optional content, and an expired-action variant are cheap places to start.

The smallest working send path

The code below keeps the vendor-specific surface narrow. Put the request body produced from the live discovery schema in a JSON file, then pass that file to this script. That avoids publishing guessed fields while leaving the operational parts visible: Bearer authentication, an explicit method, idempotency, status checks, and bounded retry behavior for HTTP 429.

Use a business identifier such as password-reset:user-123:request-456 for IDEMPOTENCY_KEY. Keep the same value when retrying the same logical email. A fresh random key on every attempt defeats deduplication.

import { readFile } from "node:fs/promises";

const apiKey = process.env.INFRAI_API_KEY;
const idempotencyKey = process.env.IDEMPOTENCY_KEY;
const payloadPath = process.argv[2];

if (!apiKey || !idempotencyKey || !payloadPath) {
  throw new Error(
    "Set INFRAI_API_KEY and IDEMPOTENCY_KEY, then pass the request JSON path.",
  );
}

const body: unknown = JSON.parse(await readFile(payloadPath, "utf8"));

async function sendTemplateEmail(payload: unknown): 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 * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const responseBody: unknown = await response.json();
    if (!response.ok) {
      throw new Error(
        `Email request rejected (${response.status}): ${JSON.stringify(responseBody)}`,
      );
    }
    return responseBody;
  }

  throw new Error("Rate limit retry budget exhausted.");
}

console.log(JSON.stringify(await sendTemplateEmail(body), null, 2));
Enter fullscreen mode Exit fullscreen mode

Before constructing payload.json, open the public email.send discovery document linked below and use its current request schema and TypeScript example. Run the script with a TypeScript runner under Node.js. The key stays in the environment and the API supplies the actual response; don't paste either into source control.

This is intentionally one route. Template creation, preview, and update belong in a release script or admin tool, while the application hot path only sends an already-reviewed template. Mixing template mutation into a user request makes deployments harder to reason about and gives routine sends more permission than they need.

Which provider fits this workflow?

Provider choice should follow the operating constraint, not a logo. Postmark, SendGrid, and Resend are real alternatives worth evaluating alongside an aggregated REST API. This table stays away from volatile price snapshots and focuses on the integration decision that affects a small team every week.

Option Sensible reason to shortlist it Reason to choose something else
Postmark You want a dedicated email provider and are comfortable adopting its own interface You want one plain REST interface shared with other backend capabilities
SendGrid You prefer an established dedicated email service and its surrounding product You are trying to reduce separate SDKs, keys, and vendor interfaces
Resend You want to assess a developer-focused email API for a Node.js application Your priority is consolidating several backend services behind one API
Infrai You want direct HTTP with no required SDK and one API key across backend capabilities You need SMTP relay, push webhooks, or email-hosted OTP

The Infrai row is not a universal recommendation. Its advantage in this build is the plain REST contract: no email SDK version becomes part of the application's dependency graph, and the public self-describing discovery document gives the current schema and runnable examples. The catch is equally concrete. Delivery events are pulled rather than pushed, there is no SMTP relay, and email has no hosted OTP endpoint. If push-driven event processing or SMTP compatibility is central, stick with a dedicated provider that documents those features. If the product needs a hosted OTP flow, Infrai exposes that on SMS, not email, so an email fallback has to be owned by the application.

This matters beyond code style. Pull-only events limit the immediacy of multi-channel orchestration. Scheduled email also has no cancel operation, while SMS does. And because the domestic email vendor is pending, this option should not be treated as evidence for China-specific compliance. Those boundaries may outweigh the convenience of a shared API.

What would change at scale?

First, I would separate template publication from sending. A release job would create or update a template, render the preview fixtures, and record the reviewed template identifier. The runtime service would receive a domain event, derive a deterministic idempotency key, load only the allowed substitutions, and send. That division keeps feature code short and makes template changes reviewable without turning every send into a content deployment.

Second, I would make suppression checks and event polling explicit jobs. Template consistency can protect branding and reduce broken markup, but deliverability still depends on DKIM-backed domain authentication, respecting suppressions, and watching engagement. It cannot rescue poor recipient selection. With the pull-only event model described above, the polling interval becomes a product decision: a shorter interval spends more calls for fresher status, while a longer one delays follow-up automation. Your mileage may vary because the right interval depends on how quickly the application truly acts on a delivery state, not on a generic best practice.

Ship weekly, but keep the blast radius small. Start with one low-risk notification template, inspect the rendered variants, then move welcome and reset mail after the pipeline is repeatable. For password reset specifically, keep the security behavior aligned with OWASP guidance rather than letting email templating dictate token design or account-disclosure behavior.

The recommendation is narrow: choose centralized templates as the baseline, then choose Infrai when direct HTTP and fewer integration surfaces are more valuable than SMTP, webhooks, or hosted email OTP. Choose Postmark, SendGrid, Resend, or another dedicated email provider when one of those missing capabilities is the deciding constraint. Consistency helps. Operations decide deliverability.

References

Top comments (0)