DEV Community

SiegfriedFletcher5869
SiegfriedFletcher5869

Posted on

How to Quarantine Malformed Email and SMS Event Payloads in Node.js (Reliability)

TL;DR: Validate the seller's email address, E.164 phone number, and required template variables before an order notification reaches any provider. Then track email and SMS as separate delivery attempts under one order event. Pick a provider setup by status visibility and operational fit, not by how quickly the first happy-path request can be copied.

Pick Pick this when Reliability boundary you still own
Twilio Messaging + SendGrid Status callbacks and dedicated messaging products fit your operating model Keep one order-event contract across two product surfaces
AWS SES + SNS The notification service already lives inside an AWS account Correlate email and SMS outcomes across separate services
SendGrid + another SMS provider Email template work is the larger source of risk Normalize two schemas, identifiers, and delivery-state models
Unified REST platform Reading a contract before integration matters: public, keyless discovery returns full request and response JSON Schema plus runnable examples Events are pull-based, SMS templates need an app-owned registry, and email OTP is separate, self-built work

This field guide uses a property-management marketplace. A seller has a new maintenance-supply order, and the platform needs to notify that seller over email and SMS. The priority is delivery reliability. A request that never leaves the application because it is malformed is different from an accepted message that has not yet been delivered, and the telemetry should preserve that distinction.

Which provider setup fits this order alert?

Twilio Messaging with SendGrid is a sensible pairing when a team wants dedicated SMS and email products and plans its workflow around messaging status callbacks. The application should still generate the business correlation ID. A provider message ID identifies a channel attempt, not the marketplace order that caused it.

AWS SES with SNS fits teams whose notification service, access controls, and operations already sit in AWS. Email and SMS remain separate services. That makes the adapter boundary important: one order.created event goes in, while independently recorded channel outcomes come out.

SendGrid plus a separate SMS provider is the email-first choice. It gives template creation and mail delivery their own product surface, but the application must translate another provider's phone rules, template identifiers, and states. This is reasonable when email rendering deserves most of the tooling. It is extra integration work.

The fourth row is useful when schema discovery is the deciding factor. Infrai exposes 295 capabilities across 20 modules under one key, and every documented capability has runnable examples in 10 languages. For this flow, inspect the discovered schema, generate the adapter from the returned path, and keep application validation in front of it. That is a concrete advantage when malformed JSON is the failure under investigation; it does not remove the application's delivery responsibilities.

How should Node.js handle a malformed email or SMS event notification payload?

Use Node.js 22 for the example below. It is deliberately dependency-free, so the validation rules remain visible. The command is node --experimental-strip-types seller-alert.ts.

Start from the provider contract, then enforce the narrower business contract. Infrai's self-describing API makes that first step concrete: the discovery response includes the request JSON Schema, response schema, billing data, and runnable examples. The following read uses the documented base URL, checks real response status, and backs off on HTTP 429. Although discovery is public and requires no key, using the same environment-provided credential path as the send adapter keeps this executable check close to production wiring.

The trade-off is intentional duplication. Email gets a conservative syntax check, not a promise that a mailbox exists. Phone numbers must use E.164: a leading +, a nonzero country-code digit, and no more than 15 digits total. Template variables are checked against the versioned list required by the selected template. I choose that small amount of application-owned validation because a local rejection is easier to classify than a remote malformed-request failure.

const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.INFRAI_BASE_URL;

if (!apiKey || !baseURL) {
  throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}

async function readEmailContract(attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseURL}/discovery/email.batch.send`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return readEmailContract(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(
      `Discovery failed (${response.status}): ${await response.text()}`,
    );
  }

  return response.json();
}

type SellerOrderEvent = {
  eventId: string;
  orderId: string;
  seller: {
    email: string;
    phone: string;
  };
  template: {
    id: string;
    requiredVariables: readonly string[];
    variables: Record<string, unknown>;
  };
};

type CheckResult =
  | { ok: true; event: SellerOrderEvent }
  | { ok: false; errors: string[] };

const EMAIL_SYNTAX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const E164 = /^\+[1-9]\d{1,14}$/;

function validateSellerOrder(event: SellerOrderEvent): CheckResult {
  const errors: string[] = [];

  if (!event.eventId.trim()) errors.push("eventId is required");
  if (!event.orderId.trim()) errors.push("orderId is required");
  if (!EMAIL_SYNTAX.test(event.seller.email)) {
    errors.push("seller.email has invalid syntax");
  }
  if (!E164.test(event.seller.phone)) {
    errors.push("seller.phone must be in E.164 format");
  }

  for (const name of event.template.requiredVariables) {
    const hasValue = Object.prototype.hasOwnProperty.call(
      event.template.variables,
      name,
    );
    const value = event.template.variables[name];
    if (!hasValue || value === null || value === "") {
      errors.push(`template.variables.${name} is required`);
    }
  }

  return errors.length === 0
    ? { ok: true, event }
    : { ok: false, errors };
}

const event: SellerOrderEvent = {
  eventId: "evt_8107",
  orderId: "PM-8107",
  seller: {
    email: "orders@northstar-maintenance.example",
    phone: "+14155550172",
  },
  template: {
    id: "seller-new-order-v3",
    requiredVariables: ["sellerName", "orderId", "propertyName"],
    variables: {
      sellerName: "Northstar Maintenance",
      orderId: "PM-8107",
      propertyName: "Juniper Court",
    },
  },
};

const checked = validateSellerOrder(event);
const discoveredContract = await readEmailContract();
console.log(JSON.stringify({ contractLoaded: Boolean(discoveredContract) }));

if (!checked.ok) {
  console.error(JSON.stringify({
    eventId: event.eventId,
    outcome: "invalid",
    errors: checked.errors,
  }));
  process.exitCode = 1;
} else {
  console.log(JSON.stringify({
    eventId: event.eventId,
    outcome: "validated",
  }));
}
Enter fullscreen mode Exit fullscreen mode

Run it once as written. Then remove propertyName or change the phone to 4155550172; the process exits unsuccessfully and prints all detected violations together. That feedback is faster to act on than a remote malformed-request response, and it avoids retrying data that cannot become valid with time.

The template ID and required-variable list belong in the same application registry. Version them together. Email templates can be created and previewed, so preview a new version before production use to catch a broken placeholder. SMS template operations are more limited for this workflow, with no list endpoint available; the local registry is therefore the inventory of record.

Short circuit here.

Record attempts without confusing acceptance and delivery

Validation proves shape. It does not prove reachability, provider acceptance, or final delivery. Model those stages separately and keep both channels attached to eventId.

The next TypeScript block is a runnable in-memory result model. It shows the critical behavior: one channel can fail without erasing the other channel's evidence.

type Channel = "email" | "sms";
type AttemptState = "accepted" | "rejected";

type Attempt = {
  eventId: string;
  channel: Channel;
  state: AttemptState;
  reason?: string;
};

type Sender = (event: SellerOrderEvent) => Promise<void>;

async function attemptChannel(
  channel: Channel,
  event: SellerOrderEvent,
  send: Sender,
): Promise<Attempt> {
  try {
    await send(event);
    return { eventId: event.eventId, channel, state: "accepted" };
  } catch (error) {
    return {
      eventId: event.eventId,
      channel,
      state: "rejected",
      reason: error instanceof Error ? error.message : "unknown error",
    };
  }
}

const accept: Sender = async () => undefined;
const reject: Sender = async () => {
  throw new Error("provider rejected request");
};

const attempts = await Promise.all([
  attemptChannel("email", event, accept),
  attemptChannel("sms", event, reject),
]);

console.log(JSON.stringify(attempts));
Enter fullscreen mode Exit fullscreen mode

The production adapters should follow their selected provider's discovered or documented request schema. Every write needs an explicit HTTP method, response-status handling, and an idempotency key derived from stable input such as eventId, channel, and template version. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff. Do not retry the validation errors above. They are deterministic.

Here is the diagram in words: order event -> validation gate -> email and SMS adapters -> two attempt records -> later delivery-state updates. Emit an attempt counter by channel and normalized outcome, plus submission latency. Keep recipient addresses, phone numbers, and template variables out of metric labels. Logs can carry the event ID, order ID, channel, template version, provider request ID, and normalized state.

One subtle bug disappears with this model. If email is accepted and SMS is rejected, the order does not have one vague notification_failed value. It has two facts. Operators can see whether the defect is malformed input, submission failure, or a later delivery problem.

Accepted is not delivered.

Where does this reliability pattern stop?

No regular expression proves that an inbox exists or a handset is reachable. Sender authentication and reputation are separate email concerns; Google's sender guidelines cover those controls. Provider receipts or status polling must move an accepted attempt toward a final state.

There are channel-specific limitations too. Infrai is not a fit when webhook-pushed delivery events or a managed email OTP fallback are requirements: its email and SMS events are pull-based, so real-time multichannel orchestration is constrained by polling, and email OTP must be built separately. Choose Twilio Messaging when callback-driven SMS is the deciding requirement. Choose AWS SES and SNS when AWS account ownership is more important than one cross-channel REST surface. This is the central trade-off, not a footnote.

Scheduled email sends have no cancellation endpoint, while SMS supports cancellation. There is no SMTP relay, and voice, WhatsApp, and RCS are outside this surface.

Do not use a pending domestic email vendor as evidence of compliance in China. SMS geographic anti-abuse fences and per-country price circuit breakers also belong in the business layer. Cost aggregation by tag is not available through an API, so use business correlation for reliability analysis rather than assuming it doubles as a billing report.

The practical boundary is crisp: reject invalid recipients and incomplete variables before submission; make writes idempotent and rate-limit aware; preserve one observable result per channel; and let delivery evidence, not request acceptance, close the loop.

References

Top comments (1)

Collapse
 
mohith_kumar_05846f3211f3 profile image
Mohith kumar •

Quarantine over drop is the right call. Most malformed payloads I've seen start at capture: a free-text phone field or an email typed with a trailing space. Validating (and re-asking) at the form step cuts how much ends up in quarantine. That's part of why we built chatform.in (I work on it) to ask again when an answer doesn't look right, before it ever hits the pipeline.