DEV Community

leiferiksson8493
leiferiksson8493

Posted on

Build Node.js Alert Mail: Custom-Domain DKIM, Templates, and Deliverability

Short answer: put a durable event outbox between the SaaS and its email transport, verify a custom sending domain with DKIM before release, render versioned templates in Node.js, and treat retries, bounces, and complaints as state transitions rather than log messages.

The deciding constraint is not how quickly one message can leave a function. It is whether an alert can be retried after a crash without sending twice, traced back to the business event that caused it, and stopped when the recipient should no longer receive mail. For a small SaaS that ships weekly, that boring control plane protects far more revenue-per-hour than a clever mail abstraction.

Keep it small.

How should a Node.js SaaS set up custom-domain DKIM for event alert emails?

Start with identity. Use a dedicated sending subdomain, publish the DNS records required by the chosen transport, and make successful verification a production release gate. DKIM gives receiving systems a cryptographic way to associate a signed message with a domain. Google also tells senders to authenticate mail, keep valid forward and reverse DNS records, use TLS, format messages according to the Internet Message Format standard, and keep spam rates low. Its published requirements get stricter for senders delivering more than 5,000 messages a day to Gmail accounts.

A subdomain is an operational boundary, not a deliverability trick. It lets the team assign ownership and change DNS without mixing application mail with employee mail. The exact DNS values and verification process come from the transport, so they belong in deployment configuration, not source code. Don't let the app claim a domain is ready merely because somebody pasted records into a dashboard; the release check should read verified state from the transport or from a deployment record produced after verification.

Template setup belongs behind the same gate. Give every alert type a stable name and version, such as invoice_payment_failed:v3, then define the exact variables that version accepts. Render both HTML and plain text. Reject missing variables before enqueueing. A template preview should use synthetic data, because copying a production recipient or reset link into a test message creates a separate class of risk.

The long paragraph here is deliberate because these controls fail together in practice: if a domain is unverified, a perfect template cannot establish the intended sender identity; if a template accepts arbitrary fields, a verified domain can still distribute malformed or sensitive content; and if deployment can switch templates without recording a version, support cannot later reconstruct what a customer received. One release record should therefore bind the verified sending identity, template version, and application revision. That is enough evidence to answer the useful question after an alert: what did this code intend to send, through which identity, and why?

The smallest working event-alert path

The application transaction should write the business change and an outbox row together. A worker claims that row, renders a known template, calls a generic transport adapter, and records the provider message ID. The unique key is the important bit: one event, one recipient, one alert type. A process restart can repeat work, so the database must make the send decision idempotent before the external call.

type AlertJob = {
  eventId: string;
  recipientId: string;
  to: string;
  kind: "invoice_payment_failed" | "report_ready";
  templateVersion: 3;
  variables: Record<string, string>;
};

type RenderedMail = {
  from: string;
  to: string;
  subject: string;
  html: string;
  text: string;
};

interface MailTransport {
  send(message: RenderedMail, idempotencyKey: string): Promise<{ messageId: string }>;
}

async function deliverAlert(job: AlertJob, transport: MailTransport): Promise<void> {
  const key = `${job.eventId}:${job.recipientId}:${job.kind}`;
  const claimed = await alerts.claimOnce(key);
  if (!claimed) return;

  if (await recipients.isSuppressed(job.recipientId)) {
    await alerts.markSuppressed(key);
    return;
  }

  const message = templates.render(job.kind, job.templateVersion, job.variables);

  try {
    const sent = await transport.send(message, key);
    await alerts.markSubmitted(key, sent.messageId);
  } catch (error) {
    await alerts.releaseForRetry(key, retryDelay(error));
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally an interface, not a vendor SDK tutorial. Plain HTTP or an SDK can sit behind it. The differentiated code is the event vocabulary, suppression policy, and audit trail; outsource the undifferentiated transport work. Ship the narrow path first.

One caveat needs to be explicit: a local claim does not magically make a remote send exactly once. The worker can lose its connection after the transport accepts a message but before markSubmitted commits. Prefer a transport that accepts an idempotency key and documents its behavior. If it does not, reconcile uncertain attempts before retrying, or accept that duplicate delivery remains possible and make the alert content safe to receive twice.

Retries should be bounded and classified. A rate limit or temporary network failure can go back to the queue with delay; an invalid recipient should not. Store attempts, the next eligible time, and a normalized outcome, but avoid putting full message bodies or sensitive template variables into routine logs. I'm not sure what retry window is right for every alert. The business deadline resolves that uncertainty: an account-security notice and a weekly report should not inherit the same expiry merely because they share a queue.

Deliverability is a feedback loop, not an open-rate chart

Accepted is not delivered, and delivered is not read. Feed delivery results back into recipient state, suppress addresses after the policy says to stop, and alert on changes in bounce or complaint patterns. Google recommends monitoring reputation and spam reports through Postmaster Tools; it also says senders should keep the user-reported spam rate below 0.1% and avoid reaching 0.3% or higher. Those are externally published thresholds, not a promise that staying below them guarantees inbox placement.

Open tracking is a poor control signal. Apple Mail Privacy Protection can prevent senders from seeing whether a recipient opened a message and masks the recipient's IP address. Product analytics should therefore measure the action behind the email, such as a signed-in user viewing the report, rather than treating a tracking pixel as proof that a person read the alert.

For each alert type, watch queue age, submission attempts, bounce classifications, complaints, suppressions, and the product action that follows. Review samples of rendered output on mobile and desktop, including long names, missing optional values, and expired links. Test the plain-text part too. These checks are less exciting than template design, but they catch the failures that cost support time.

No single transport fixes weak consent, stale addresses, misleading content, or an unsafe retry loop.

What would change at scale?

At higher volume, split workers by urgency, partition the outbox, automate domain-health checks, and route delivery events through their own durable consumer. Add per-tenant and per-alert-type rate limits so one noisy integration cannot consume the whole sending budget. Keep the transport adapter narrow enough to test a second route without rewriting business events, but do not build multi-provider routing before there is a measured reliability or regional requirement. Every extra route multiplies template previews, suppression synchronization, incident playbooks, and reconciliation work.

The catch is that this architecture is not suitable for a tiny internal tool sending a handful of low-stakes notices; a database outbox, delivery-event consumer, and template registry may cost more engineering time than they save. A managed transactional-email workflow with its hosted templates can be the better choice there. At the other extreme, stick with a specialized high-volume architecture when dedicated IP management, complex reputation segmentation, or strict regional routing is a hard requirement. Provider choice should follow those constraints, plus documented authentication, idempotency, event delivery, suppression, data residency, support, and total operating effort.

For the common SaaS middle ground, the recommendation stays plain: own the event and recipient state, verify the sending identity, and keep transport replaceable. That gives a weekly shipping cadence somewhere safe to stand without turning email into the product.

References

Top comments (0)