DEV Community

PeterParker8991
PeterParker8991

Posted on

Transactional Email Deliverability: Custom Domain SPF, DKIM, DMARC, and Bounce Handling

Short answer: for a Node.js email deliverability setup, keep compliance-notice templates in your application, authenticate a custom sending domain with SPF, DKIM, and DMARC, and put every send behind a suppression check plus a delivery-event audit trail.

For a one-person property-management SaaS, that is the least complex setup that still leaves an auditable record. The email provider transports the message. The application owns what was sent, why it was sent, and what happened next.

Choice Template owner Audit record Best fit
Application-rendered Your code and repository Rendered content, template version, recipient, and provider message ID Compliance notices that must be reproducible
Provider-hosted Provider dashboard or API Template ID plus provider events Marketing or operations teams editing copy outside deployments
Hybrid Shared between both Requires version mapping across two systems Teams with a real review workflow on both sides

My recommendation is application-rendered templates for compliance notices. Ship the wording with the feature, version it in the same review, and store the rendered subject and body before calling the transactional email API. The catch is that this choice isn't suitable when non-engineers must change templates every day; use provider-hosted templates then, and archive an immutable rendered copy with each send.

The cost of owning compliance-notice templates

Domain and template ownership decide whether an old notice can be reconstructed months later. A provider template ID alone is weak evidence if that template can be edited in place. The application should create a notice record first, assign a stable template version, render the exact content, and only then enqueue delivery. That sequence makes the business event independent of a later provider retry or template edit.

This is also a revenue-per-hour decision. A solo operator shouldn't build a drag-and-drop editor merely to avoid committing a few TypeScript templates. Ship weekly. Outsource transport, bounce classification, and mailbox handoff, but keep the part that expresses a legal or contractual obligation close to the domain model.

Keep it boring.

The longer answer is that application ownership carries a maintenance cost. Escaping, plain-text alternatives, localization, and review become your job. Provider-hosted templates can be the better runner-up when a property manager needs same-day copy changes without a deployment, when the organization already has a formal approval flow in the provider, or when a large template library would turn the application repository into a content-management system. A hybrid can work, but only if the audit record maps the application's logical version to the provider's immutable version; otherwise it creates two sources of truth and saves no time.

Failure checks after custom domain verification

Domain verification and message authentication are related, but they aren't one checkbox. SPF publishes which infrastructure may send for a domain. DKIM adds a cryptographic signature that a receiver can validate against a public key in DNS; RFC 6376 defines the signing and verification model. DMARC publishes a domain policy and aligns authentication with the domain visible to the recipient.

Treat setup as a state machine: unconfigured, pending_dns, verified, or failed_review. Store the records you expect, ask the provider to verify them, and poll until the provider reports a terminal state. Don't unlock production sending merely because someone clicked “Verify.” The application should read the resulting status and record when it changed.

DNS makes timing uncertain — your mileage may vary by resolver and existing cache state — so deployment should tolerate pending_dns without repeatedly creating new domains or records. A sensible operator path shows the exact expected records, the last verification result, and a retry action. It does not guess that propagation has finished.

Authentication is necessary, not sufficient. The visible From address, reply handling, recipient consent, message content, list hygiene, and delivery events still belong in the operating model. For a compliance notice, use a stable transactional identity and keep promotional traffic out of the same workflow. That boundary makes failures easier to reason about and keeps an urgent notice from sharing retry logic with a campaign.

How should a Node.js custom-domain email deliverability setup handle failures?

Never call the send adapter before checking suppression. A hard bounce or complaint event should add the normalized address to a local suppression projection, and later jobs should stop before transport. Keep the provider's event identifier so replayed events are idempotent. If the provider offers event callbacks, consume them; polling is still useful as a reconciliation path when the application needs to verify that its local audit trail matches the provider's record.

No send. No exception.

The important split is between the notice and the attempt. A notice says the business required a communication: identify the property and recipient, freeze the template version, archive the rendered subject and bodies, and record the event that made the notice necessary. An attempt is narrower. It says a particular transport accepted that frozen payload at a particular time and returned a message identifier. One notice may have multiple attempts, but every retry must point back to the same archived content and must consult suppression again immediately before transport. Otherwise a retry can silently send revised wording, lose the relationship between the compliance event and its evidence, or contact an address that complained after the first attempt. Keeping these records separate also avoids treating provider acceptance as proof of delivery.

Use neutral states in your own model, such as queued, accepted, delivered, bounced, complained, and suppressed. Map provider-specific events at the adapter boundary. I'm not sure any fixed polling interval is universally right; resolve that from the compliance deadline, expected volume, provider limits, and how quickly an operator must react. The invariant matters more than the timer: polling must be cursor-based, idempotent, and safe to resume.

An API implementation model for an auditable delivery record

The example below deliberately avoids a vendor route. The interfaces are the boundary; a provider adapter can use an SDK or plain HTTP without leaking its vocabulary into the property-management model.

type DeliveryState =
  | "queued"
  | "accepted"
  | "delivered"
  | "bounced"
  | "complained"
  | "suppressed";

type ComplianceNotice = {
  id: string;
  propertyId: string;
  recipient: string;
  templateVersion: string;
  subject: string;
  html: string;
  text: string;
  createdAt: string;
};

type DeliveryAttempt = {
  noticeId: string;
  providerMessageId?: string;
  state: DeliveryState;
  recordedAt: string;
};

interface SuppressionStore {
  has(address: string): Promise<boolean>;
}

interface AuditStore {
  saveNotice(notice: ComplianceNotice): Promise<void>;
  appendAttempt(attempt: DeliveryAttempt): Promise<void>;
}

interface MailTransport {
  send(message: {
    from: string;
    to: string;
    subject: string;
    html: string;
    text: string;
  }): Promise<{ messageId: string }>;
}

async function sendComplianceNotice(
  notice: ComplianceNotice,
  suppression: SuppressionStore,
  audit: AuditStore,
  transport: MailTransport,
): Promise<void> {
  await audit.saveNotice(notice);

  if (await suppression.has(notice.recipient.toLowerCase())) {
    await audit.appendAttempt({
      noticeId: notice.id,
      state: "suppressed",
      recordedAt: new Date().toISOString(),
    });
    return;
  }

  await audit.appendAttempt({
    noticeId: notice.id,
    state: "queued",
    recordedAt: new Date().toISOString(),
  });

  const result = await transport.send({
    from: "notices@example-property.com",
    to: notice.recipient,
    subject: notice.subject,
    html: notice.html,
    text: notice.text,
  });

  await audit.appendAttempt({
    noticeId: notice.id,
    providerMessageId: result.messageId,
    state: "accepted",
    recordedAt: new Date().toISOString(),
  });
}
Enter fullscreen mode Exit fullscreen mode

Acceptance is not delivery. Update the attempt only from authenticated delivery events or a reconciled status read, retain the raw event separately, and reject duplicate event IDs. A bounce or complaint should update both the attempt and suppression projection in one idempotent operation. If those writes can't share a transaction, persist the event first and process it through a retryable queue.

Test the failure boundaries, not just the happy send. A useful suite proves that a suppressed recipient never reaches transport.send, the archived body doesn't change across retries, duplicate events don't create duplicate transitions, and an unknown provider event remains available for later mapping. During deployment, run domain verification before enabling the sender, seed no production suppressions from test data, and expose counts for pending notices, suppressed sends, event-processing lag, and unmapped events.

The audit record should answer one plain question without opening a provider dashboard: what exact notice did this tenant receive, through which attempt, and with what final delivery state?

Template rollout and migration boundaries

Stick with provider-hosted templates when copy ownership genuinely sits with an operations team and deployment latency would block required edits. Require immutable versions or archive every rendered message in your application. Choose the hybrid only when separate engineering and content approvals justify the mapping work.

No model removes operational ownership. Before launch, verify the custom domain, exercise SPF, DKIM, and DMARC checks in a controlled environment, test bounce and complaint ingestion, confirm suppression before retry, and rehearse polling from an old cursor. The winning design is the one a small team can explain and audit while still shipping the next feature.

Further reading

Top comments (0)