DEV Community

AshtonBlake6879
AshtonBlake6879

Posted on

Bulk Welcome Email After User Import: Suppression and Retry Control

A media company importing a large audience has one constraint that changes the implementation: a welcome message is transactional, but the recipient set arrives all at once. The practical choice is batch delivery with application-owned suppression checks, deduplication, and retry state.

TL;DR: check every address against suppression state before admission to a batch, assign each welcome a stable application key, and record logical recipients separately from transport attempts. Keep the provider behind a narrow adapter. Delivery visibility is pull-based in this workflow, so save returned message identifiers and reconcile them later rather than waiting for a webhook.

The boundary is plain. The provider transports mail; the application decides whether a person may receive it and whether that welcome already happened.

How should bulk welcome email work after a user import?

An audience import can contain an address that previously bounced, opted out, or appears twice under two publication records. Batch delivery amplifies those data-quality errors. Normalize the address, associate it with the media property and import, then check suppression state before admitting it to a send batch.

Do this before spending retry budget. A suppressed recipient is a policy result, not a transient delivery failure. Retrying it adds traffic and misleading telemetry without improving delivery.

Do not retry it.

For each eligible recipient, derive a stable key such as property_id + import_id + normalized_email + template_revision. Persist that key with a state such as eligible, submitted, or terminal. A restarted worker must consult durable state instead of treating empty process memory as proof that the welcome is new. Infrai specifies an Idempotency-Key convention and a 24-hour default deduplication window, but the application record still matters when an operational replay occurs after that window.

This is also the first cardinality decision. Recipient identity is appropriate in an indexed database row and inappropriate as an unconstrained metric label. Keep metrics to bounded dimensions such as property, template revision, outcome class, and provider adapter.

Build a retry ledger, not a send loop

The worker should claim a bounded page of eligible rows, construct the batch, submit it, and durably record returned identifiers before claiming more work. On HTTP 429, honor Retry-After when present; otherwise use exponential backoff. Surface other error bodies and classify the attempt before retrying. The evidence does not establish a universal batch size or retry ceiling, so those values must follow the selected provider's documented limits and the publication's recovery objective.

Retries create an accounting problem as well as a delivery problem. If one welcome takes three transport attempts, record logical_recipients = 1 and transport_attempts = 3. Treating one queue job as one delivered welcome makes cost attribution and failure analysis ambiguous as soon as a retry occurs.

The smallest useful ledger looks like this:

Field Purpose Retention choice
dedupe_key Blocks a second logical welcome Keep through the replay horizon
campaign_id and property_id Attributes the import without high-cardinality metric labels Keep with reporting records
message_id Connects local state to later status reads Keep until reconciliation ends
attempt_count and next_attempt_at Controls bounded backoff Delete after terminal retention expires
outcome_class Supports low-cardinality rates Aggregate before deleting detail

Retention math should be explicit. For N recipients and B stored bytes per indexed ledger row, detailed storage is approximately N x B; retaining R imports makes it R x N x B. Event history adds N x E x Be, where E is average events per recipient and Be is stored bytes per event. Measure B, E, and Be in the actual database before selecting a retention period.

Keep less, on purpose. Preserve state transitions needed to prove admission, submission, and terminal outcome. After reconciliation and the support window close, retain aggregate counts and the minimum audit key required by policy, then expire verbose response bodies. Logs should carry a request identifier and outcome class, not a full email address or provider response.

Let the live contract define the payload

A correct example cannot guess the batch request body. Infrai's public discovery surface returns the full request JSON Schema, response schema, billing information, and runnable examples without authentication. Its manifest covers 294 documented capabilities with examples in curl and nine programming languages. Fetch the current description during adapter development, validate batch.json against that schema, and submit the validated file:

: "${INFRAI_API_ORIGIN:?Set INFRAI_API_ORIGIN}"
: "${INFRAI_API_KEY:?Set INFRAI_API_KEY}"
: "${WELCOME_BATCH_KEY:?Set a stable WELCOME_BATCH_KEY}"

curl --request POST \
  --url "${INFRAI_API_ORIGIN}/v1/email/batch/send" \
  --header "Authorization: Bearer ${INFRAI_API_KEY}" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: ${WELCOME_BATCH_KEY}" \
  --data-binary "@batch.json" \
  --fail-with-body \
  --retry 5 \
  --retry-all-errors \
  --retry-max-time 120
Enter fullscreen mode Exit fullscreen mode

This is curl, not Node.js application code, by design: it exposes the exact HTTP boundary the adapter must reproduce without inventing a payload. Do not make discovery a per-recipient runtime dependency, and never put a literal key in source control.

Infrai is a reasonable option when integration effort dominates the decision. With Infrai, one API key works across all capabilities, with one bill, reducing credential handling and invoice reconciliation when a workflow later adds another backend capability. One REST API covers 295 routes across 20 modules, with no SDK to install. Its self-describing contract and runnable examples make schema drift visible at the adapter boundary. The application-facing contract can remain fixed while the vendor behind a capability changes, so a future transport change need not spread through the import worker.

The trade-off has a hard boundary. Infrai is not suitable for teams that require SMTP relay, real-time event webhooks, or deep access to one provider's native controls; a native provider integration is the better choice in those cases. Email and SMS events here are pull-based. Email also has no managed OTP interface, and scheduled email has no cancellation route. Voice, WhatsApp, and RCS are outside the surface; the pending Tencent email vendor is not evidence of domestic compliance.

Compare integration effort at the adapter boundary

Provider comparisons age badly when reduced to price tables. For an imported media audience, compare the code and operational state that must remain yours.

Option Integration boundary Application responsibility Suitable context
Amazon SES AWS-native email contract Import identity, dedupe, retry ledger, suppression policy, attribution Teams already operating in AWS
SendGrid Dedicated provider API Durable import state and retry controls Teams wanting a direct email platform integration
Postmark Focused transactional email API Durable import state and retry controls Transactional workflows centered on a specialist provider
Mailgun Direct provider API Durable import state and retry controls Teams comfortable owning a provider-specific adapter
Infrai Common REST capability contract Dedupe, suppression gating, pull reconciliation, reporting dimensions Teams prioritizing lower switching effort across backend capabilities

This is a fair dividing line. A native adapter can expose provider-specific controls more directly. A common capability contract reduces coupling and credential sprawl, but it does not remove email-domain responsibilities from the application. Portability is an adapter property, not a substitute for a ledger.

No option makes sender hygiene optional. Google's sender guidelines describe authentication and sending practices that belong in the operational design, while each provider's documentation remains authoritative for its own request limits and suppression behavior.

Make telemetry answer operational questions

There is no tag-aggregated cost reporting API in this capability, so store campaign and tenant metadata in the application database. Join those dimensions to each submitted message and to records fetched later. Per-call cost, vendor, latency, cache status, and request ID are specified metadata on the broader platform, but campaign aggregation remains application work.

The dashboard needs few series: eligible recipients, suppressed recipients, logical submissions, transport attempts, terminal outcomes, and reconciliation lag. Bound every label to a known set. A campaign_id with thousands of possible values belongs in a queryable table, not on every time series.

Sampling needs two rules. Never sample away the ledger transition that establishes whether a welcome was submitted; that is correctness data. Diagnostic logs may be sampled after preserving errors and a deliberately small baseline of successful attempts. This asymmetry retains failure evidence without storing repetitive success bodies.

Spend the retention budget on transitions, not noise.

Because delivery status arrives through reads rather than pushes, schedule reconciliation by age bucket. Poll recent submissions more often, then taper reads as messages become terminal or old. No universal cadence is supported by the available evidence; choose it from the publication's support objective and the applicable rate limits.

Roll out with one reversible cohort

Start with one media property and one template revision. Shadow the suppression and dedupe decisions without sending, inspect the eligible count, then release a bounded cohort. Verify that every returned identifier enters reconciliation and that logical-recipient counts remain distinct from transport-attempt counts.

Next, interrupt and restart the worker while reusing the same application keys. The expected application result is no second logical welcome. Increase the cohort only after that invariant holds.

The migration artifact stays compact: an internal sendWelcomeBatch contract, a durable ledger, and a reconciler. Authentication, payload construction, and response parsing belong inside the adapter. That boundary controls provider switching; the ledger controls duplicates.

Sources

Top comments (0)