← Complete Projects

阅读中文版本

Build an Outbound Email Queue Service

A complete email queue: job persistence, provider rate limiting, bounce webhook processing, suppression lists, and retry-only-transient tests.

intermediate~5 hemailqueuenodepostgresqldeliverability

Project goal

Queue outbound email jobs, send them within the provider rate limit, suppress addresses that bounced, and never retry a permanent rejection.

Final features

  • Job model
  • Provider rate limit
  • Response classification
  • Bounce webhook
  • Suppression check

Architecture

Jobs are persisted with a status and attempt count. A worker pulls due jobs, enforces a provider token bucket, sends through the provider API, and classifies the response as transient or permanent. Bounce and complaint webhooks update a suppression table.

Prerequisites

  • Node.js 20 LTS or later
  • PostgreSQL 15+
  • A transactional email provider with an API key

Dependencies

  • pg ^8.12.0 — Job and suppression storage
  • undici ^6.19.0 — Provider HTTP calls

Directory structure

email-queue/
├── src/worker.ts
├── src/provider.ts
├── src/webhooks/bounce.ts
├── src/suppression.ts
├── tests/queue.test.ts
└── package.json

Phases

1. Job model

Persist to, subject, body, status, attempts, and provider message id.

{ to, subject, body, status: 'queued', attempts: 0, messageId: null }

Expected behavior: A durable job survives a worker restart mid-send.

2. Provider rate limit

Consume a token before each send and pause when empty.

if (!bucket.take()) { await sleep(refillMs); continue; }

Expected behavior: Providers reject bursts and may throttle the account.

3. Response classification

Map 4xx to permanent and 429/5xx to transient.

if (res.status >= 500 || res.status === 429) retry(job); else fail(job);

Expected behavior: Retrying a permanent failure wastes quota and hides bugs.

4. Bounce webhook

Verify the signature and add hard-bounced addresses to suppression.

await suppression.add(event.email, event.type);

Expected behavior: Sending to suppressed addresses harms deliverability.

5. Suppression check

Skip any job whose recipient is suppressed.

if (await suppression.has(job.to)) return markSkipped(job);

Expected behavior: The suppression list must be enforced at send time, not only at enqueue.

Testing

Assert that a 5xx response retries with backoff, that a 422 is not retried, and that a job to a suppressed address is skipped without a provider call.

Troubleshooting

A permanent bounce is retried forever

The classifier treated all non-2xx as transient; inspect the status code and error type.

Provider quota exhausted

Workers ran above the configured concurrency or ignored the token bucket.

Deployment

Run workers as replicas and cap concurrency below the provider limit. Keep a dead-letter state for jobs that exhaust attempts and surface it for support.

Known limitations

  • No attachment handling.
  • Single provider; no failover provider.
  • The example does not render templates, only sends prepared bodies.

Project overview

Sending email looks trivial until a provider rate-limits you, a hard bounce spoils your reputation, or a retry delivers the same message three times. This build treats deliverability as a queue problem rather than a fire-and-forget call.

You will persist email jobs with an attempt count, pull them within a provider rate limit, send through an API, classify each response as transient or permanent, and update a suppression list from bounce and complaint webhooks.

The classification step is where most implementations go wrong: retrying a permanent rejection wastes provider quota and can look like abuse, while dropping a transient failure loses a message a user was told was sent.

The queue is the source of truth for delivery state, so an operator can answer what happened to a specific message without digging through provider dashboards.

Each phase adds one guarantee: a job model, a rate limit, response classification, bounce handling, and suppression, so you always have a sender that behaves correctly at the stage you stopped.

The project matters most to teams that send transactional mail, where a lost password reset or an unsent receipt is a real support cost. It treats each send as a durable job with a state machine rather than as a function call that either worked or did not.

What you will build

The service provides five capabilities, each with a test that uses a fake provider:

  • Job model
  • Provider rate limit
  • Response classification
  • Bounce webhook
  • Suppression check

A job model persists intent and attempt history; a provider rate limit keeps sends within plan; response classification decides retry versus stop; bounce webhooks feed a suppression list; and the suppression check prevents sending to an address that will only bounce.

The design assumes the provider will fail in ways that are sometimes temporary and sometimes final, and it refuses to guess. Every response is classified explicitly, and the classification drives the retry decision.

Architecture and data model

Jobs are persisted with a status and attempt count. A worker pulls due jobs, enforces a provider token bucket, sends through the provider API, and classifies the response as transient or permanent. Bounce and complaint webhooks update a suppression table.

A job row stores the recipient, the rendered content, a status, and an attempt count. A worker pulls due jobs, waits on a provider token bucket, sends, and writes the classification back to the row.

Transient responses reschedule with backoff; permanent responses stop and mark the job failed. Suppression is a separate table keyed by address, so a bounce received from a webhook immediately affects future sends.

The token bucket is shared across workers so the aggregate send rate stays under the provider limit regardless of worker count. A per-worker limit multiplied by replica count is the classic way to exceed a plan.

Bounce and complaint webhooks update the suppression table independently of the send path, which means suppression reflects provider reality asynchronously without blocking delivery.

The status field is a small state machine: queued, sending, sent, failed, or suppressed. Recording each transition rather than only the final state is what makes a stuck job diagnosable instead of mysterious.

Because the rate limit and the job state live outside the worker, a worker can be replaced at any time without losing queued mail, which is the property that separates a queue from an in-process retry loop.

Prerequisites and dependencies

You need a database for jobs, a provider API with credentials, and a publicly reachable endpoint for bounce webhooks.

  • Node.js 20 LTS or later
  • PostgreSQL 15+
  • A transactional email provider with an API key

The dependencies cover job storage, provider calls, and webhook verification:

  • pg (^8.12.0) — Job and suppression storage
  • undici (^6.19.0) — Provider HTTP calls
pnpm init
pnpm add pg undici
pnpm add -D typescript tsx vitest

Store the provider key in a secret manager and validate it at boot. Keep the rate limit and the suppression policy in configuration so a plan change or a compliance decision is a config edit.

Directory structure

Separate the job model, rate limiting, response classification, webhook handling, and the suppression check so each is testable in isolation:

email-queue/
├── src/worker.ts
├── src/provider.ts
├── src/webhooks/bounce.ts
├── src/suppression.ts
├── tests/queue.test.ts
└── package.json

Phase 1: Job model

Persist to, subject, body, status, attempts, and provider message id.

{ to, subject, body, status: 'queued', attempts: 0, messageId: null }

A durable job survives a worker restart mid-send.

Model a job with enough state to be resumable: recipient, content, status, attempts, and the last provider response. A job that stores only the content cannot explain its own delivery history.

Phase 2: Provider rate limit

Consume a token before each send and pause when empty.

if (!bucket.take()) { await sleep(refillMs); continue; }

Providers reject bursts and may throttle the account.

Enforce the provider rate limit with a shared token bucket rather than a per-worker sleep. A sleep scales linearly with workers and silently multiplies your send rate the moment you add a replica.

Phase 3: Response classification

Map 4xx to permanent and 429/5xx to transient.

if (res.status >= 500 || res.status === 429) retry(job); else fail(job);

Retrying a permanent failure wastes quota and hides bugs.

Classify every response into transient or permanent and act on the classification. A 429 or a 5xx retries with backoff, while a hard reject or an invalid address is not retried at all.

Phase 4: Bounce webhook

Verify the signature and add hard-bounced addresses to suppression.

await suppression.add(event.email, event.type);

Sending to suppressed addresses harms deliverability.

Handle bounce and complaint webhooks by writing to the suppression table, and verify the webhook signature before trusting it. An unverified webhook is an open endpoint that can suppress any address.

Phase 5: Suppression check

Skip any job whose recipient is suppressed.

if (await suppression.has(job.to)) return markSkipped(job);

The suppression list must be enforced at send time, not only at enqueue.

Check suppression before sending and skip silently, recording the skip as a state rather than an error. Sending to a suppressed address wastes quota and harms reputation for no benefit.

Testing

Assert that a 5xx response retries with backoff, that a 422 is not retried, and that a job to a suppressed address is skipped without a provider call.

Assert that a 5xx response schedules a retry with backoff, then assert that a 422 is not retried and is marked failed. Both cases are the reason the classification exists.

Assert that a job to a suppressed address is skipped without any provider call, which is the only way to prove suppression is enforced on the send path rather than merely recorded.

Verify webhook signature checking by posting an unsigned bounce and asserting the suppression is unchanged, because this is the difference between a compliance feature and an open relay.

Strip or reject header-injection characters in any address or subject derived from user input, because an email header is a control surface as much as a display surface.

Run two workers against a shared rate limit and assert the aggregate sends stay within the configured ceiling, since per-worker limits hide a multiplied rate until production.

Assert that the attempt count increments on each retry and that the recorded error changes with the provider response, so the job history reflects reality rather than a generic failure message.

Use a fake provider that can return a chosen status on each call, so retries, permanent failures, and suppression are all exercised without touching a real mailbox.

pnpm vitest run

Deployment and operations

Run workers as replicas and cap concurrency below the provider limit. Keep a dead-letter state for jobs that exhaust attempts and surface it for support.

Run workers as replicas and cap concurrency below the provider limit, with the shared token bucket as the actual ceiling. A provider that throttles you is telling you the bucket is wrong, not the workers.

Keep a dead-letter state for jobs that exhaust their attempts, and surface it for support rather than dropping it. A message silently lost is indistinguishable from a message never queued.

Keep suppression data under the same retention policy as the messages, and honour unsubscribe and complaint signals promptly, because these are both a compliance obligation and a deliverability one.

Treat provider status pages and error codes as inputs to configuration, since a changed error taxonomy is the most common reason classification drifts and retries start behaving oddly.

Set worker concurrency from the provider plan divided by an average send latency, then confirm it against the observed bucket saturation, because the arithmetic that looks safe is often wrong by a factor of two.

Keep the webhook route separate from the public API so its signature checks and rate limits can be reasoned about on their own, and reject payloads beyond a size cap before parsing.

Security considerations

Verify webhook signatures, never log full message bodies, and keep the provider key in a secret store. Strip tracking pixels unless the product explicitly opts in.

Verify every webhook signature and timestamp before acting on a bounce, because an unauthenticated endpoint that suppresses addresses is a denial-of-delivery tool.

Keep provider credentials in a secret manager and scope them to sending only, so a compromised worker cannot read the account or change the plan.

Never log the full message body or recipient addresses beyond the retention policy, since an email queue is otherwise a searchable archive of correspondence.

Rate-limit the webhook endpoint and reject payloads beyond a size cap, so the suppression path cannot itself be used as an amplification target.

Observability

Track queue depth, send latency, bounce rate, and suppression growth. Alert on a bounce spike, which signals list quality or a misconfigured domain.

Track sends, retries, and failures by classification, because the retry-to-failure ratio is the clearest signal that a provider or an address source changed behavior.

Chart suppression table growth and the bounce rate, since a rising bounce rate costs reputation and usually points at a stale or purchased address list.

Monitor token bucket saturation; sustained saturation means jobs are waiting on the provider limit and the send SLA is at risk.

Log the provider response code with each attempt so classification decisions are auditable after the fact, which matters when a provider changes what a code means.

Emit a counter per provider response code, because a new code appearing in the stream is the earliest sign that a provider changed its API behavior.

Track the age of the oldest queued job, because a rising value while sends still succeed means the token bucket is the constraint and the provider plan needs revisiting.

Troubleshooting

A permanent bounce is retried forever. The classifier treated all non-2xx as transient; inspect the status code and error type.

Provider quota exhausted. Workers ran above the configured concurrency or ignored the token bucket.

If the same message is delivered twice, check whether the job was marked complete before the send was acknowledged; a retry after an unacknowledged send is the usual cause.

If sends stop entirely, check suppression first and the token bucket second, because a mass suppression from a bad webhook looks exactly like a provider outage from the inside.

If a legitimate message is stuck in a sending state, the worker likely died mid-send; a job left in a non-terminal state past its timeout should be reclaimable rather than waiting forever.

Limitations and next steps

  • No attachment handling.
  • Single provider; no failover provider.
  • The example does not render templates, only sends prepared bodies.

The service sends text and HTML rendered from a template but has no template authoring or preview UI, so content changes are code changes.

Suppression is per address rather than per domain or per campaign, so a single bounce suppresses that address everywhere rather than for the relevant stream only.

There is no A/B testing or scheduling for a future send window; jobs send as soon as the rate limit allows.

Provider failover is absent, so a single provider outage stops delivery rather than shifting to a backup, which is a deliberate simplification for a single-provider build.

The service does not verify addresses at signup, so the suppression list grows from real bounces rather than being seeded by verification, which is adequate for a small list but costly at scale, and there is no quiet-hours window for recipients in other time zones.

There is no per-recipient timezone-aware send window, so jobs send as soon as the rate limit allows, which a production notification system usually needs.