DEV Community

evanshepherd5623
evanshepherd5623

Posted on

Email API Debugging: Fix Malformed JSON, Missing Variables, and HTML Preview in Node.js

Short answer: validate the payload before transport, render the template with a strict variable contract, and preview the exact HTML string that the email API will receive. A 400 usually means the server could not parse or validate your request, so logging the outgoing JSON (with secrets removed) is more useful than staring at the provider dashboard.

The mental model is simple. Before the fix, an event handler builds an object, interpolates a template, and hands everything to an HTTP client in one opaque step. After the fix, those are three observable boundaries: schema validation, template rendering, and serialization. Each boundary gets a test and a useful error message. Delivery reliability improves because a bad reset event is rejected before it becomes a retry storm. I keep the event ID attached to all three steps, so one support ticket can be traced from intake to provider response without guessing which payload was sent.

Start with the fixture.

Reliability starts before the network call

JSON syntax and message semantics are separate checks. A trailing comma, an unescaped quote in an HTML fragment, or a value accidentally passed as undefined can produce invalid JSON. Valid JSON can still be rejected when a required field is absent, a content type is wrong, or a template variable is empty where the API requires a string.

Keep the event data typed and the transport object boring. In Node.js, JSON.stringify should be the only serializer, and the request should declare Content-Type: application/json.

type PasswordResetEvent = {
  userId: string;
  email: string;
  resetUrl: string;
  expiresAt: string;
};

function buildEmailRequest(event: PasswordResetEvent) {
  if (!event.email || !event.resetUrl || !event.expiresAt) {
    throw new Error("password-reset event is missing a required value");
  }

  const html = renderResetHtml(event);
  const payload = {
    to: [{ email: event.email }],
    subject: "Reset your password",
    content: [{ type: "text/html", value: html }],
  };

  return {
    body: JSON.stringify(payload),
    headers: { "content-type": "application/json" },
  };
}
Enter fullscreen mode Exit fullscreen mode

That explicit check catches a missing variable at the event boundary, where you still know which event failed. It also avoids logging a full tokenized reset URL. Redact query strings and authorization headers in diagnostic output; a preview log should never become a credential leak.

How can Node.js keep malformed JSON, missing variables, and HTML preview in sync?

Treat a template as a function, not a bag of optional substitutions. A strict renderer can fail fast and still produce a safe preview for a human reviewer.

function requireValue(name: string, value: string | undefined): string {
  if (typeof value !== "string" || value.length === 0) {
    throw new Error(`template variable ${name} is missing`);
  }
  return value;
}

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

function renderResetHtml(event: PasswordResetEvent): string {
  const url = escapeHtml(requireValue("resetUrl", event.resetUrl));
  const expiry = escapeHtml(requireValue("expiresAt", event.expiresAt));
  return `<p>Reset your password using <a href="${url}">this link</a>.</p>` +
    `<p>The link expires at ${expiry}.</p>`;
}
Enter fullscreen mode Exit fullscreen mode

The preview should be generated from the same html variable passed to payload. That sounds obvious. It is also where many systems drift: a preview route renders with sample data while the sender uses production data, or one path applies escaping and the other does not. Save a short-lived preview artifact keyed by an event ID, then inspect its rendered markup and its serialized request body side by side.

For a 400, capture the status, response body, request ID, and a redacted payload hash. Do not automatically retry parsing failures. Retries make sense for a transient network failure; they cannot repair malformed JSON or a missing variable.

Measure the contract with fixtures and telemetry

Test the boundaries independently, then run one end-to-end fixture for a password-reset event with a short expiry. The fixture should include an ampersand in the display name, a quote in a support note, and a URL containing query parameters. Those characters expose escaping mistakes quickly.

Check Failure caught Useful assertion
Schema validation Missing recipient or expiry Error names the event field
Template render Missing variable or unsafe HTML No undefined; dynamic text is escaped
Serialization Malformed JSON JSON.parse(JSON.stringify(payload)) succeeds
Contract test API field/type mismatch Fixture matches the documented request shape
Preview parity Preview differs from sent HTML Preview uses the exact rendered string
Delivery telemetry Silent drops and duplicate sends Event ID, attempt, status, and latency are recorded

One practical trap: a message can pass JSON parsing and still fail downstream because its HTML contains an invalid link or because the declared content type does not match the body. Keep a plain-text alternative, too. Some support agents and customers read mail in clients that strip HTML.

The matrix is also a debugging map. Suppose the API returns 400, but the local JSON.parse check passes. First compare the redacted payload hash with the preview artifact. Then inspect field names and content types against the API contract. Finally replay the exact body against a test endpoint. This sequence narrows the fault without changing three variables at once, and it leaves an audit trail that another engineer can follow during an on-call handoff.

Choose the boundary your team can maintain

Strict, synchronous validation is a poor fit for a bulk campaign pipeline where partial acceptance and provider-side templating are intentional. In that case, quarantine invalid events, report them to a dead-letter stream, and let the campaign workflow continue. A password-reset message is different: rejecting one event is preferable to sending a broken or non-expiring link.

The catch is operational overhead. You need a schema version, a fixture set, and redaction rules that the whole team follows. Teams that cannot maintain those contracts may prefer a managed template system with provider-side validation, accepting less control over rendering and portability. Your mileage may vary; the right choice depends on who owns the templates and how quickly a rollback must happen.

Keep the HTTP client replaceable. Standards-based requests make it possible to switch transport implementations without rewriting event logic, while logs and metrics retain the same event ID and outcome vocabulary. Review the API's documented limits before shipping, especially for retries, payload size, and recipient formatting.

References

Top comments (0)