DEV Community

HumphreyFox1243
HumphreyFox1243

Posted on

Step-by-Step Email Event Notification APIs in Node.js with Report Template Ownership

Short answer: keep the report template with the team that owns the report, and treat email events as an evidence stream rather than as the delivery operation itself. The Node.js service should create one immutable report version, submit one message with a stable idempotency key, and record provider-neutral events against that message. This keeps a template edit from becoming a routing change, and it makes a missing attachment diagnosable.

The useful mental model is a small pipeline. This is the first step in a practical integration:

report version -> message intent -> email submission -> event evidence

Before, one request often does all four jobs. A timeout then leaves an awkward question: did the API reject the email, or did it accept it before the response disappeared? After, the report job owns the artifact, the notification job owns delivery state, and an event consumer closes the loop. That separation matters more than the choice of email API.

Who should own the email template?

The report owner should own the template when the template expresses report meaning: column labels, explanations, date windows, or attachment names. A platform team can own transport policy, authentication, suppression handling, and event retention. Those are different responsibilities.

I use a deliberately boring rule: if a change needs a product or data decision, it belongs beside the report code; if it needs a delivery or security decision, it belongs beside the mail adapter. The boundary is visible in the interface below. The report code supplies content and a filename. The adapter supplies transport details.

type ReportArtifact = {
  reportId: string;
  revision: number;
  filename: string;
  contentType: "application/pdf";
  bytes: Buffer;
};

type MessageIntent = {
  idempotencyKey: string;
  to: string;
  subject: string;
  text: string;
  attachment: ReportArtifact;
};

interface EmailGateway {
  submit(intent: MessageIntent): Promise<{ messageId: string }>;
}
Enter fullscreen mode Exit fullscreen mode

Do not render a fresh report while retrying delivery. Store or address an immutable revision first. Otherwise, a retry sent at 09:02 can contain different data from the first attempt at 09:00 while carrying the same human-facing subject. That is a support ticket waiting to happen.

This approach has a boundary. A tiny internal tool that sends ten manual reports a month may not need an event consumer or a durable job. The extra records and alerts add maintenance. That is the trade-off: stronger evidence costs storage, worker code, and on-call surface. This design is not a fit for a low-volume, human-verified workflow; choose the simpler direct send there. Use the full split when reports are scheduled, sensitive, or operationally important. Keep the simpler path when a person can verify every send, because an elaborate delivery ledger would cost more attention than the problem deserves.

Choose the boundary deliberately.

How should you integrate email event notification APIs in Node.js?

Use this step-by-step shape: create the report revision, build a message intent, integrate the intent with the email API adapter, then consume notification events. Start with the message intent, not a provider-specific request body. The intent needs a stable key derived from the report revision and recipient. It also needs a bounded attachment size check before it reaches the network. The exact limit is an operational policy, so make it configuration, test it, and expose the rejection reason as a metric.

const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;

async function sendReport(
  gateway: EmailGateway,
  artifact: ReportArtifact,
  recipient: string,
): Promise<string> {
  if (artifact.bytes.byteLength > MAX_ATTACHMENT_BYTES) {
    throw new Error("report attachment exceeds the configured limit");
  }

  const intent: MessageIntent = {
    idempotencyKey: `${artifact.reportId}:${artifact.revision}:${recipient}`,
    to: recipient,
    subject: `Report ${artifact.reportId} revision ${artifact.revision}`,
    text: "The requested report is attached.",
    attachment: artifact,
  };

  const result = await gateway.submit(intent);
  return result.messageId;
}
Enter fullscreen mode Exit fullscreen mode

An idempotency key is not a magic guarantee. It is a correlation handle. Your adapter must define what happens when the same key arrives twice: return the original message ID, reject the duplicate, or create a second message. Document that behavior and test it. A timeout after submission is the dangerous path, because the caller cannot know whether the remote system committed the message.

An event notification API also deserves a narrow adapter. Normalize incoming payloads into your own record with messageId, event type, observed time, and the raw event identifier. Keep the raw payload for investigation, but do not make downstream logic depend on a vendor-shaped field buried three objects deep.

type DeliveryEvent = {
  eventId: string;
  messageId: string;
  type: "accepted" | "delivered" | "bounced" | "complained";
  observedAt: string;
};

type EventStore = {
  has(eventId: string): Promise<boolean>;
  insert(event: DeliveryEvent): Promise<void>;
};

async function recordEvent(store: EventStore, event: DeliveryEvent) {
  if (await store.has(event.eventId)) return;
  await store.insert(event);
}
Enter fullscreen mode Exit fullscreen mode

That early duplicate check is useful, but the database still needs a unique constraint on eventId. Two workers can pass the check at the same time. The constraint is the final arbiter; a duplicate-key result should be treated as an already-recorded event, not as an outage.

The event API is evidence, not a receipt for human understanding.

That distinction is easy to lose.

Which signals prove that the attachment arrived?

An accepted event proves that the submission passed an initial boundary. It does not prove that a recipient opened the report, and a delivered event does not prove that the attachment was interpreted correctly. Keep those claims separate in dashboards and support language.

The minimum useful observability set is small: submission attempts and outcomes, event ingestion lag, duplicate event count, permanent bounce count, and a correlation from every report revision to its message ID. I also log the attachment filename and byte count, never the attachment contents. A report can contain sensitive data; operational visibility should not turn logs into a second copy of it.

The alert should follow the failure a person can act on. An elevated ingestion lag points to the consumer path. A bounce rate points to recipient or address quality. A high submission timeout rate points to the boundary between your service and the gateway. One giant “email failed” counter hides all three.

What are the two hard objections?

“Why not let the report service call the email API and stop there?” Because the synchronous response is only one observation. It is a good place to persist the message ID and mark submission status, but it cannot replace later events. Put delivery into a queue or durable job when the report request must remain responsive, then make the job retryable and the intent idempotent.

“Why not let the platform team own every template?” Because that makes a domain change a platform release, and it encourages a generic template with weak report context. Centralize the transport contract and review rules instead. Let the report team version the content and test the attachment against the report schema.

This design is intentionally plain. It gives each team a clear decision, makes retries safe enough to reason about, and leaves a trail from a report revision to delivery evidence. When a user says “my report never arrived,” you can inspect that trail without guessing which version was generated or which worker sent it.

References

Top comments (0)