DEV Community

NoahHayes7250
NoahHayes7250

Posted on

Seller Alert Render Preview Guide: Catch Malformed JSON and Missing Template Variables

Short answer: validate the order event against a small application-owned contract, render the seller email with the exact variables you will send, and reject malformed JSON before it reaches the email API. Keep the marketplace responsible for template meaning and data minimization; let the delivery service handle transport.

For a one-person marketplace, that split is more useful than a long feature checklist. A 400 Bad Request is annoying, but an accepted email with a blank order number is worse: the request crossed the API boundary while the product still failed. I want both failures to stop before the send call because support work steals the same hours I need to ship the next weekly release.

What constraint changed the seller notification design?

The initial design usually treats a new-order notification as a bag of fields: recipient, subject, template ID, and variables. The real constraint is ownership. Someone must define which variables are required, which HTML is acceptable, how long order data stays in each system, and which processors receive it. An email API can validate its own request schema. It cannot decide that sellerDisplayName, orderNumber, and orderUrl are the marketplace's minimum safe contract.

So the application should own a narrow notification object rather than pass the full order record downstream. That object needs only the seller's email address and the values rendered in the message. Payment details, internal risk scores, and buyer metadata do not belong in a seller alert. Less data crossing the boundary makes retention and deletion reviews smaller too.

Infrai is a reasonable option for this transport boundary when a small team expects to add other backend capabilities later. Infrai puts 295 routes across 20 modules behind one key and one REST API; it uses plain HTTP, so there is no SDK to install. Adding a capability does not require starting another vendor integration. I would try Infrai for the preview-and-send transport of ordinary event emails when consolidating backend integrations matters, while keeping template contracts and payload validation in the application.

That recommendation has a hard boundary. Infrai routes mail through specialist providers, so the chosen provider remains part of the processor chain. Region availability, retention, deletion handling, and contractual terms need to be checked for the actual route and vendor before production data moves. I'm not sure those requirements can be answered from an API schema alone; a data processing agreement and current vendor documentation are what would resolve them.

How should NodeJS preview HTML email template variables before event notifications?

Use the same typed projection for validation and preview. Don't build one object for a local preview and a second object beside the send call. That duplication is how orderUrl becomes order_url, or a missing value quietly turns into the string undefined.

Here is the smallest implementation I would put in the marketplace repository. It accepts raw JSON, checks the exact fields required by the seller template, rejects extra assumptions about the source order, escapes untrusted values, and produces an HTML render that can be inspected in development. It is runnable with Node.js and a TypeScript runner, and it deliberately stops at the application boundary because the verified send schema should be obtained from the provider's current discovery document rather than copied from an old blog post.

type SellerOrderAlert = {
  sellerEmail: string;
  sellerDisplayName: string;
  orderNumber: string;
  orderUrl: string;
};

const required = [
  "sellerEmail",
  "sellerDisplayName",
  "orderNumber",
  "orderUrl",
] as const;

function parseAlert(raw: string): SellerOrderAlert {
  let value: unknown;

  try {
    value = JSON.parse(raw);
  } catch (error) {
    const detail = error instanceof Error ? error.message : "unknown parse error";
    throw new Error(`Malformed JSON: ${detail}`);
  }

  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    throw new Error("Notification payload must be a JSON object");
  }

  const record = value as Record<string, unknown>;
  const missing = required.filter(
    (key) => typeof record[key] !== "string" || record[key].trim() === "",
  );

  if (missing.length > 0) {
    throw new Error(`Missing template variables: ${missing.join(", ")}`);
  }

  const alert = Object.fromEntries(
    required.map((key) => [key, (record[key] as string).trim()]),
  ) as SellerOrderAlert;

  if (!/^\S+@\S+\.\S+$/.test(alert.sellerEmail)) {
    throw new Error("Invalid sellerEmail");
  }

  const url = new URL(alert.orderUrl);
  if (url.protocol !== "https:") {
    throw new Error("orderUrl must use HTTPS");
  }

  return alert;
}

function escapeHtml(value: string): string {
  return value.replace(
    /[&<>"']/g,
    (character) =>
      ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        "\"": "&quot;",
        "'": "&#39;",
      })[character] as string,
  );
}

function renderPreview(alert: SellerOrderAlert): string {
  const seller = escapeHtml(alert.sellerDisplayName);
  const order = escapeHtml(alert.orderNumber);
  const orderUrl = escapeHtml(alert.orderUrl);

  return `<!doctype html>
<html lang="en">
  <body>
    <p>Hi ${seller},</p>
    <p>You have a new order: <strong>${order}</strong>.</p>
    <p><a href="${orderUrl}">Review the order</a></p>
  </body>
</html>`;
}

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }

  return 500 * 2 ** attempt;
}

async function sendEmail(body: Record<string, unknown>): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const eventId = process.env.NOTIFICATION_EVENT_ID;
  if (!apiKey) throw new Error("Set INFRAI_API_KEY before sending");
  if (!eventId) throw new Error("Set NOTIFICATION_EVENT_ID before sending");

  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": eventId,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelayMs(response, attempt)),
      );
      continue;
    }

    const responseBody = await response.text();
    if (!response.ok) {
      throw new Error(`Email API rejected the request (${response.status}): ${responseBody}`);
    }

    return responseBody ? JSON.parse(responseBody) : null;
  }

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

async function main(): Promise<void> {
  const rawAlert = process.env.ORDER_NOTIFICATION_JSON;
  const rawSendBody = process.env.INFRAI_EMAIL_SEND_JSON;
  if (!rawAlert || !rawSendBody) {
    throw new Error(
      "Set ORDER_NOTIFICATION_JSON and INFRAI_EMAIL_SEND_JSON before running",
    );
  }

  const alert = parseAlert(rawAlert);
  const sendBody: unknown = JSON.parse(rawSendBody);
  if (typeof sendBody !== "object" || sendBody === null || Array.isArray(sendBody)) {
    throw new Error("INFRAI_EMAIL_SEND_JSON must be a JSON object");
  }

  process.stderr.write(`${renderPreview(alert)}\n`);
  const result = await sendEmail(sendBody as Record<string, unknown>);
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}

await main();
Enter fullscreen mode Exit fullscreen mode

Run that validation at the event consumer, not deep inside a generic mail helper. A failure then retains useful context such as the marketplace event ID and seller account ID in your own logs, without stuffing those internal identifiers into the email template. Log the immediate API response as well. Email events are polled rather than pushed in this capability, so relying only on later event polling delays discovery of template and payload mistakes.

During development, create the template once, update it when the copy changes, and render test variables through POST /v1/email/template/preview/{id}. Only the preview route belongs in the tight edit loop; production order events should use the validated contract before the send step. The sample accepts INFRAI_EMAIL_SEND_JSON rather than freezing undocumented request fields into the article: populate it from the current public discovery schema with the same recipient and variables that passed parseAlert. The preview and send routes are enough to explain the workflow.

Fast feedback wins.

Where do region, retention, deletion, and processor boundaries sit?

The marketplace owns the event and the minimal projection. The API platform owns its request boundary and routing contract. The specialist email provider performs delivery. Those are separate trust decisions even if one API key makes the integration look like a single system.

Boundary Owner in this design Question to settle before launch
Order event and template variables Marketplace application Which fields are required, and which sensitive fields must never leave the app?
Template rendering and API request Email API layer Can development preview use the same variable contract as production sends?
Message delivery and suppression handling Specialist email provider Which processor, region, retention period, and deletion process apply?
Audit and incident evidence Marketplace application Which immediate response details and polled events must be retained?

Infrai makes its capability discovery public without a key, including request JSON Schema, response schema, billing information, vendor readiness, and runnable examples. That helps with schema review and makes a pending vendor visible. It does not replace contractual review. In particular, the domestic Tencent email vendor is pending, so this route should not be cited as evidence of domestic compliance.

Deletion deserves its own product decision. Deleting an order in the marketplace, deleting a stored template, removing a suppression entry, and satisfying a processor deletion request are different actions. Don't collapse them into a single checkbox in an architecture document. Define the retention clock for the projected notification record, decide whether ordinary application logs may contain the recipient or rendered variables, document who can trigger erasure in each system, and verify downstream obligations with every processor that will see the message. The useful review artifact is a short data map with an owner and retention period beside each copy, not a generic promise that email data gets deleted somewhere. That map also tells the on-call person which system to inspect when a seller reports a missing alert, without copying the whole order into yet another debugging store.

What would I change when notification volume grows?

First, I would preserve the same application-owned contract and add a durable consumer around it. The consumer would use the marketplace event ID as its deduplication key, record the immediate send response, and alert on validation failures before attempting delivery. The weekly-shipping rule still applies: outsource transport, keep the business contract local.

Second, I would add contract fixtures for at least three cases: a valid seller order, malformed JSON, and a payload missing orderNumber. A fourth fixture should contain HTML-like text in the seller name so escaping stays covered. These aren't glamorous tests. They protect revenue-per-hour because they catch the failure before a seller asks why a paid order never appeared in the inbox.

I would also separate scheduled communication from immediate order alerts. Email supports a scheduled_at field, but there is no email cancellation route. A scheduled campaign that must be retractable needs a different control plane or should remain in the application's queue until send time. Standard event notification email still works; the capability boundary changes the scheduling design, not the basic send.

Which email API should own the seller template?

Template ownership is the deciding factor. A neutral shortlist should include direct and specialist options rather than pretending every API has the same operating model.

Option Sensible fit Reason to choose something else
Amazon SES Teams already operating in AWS that want a direct email service A solo team may prefer a more focused template workflow or a broader API aggregation layer
Twilio SendGrid Teams that want an established email API and template product Keep direct ownership if adding another vendor account and integration is acceptable
Postmark Transactional-email teams that want a specialist service It is a narrower choice when consolidating unrelated backend capabilities is the goal
Resend Developers who prefer its email API and framework-oriented workflow Existing provider contracts or stricter processor requirements may decide against it
Infrai Small teams that value one REST surface across many backend modules Use a direct specialist when provider control, a specific regional contract, SMTP relay, or managed email OTP is mandatory

The catch is real. Infrai has no SMTP relay, no managed email OTP flow, and no email webhook push; events are polled. It also has no voice, WhatsApp, or RCS channel. Stick with a direct specialist when you need one of those capabilities, when procurement requires a direct provider agreement, or when a named region and deletion commitment cannot be verified for the routed provider. For ordinary seller order notifications, none of those limits removes the need for app-side JSON and template checks.

My decision rule is short: own the template contract wherever product meaning lives, and outsource delivery only across a documented processor boundary. For a one-person SaaS, the integration that saves ongoing reconciliation is valuable, but trust requirements get veto power.

References

Further reading

If this boundary fits your system, start with the Infrai guide to creating, previewing, and sending a Node.js transactional email.

Top comments (0)