DEV Community

UrbanDonovan1576
UrbanDonovan1576

Posted on

Implement Email Event Notifications in Node.js with Example Code: Compliance Evidence

To implement email event notifications in Node.js, use a two-record design for a healthtech compliance notice: record the submission, then reconcile delivery observations separately. A plain REST integration is a good fit when the Node.js service can run a durable poller and needs an auditable history without committing its application to one client library.

Short answer: send the notice with an authenticated POST request, persist the submission before retrying, and use the email event list as a later observation source. A successful send response proves that the application submitted a request. It does not, by itself, prove that a mailbox accepted or displayed the message.

That distinction is the decision. The send path should be short and idempotent. The event path should be a worker that polls, deduplicates, and records when it last checked. Before copying this design, measure event freshness and the age of unresolved notices in your own recipient environment.

That is the audit boundary.

What makes an email notice auditable?

Start with the business fact, not the provider response. Persist a notice ID, case reference, recipient, template revision, and the time the application decided to send. Keep protected health information out of diagnostic logs unless the retention policy explicitly allows it.

Then append delivery facts separately. One row can say submitted; a later row can say that an event was observed. Never replace the original submission with the latest status. The history is the evidence.

type NoticeRecord = {
  noticeId: string;
  caseId: string;
  recipient: string;
  templateVersion: string;
  submittedAt?: string;
  lastEventCheckAt?: string;
  status: "created" | "submitted" | "event_observed" | "failed";
};

type DeliveryObservation = {
  noticeId: string;
  providerEventKey: string;
  observedAt: string;
  providerStatus: string;
};
Enter fullscreen mode Exit fullscreen mode

The application owns noticeId; the provider owns the event information. If an event response supplies a stable event identifier, use it as the deduplication key. If it does not, keep the poll timestamp and raw response under the access and retention rules for the system. Do not invent a delivery guarantee from a missing event.

A useful state model is deliberately small: created to submitted to event_observed. The observed event can carry a provider status such as delivered or bounced, but the transition itself means only that the reconciliation worker saw that status. This wording avoids a common audit failure: presenting a stale dashboard as current because the last successful send happened to return quickly.

How do you implement email event notifications in Node.js?

The send operation needs one verified route, explicit authentication, and a stable idempotency key. The key must be derived from the application attempt, not generated afresh inside every retry. Otherwise a temporary rate limit can turn one compliance notice into several submissions.

The example uses the verified email send route and the required email details. It treats a non-success response as an error, gives the caller the response body, and retries only HTTP 429. The API key stays in the environment; it is never part of source control.

type SendNoticeInput = {
  noticeId: string;
  to: string;
  subject: string;
  body: string;
};

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("Retry-After");
  const seconds = retryAfter === null ? Number.NaN : Number(retryAfter);
  if (Number.isFinite(seconds) && seconds >= 0) {
    return seconds * 1000;
  }
  return Math.min(30_000, 500 * 2 ** attempt);
}

export async function sendNotice(input: SendNoticeInput): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
  if (!apiKey || !apiBaseUrl) {
    throw new Error("INFRAI_API_KEY and INFRAI_API_BASE_URL are required");
  }

  const payload = {
    to: input.to,
    subject: input.subject,
    body: input.body,
  };

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${apiBaseUrl}/email/send`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": input.noticeId,
      },
      body: JSON.stringify(payload),
    });

    const responseText = await response.text();
    if (response.ok) {
      return responseText.length === 0 ? null : JSON.parse(responseText);
    }

    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Email submission failed (${response.status}): ${responseText}`);
    }

    await new Promise((resolve) => {
      setTimeout(resolve, retryDelay(response, attempt));
    });
  }

  throw new Error("Email submission exhausted its retry budget");
}
Enter fullscreen mode Exit fullscreen mode

Persist the local created record before calling this function, and change it to submitted only after the response is accepted. The database update and the job that invokes the send operation need a retry policy of their own. An idempotency key protects the external write; it does not make an unfinished local transaction disappear.

Email also supports a scheduled_at value, but this surface has no email cancellation route. If a case changes after a scheduled notice is accepted, the application needs a supersession rule, such as marking the old notice obsolete and sending a replacement where policy permits. It should not promise that a later cancellation call will retract the message.

Can a polling worker produce timely evidence?

It can support reconciliation, but it should not be described as push delivery. The email event list is read by a worker on a schedule. Both email and SMS event handling in this capability group are pull-oriented, so a multi-channel workflow has to define its polling interval, maximum unresolved age, and escalation path.

The worker should follow a conservative sequence:

  1. Read the next event page or polling window.
  2. Write each unseen observation durably, keyed by its provider event identity when available.
  3. Update the notice projection only after the observation is stored.
  4. Advance the cursor or checkpoint after the batch commits.

If the process dies between steps two and four, the next run may see duplicates. That is acceptable when the store is idempotent. If it dies after advancing a checkpoint but before writing the observations, evidence can be lost. The order matters more than the polling framework.

Small detail, large consequence.

Do not make the request handler wait for the event list. That adds latency to the user-facing operation and still cannot guarantee that a provider event exists at that instant. The send request creates a submission fact; the worker creates observation facts later.

Which delivery model fits the evidence workflow?

The right comparison is not a feature-count contest. It is about where status events arrive and which operational burden the team is prepared to own.

Option Status path Best fit Boundary to plan for
Amazon SES Configuration-set event publishing to destinations such as SNS, SQS, or CloudWatch Teams already operating an AWS event pipeline Correlation, retention, and destination permissions become part of the audit design
SendGrid Event Webhook for delivery and bounce events Teams that can run a secured webhook consumer Signature validation, replay handling, and webhook retention are application responsibilities
Postmark Message streams with webhooks for message activity Focused transactional-email workflows The application still needs to secure and retain webhook observations
A plain REST API Email submission plus pull-based email event listing A Node.js service with a dependable reconciliation worker Status freshness depends on polling, and there is no SMTP relay or managed email OTP interface

The REST option has two concrete advantages for a small team. There is no SDK or client-library version to maintain: any runtime that can send an authenticated HTTP request can use it. Its public discovery surface is self-describing, and the wider platform exposes 295 routes across 20 modules under one key. That is useful when the notice workflow later needs another backend capability, because credential setup stays in one place rather than spreading across separate client libraries.

The second advantage is operational consistency. Documented capabilities include runnable examples in 10 languages, and the platform uses one key and one bill across its capabilities. For this notice workflow, that removes a specific piece of friction: the compliance worker, its later storage integration, and its scheduling integration do not each need a separate credential and billing reconciliation path. They can follow the same discovery and request conventions instead of introducing a new vendor account, authentication shape, and maintenance path. This is a workflow benefit, not proof of better email delivery. The platform's documented idempotency convention also specifies a 24-hour default deduplication window, which gives the application a concrete boundary to test against rather than an informal retry promise.

Infrai is a reasonable option here when the team values that plain REST surface, single-key operations, and a single bill across backend capabilities, and can own the poller; it is not the right choice when push freshness is non-negotiable.

Those advantages do not erase the limitation. SendGrid or Postmark may be the better operational fit when webhook freshness is the requirement. SES may be the better fit when the surrounding evidence pipeline is already AWS-native. A REST integration is a poor fit if the team cannot operate a poller, alert on stale checkpoints, or retain unresolved notices for review.

There are other boundaries to keep visible. Email has no managed OTP interface, so a verification fallback needs application-owned email-code logic. Email has no SMTP relay, and a domestic vendor marked pending cannot serve as a domestic compliance basis. Those are architecture constraints, not details to hide in provider selection.

My decision rule is simple: choose the provider whose event ownership matches the evidence you must produce. I initially treated the send response as the important checkpoint; the better design is to treat it as only the first checkpoint. If an auditor needs a traceable history and the team can run a durable poller, the pull model is workable. If the business needs immediate escalation when a message bounces, a provider-pushed event path removes a meaningful class of delay.

What should be measured before shipping?

Run the flow with synthetic recipients and inspect the stored history, not just the final dashboard. Measure the time from submission to the first observed event, the percentage of notices unresolved after the defined window, and the number of duplicate observations accepted by the store. These measurements belong to the deployment because no universal polling interval or delivery latency is established here.

Also test the unglamorous paths: a 429 response, a malformed request, a worker restart after writing an observation, and a notice whose business data changes after scheduling. I would run four explicit cases before release, because a happy-path send does not exercise retry safety or checkpoint ordering. The test is successful when the audit record can answer four separate questions: what did the application intend to send, when did it submit the request, what status did the worker observe, and when was that status last checked?

Do not guess.

For this healthtech use case, that evidence chain is more valuable than a provider label. The integration is ready when a reviewer can reconstruct it without trusting an overwritten delivered field. Deployment measurements, rather than a generic promise about delivery, determine whether the polling window is acceptable.

References

Top comments (0)