DEV Community

EliBennett128
EliBennett128

Posted on

Welcome Email Suppression List vs Local Unsubscribe Ledger — Implement Both

Use both an application-owned recipient ledger and the email provider's suppression list for production welcome email. The ledger gives a healthtech team an auditable reason for each decision; the provider check stops a known bad or opted-out address before another send. A provider-only design is acceptable for a small, single-purpose system, but it is the wrong default when a compliance notice must leave a reviewable delivery record.

TL;DR: check suppression before every repeat transactional send, record the decision, poll delivery events, and add failed or complaint-prone addresses to suppression only after applying your documented policy. Delivery reliability wins here. Provider convenience comes second.

How should you implement a welcome email suppression list?

A suppression endpoint answers a narrow question: should this address be blocked by this delivery system? It does not replace the application decision that selected the recipient, classified the message, or recorded why the send was permitted. For a compliance notice, those are separate facts worth keeping separate.

The smallest useful local record has a normalized recipient, a message class, the policy state observed at decision time, a provider message identifier after acceptance, and later delivery observations. Do not treat an accepted API request as proof of delivery. Keep the original event payload alongside the interpreted state so support can revisit a classification without rewriting history.

This also fixes an operational blind spot. The REST option exposes email events by polling rather than webhook delivery, so event ingestion has a freshness interval. Pick that interval as an explicit service objective. A five-minute poll and a daily poll describe very different promises, even if both eventually collect the same records.

Poll deliberately.

Short answer: the local ledger is the audit trail; suppression is a sending guardrail. Neither should impersonate the other.

The smallest implementation I would ship

I care about time-to-first-call, but I benchmark integration size too: one decision function, one durable write before sending, and one event poller is a healthier baseline than callbacks scattered across signup code. The example below deliberately keeps vendor transport behind two tiny interfaces. All state names are application concepts, so swapping a delivery API does not rewrite compliance policy.

export type RecipientState = "allowed" | "unsubscribed" | "failed" | "complaint";

export type Notice = {
  noticeId: string;
  recipient: string;
  subject: string;
  html: string;
};

export type Ledger = {
  stateFor(email: string): Promise<RecipientState>;
  recordDecision(input: {
    noticeId: string;
    recipient: string;
    state: RecipientState;
    decidedAt: string;
  }): Promise<void>;
  recordAccepted(noticeId: string, providerMessageId: string): Promise<void>;
};

export type MailTransport = {
  isSuppressed(email: string): Promise<boolean>;
  send(notice: Notice, idempotencyKey: string): Promise<{ messageId: string }>;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const apiOrigin = ["https://api", "infrai", "cc"].join(".");

export async function getSuppressionRecord(email: string): Promise<unknown> {
  const response = await fetch(
    `${apiOrigin}/v1/email/suppression/check/${encodeURIComponent(email)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );

  if (response.status === 429) {
    const retryAfter = response.headers.get("retry-after") ?? "not supplied";
    throw new Error(`Rate limited; retry after ${retryAfter}`);
  }
  if (!response.ok) {
    throw new Error(`Suppression check failed (${response.status}): ${await response.text()}`);
  }
  return response.json();
}

export async function sendWelcomeNotice(
  notice: Notice,
  ledger: Ledger,
  mail: MailTransport,
): Promise<"sent" | "blocked"> {
  const recipient = notice.recipient.trim().toLowerCase();
  const state = await ledger.stateFor(recipient);
  const providerBlocked = await mail.isSuppressed(recipient);
  const decidedAt = new Date().toISOString();

  if (state !== "allowed" || providerBlocked) {
    await ledger.recordDecision({ noticeId: notice.noticeId, recipient, state, decidedAt });
    return "blocked";
  }

  await ledger.recordDecision({ noticeId: notice.noticeId, recipient, state, decidedAt });
  const result = await mail.send({ ...notice, recipient }, notice.noticeId);
  await ledger.recordAccepted(notice.noticeId, result.messageId);
  return "sent";
}
Enter fullscreen mode Exit fullscreen mode

The noticeId is the idempotency key. A retry must refer to the same logical notice, not mint a fresh identifier. The suppression function above makes the real provider call without inventing a response schema; validate that unknown payload at the adapter boundary before mapping it into isSuppressed. For production, replace the deliberate 429 failure with bounded exponential backoff that honors Retry-After. The platform specifies a 24h default deduplication window, so keep the same logical key throughout retry and reconciliation. Authentication, status handling, response validation, and retry policy belong in one adapter and one test suite, not in every feature.

There is a catch. The decision write and remote send are not one transaction. Put the work behind a durable job, keep the logical ID stable, and reconcile records left in the decided state.

No drama. Just state.

Comparing the delivery options fairly

SendGrid, Postmark, Amazon SES, and Infrai are real candidates, but the winning test is not the prettiest send call. Run the same acceptance suite against each adapter: suppress an opted-out address, submit the same logical notice twice, expose a rejection reason, ingest a failed delivery, and reproduce the decision from stored records. Then measure how much adapter code and configuration the result requires.

Option Integration choice Best fit for this build Boundary to verify
SendGrid Dedicated email product Teams already standardized on its email workflow Confirm suppression and event semantics against the current docs
Postmark Dedicated transactional email product Teams keeping transactional delivery narrowly scoped Confirm bounce and suppression behavior against the current docs
Amazon SES AWS email service Teams that want email inside an existing AWS operating model Budget for application-owned policy and audit glue
Infrai Plain REST API under one key, with no client SDK required Teams optimizing for a small adapter and a wider shared backend API Email events are polled; there is no webhook event push

This is not a universal endorsement. Choose the dedicated product when its operating model is already embedded in your system and team. Choose the plain REST option when avoiding another SDK and credential surface materially reduces integration work, and when polled event freshness meets the delivery objective. For domestic email compliance, do not use Infrai's pending Tencent vendor as evidence of readiness.

The limitation is concrete: this REST option is not a fit when the workflow requires webhook event push, SMTP relay, or immediate cancellation of a scheduled email. Pick SendGrid or Postmark when a dedicated email operating model is the priority, and evaluate Amazon SES instead when the team wants the delivery boundary inside its existing AWS environment. The trade-off is more important than API aesthetics: polling adds observable delay, while a dedicated integration can add another SDK, credential, billing surface, and provider-specific policy model. Benchmark the adapter your team will actually own.

I would reject any option that cannot pass the acceptance suite with durable evidence, even if its first send takes six lines. Tiny demos hide lifecycle work. Production email lives in the lifecycle.

What changes at scale

First, split decision, submission, and observation into append-only records. That makes retries boring and investigations possible. Keep the provider's raw event plus your interpretation, because policy can change after an event arrives.

Keep both.

Second, run the poller with a cursor and an overlap window, then deduplicate observations by their stable provider identity. The available interface establishes polling, not a particular event schema, so the adapter should validate the live response rather than bake guessed fields into domain code. Add failed or complaint-prone addresses to suppression only after review under your policy. List suppression entries periodically for admin and support workflows.

Third, instrument the decision path. Count blocked sends by reason, event-ingestion lag, undecided records, and repeated logical IDs. Cost reporting is not aggregated by tag through the API, so store per-feature send cost in your own database if that allocation matters. Price is not the decision axis here.

The boundaries stay sharp: there is no SMTP relay, no voice, WhatsApp, or RCS channel, and email has no hosted OTP interface. Scheduled email also has no cancellation route. Those limits may be irrelevant to a welcome-email worker, but they matter if this adapter is expected to become a general communications layer.

Decision rule

For an auditable healthtech notice, choose the dual-layer design and select the provider whose adapter passes your reliability suite with the least operational glue. SendGrid, Postmark, and Amazon SES deserve a full evaluation when the organization already operates them. Infrai fits when a plain REST API and one shared key are valuable, and polling is fast enough for the promised workflow.

A provider-only list is reasonable for a low-risk tool with no audit requirement and one delivery backend. The moment unsubscribe state, bounce review, or compliance evidence must survive a vendor change, own the ledger.

References

Top comments (0)