Skip to content
Architecture

Switch email providers without losing track of messages

Rehearse a provider cutover and rollback while queued and uncertain messages keep their original identity.

THE SHORT ANSWER

Route each new logical email to one provider and persist that choice with the operation. Verify the new sender configuration and import suppressions before moving traffic. Keep queued and uncertain work attached to its original provider; rolling back new traffic does not recall or safely duplicate an earlier submission.

Before you start #

For
Developers building and operating application email.
Bring
Node.js 22+, npm, PostgreSQL 17+, and a dedicated loopback _test database. Both providers in the lab are simulated.
Scope
The download runs locally with synthetic data. All email delivery is simulated; no provider credentials or live sends are needed.

Inventory what actually needs to move #

List sending identities, template versions, credential scopes, suppressions, webhook endpoints, pending jobs, and unresolved operations. Include the app and environment that own each item. A successful request to the new API does not establish that the rest of the migration is ready.

Verify the new provider’s domain records before moving traffic. Keep the old credentials and event receiver available while old operations can still generate useful evidence, subject to your access and retention policies. Plan credential retirement explicitly rather than deleting the old integration immediately.

Import suppressions before cutover and keep them synchronized during the transition. Moving providers should not turn an opted-out or invalid recipient back into an eligible destination. The local lab checks suppression both when work is enqueued and immediately before it is claimed.

Run the migration and rollback experiment #

Extract the ZIP and run npm ci. Create a separate stampwing_guides_test PostgreSQL database and configure GUIDE_DATABASE_URL using the README. The lab refuses remote hosts and non-test database names, ignores DATABASE_URL, and creates a unique schema for each run.

Run npm test and npm run demo. Provider A and provider B return deliberately different synthetic response formats. The dispatcher maps them into accepted, rejected, or uncertain states without making any network calls.

The experiment queues an old job on A, moves new traffic to B, produces an uncertain B attempt, then rolls new traffic back to A. Its recorded results are local simulation evidence; they are not a benchmark or a claim about a real provider.

Run from the extracted example foldersh
npm ci
createdb -h 127.0.0.1 stampwing_guides_test
export GUIDE_DATABASE_URL="postgresql://YOUR_LOCAL_USER@127.0.0.1:5432/stampwing_guides_test"
npm test
npm run demo

Read the key part of the example #

This excerpt comes from lab.mjs, lines 29–72, in the download. It shows the central decision; run the complete package with the commands above.

Local simulation · lab.mjsjavascript
export async function enqueue(
  pool,
  operation,
  recipient,
  templateVersion = "receipt-v1",
  content = "Synthetic order confirmation.",
) {
  if (
    typeof operation !== "string" ||
    typeof recipient !== "string" ||
    recipient.length > 254 ||
    !/^[a-zA-Z0-9_-]{1,100}$/.test(operation) ||
    !/^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(recipient)
  )
    throw Error("Invalid operation or recipient");
  if (
    typeof templateVersion !== "string" ||
    !/^[a-zA-Z0-9_-]{1,100}$/.test(templateVersion) ||
    typeof content !== "string" ||
    !content.trim() ||
    content.length > 4000
  )
    throw Error("Invalid template version or content");
  const normalized = recipient.toLowerCase(),
    bucket =
      createHash("sha256").update(operation).digest().readUInt32BE(0) % 100;
  await pool.query(
    `INSERT INTO jobs(operation,recipient,provider,template_version,content)
 SELECT $1,$2,CASE WHEN $3 < percent_b THEN 'b' ELSE 'a' END,$4,$5 FROM routing
 WHERE NOT EXISTS(SELECT 1 FROM suppressions WHERE recipient=$2) ON CONFLICT DO NOTHING`,
    [operation, normalized, bucket, templateVersion, content],
  );
  const row = (
    await pool.query("SELECT * FROM jobs WHERE operation=$1", [operation])
  ).rows[0];
  if (row && row.recipient !== normalized)
    throw Error("Operation already belongs to a different recipient");
  if (
    row &&
    (row.template_version !== templateVersion || row.content !== content)
  )
    throw Error("Operation already belongs to different content or template");
  return row || null;
}

Find the files you’ll change #

Pinned dependencies: pg 8.23.1. Install with npm ci so the lockfile controls the resolved versions.

FilePurpose
lab.mjsThe example behavior shown in this article.
test.mjsAcceptance checks and synthetic failure cases.
demo.mjs / expected-output.jsonA repeatable local experiment and its recorded result.
README.mdSetup commands, expected behavior and production boundaries.
BUILD-BRIEF.mdThe coding-agent brief below.
package.json / package-lock.jsonPinned dependencies and runnable commands.
LICENSEMIT license for adapting this example.

Compare the recorded local result #

Captured from this package’s demo command on October 5, 2026. These results use synthetic fixtures and simulated delivery; they do not measure a live provider or inbox placement.

Recorded local simulation · expected-output.jsonjson
{
  "simulated": true,
  "jobs": [
    {
      "operation": "new-job",
      "provider": "b",
      "state": "uncertain"
    },
    {
      "operation": "old-job",
      "provider": "a",
      "state": "accepted"
    },
    {
      "operation": "rollback-job",
      "provider": "a",
      "state": "accepted"
    }
  ],
  "nextDispatch": "nothing pending"
}

Persist the routing decision once #

Store the chosen provider when the logical operation is created. A subsequent retry of that operation reads the existing row rather than running today’s routing rule again. The lab rejects reuse of an operation key with a different recipient.

For gradual cutover, hash the stable operation ID into a cohort and compare it with the configured percentage. This makes the choice repeatable. A 50 percent threshold is not a promise that a small batch will contain exactly half its rows on each provider.

Use separate fields for your logical operation ID and each provider’s message ID. The formats may differ, and they serve different reconciliation purposes. Keep template versions and immutable content tied to the same operation in your production implementation.

Do not turn uncertainty into cross-provider failover #

A timeout from A might occur after A accepted the request. Sending through B immediately can create a duplicate even if B uses the same-looking idempotency key: provider-specific deduplication does not automatically cross that boundary.

The lab claims a job by recording uncertainty before invoking the simulated provider. Only explicit evidence advances it to accepted or rejected. A process restart keeps the uncertain row intact. That conservative choice can require manual reconciliation even when no submission actually occurred.

Production recovery should consult the original provider’s operation records and supported idempotency behavior. Record any manual decision to retry as a deliberate action with evidence. Do not interpret a missing webhook alone as proof of failure.

Know what rollback changes #

OperationAfter routing rolls back to A
New operationUses the current routing rule and may go to A.
Queued operation already pinned to BStays on B unless deliberately reconciled and reassigned before any attempt.
Uncertain B attemptRemains unresolved on B; no automatic A resend.
Already accepted operationKeeps its provider ID and acceptance evidence.
Suppressed recipientRemains suppressed regardless of provider.

Move traffic with evidence and an exit condition #

Begin with verified identities, equivalent templates, imported suppressions, valid credentials, and working event verification on the new provider. Normalize events without erasing their original provider identity or raw diagnostic codes. Test the mapping before applying it to operational state.

Move a controlled cohort of new operations, then compare queue age, acceptance failures, unresolved sends, and bounce evidence. A small healthy sample does not guarantee later inbox placement. Keep the rollback control and an owner available throughout the transition.

After new traffic has moved, reconcile old queued and uncertain work, retain required event history, and retire old credentials when they are no longer needed. Document the cutover and rollback decisions. The ZIP supplies a routing lab, not a full provider administration or event-reconciliation service.

The lab also freezes each operation’s template version and content. Its reconcile function records an explicit accepted or rejected decision plus original-provider evidence; it never resends. Suppressions are checked again in the claim statement, but a suppression arriving after a claim cannot undo work already in flight.

Review project ownership and credentials →

Reconcile duplicate and timed-out requests →

Can I resend through the new provider after a timeout? #

A timeout does not tell you whether the original provider accepted the message. Keep the operation on its original provider and check that provider’s records first. The lab’s reconcile function records an explicit decision and evidence; it never sends a replacement.

Use with your coding agent #

Download the example, then copy this brief into your coding agent. The same brief is included as BUILD-BRIEF.md.

Coding-agent build briefmarkdown
# Switch email providers without losing track of messages — coding-agent brief

## Objective

Demonstrate persisted provider routing, stable cohorts, suppressions, gradual cutover and rollback without resending uncertain work.

## Read first

Read README.md, package.json, lab.mjs, test.mjs and demo.mjs. Keep dependency versions pinned to package-lock.json. Use Node.js 22+; PostgreSQL 17+ with a disposable loopback \_test database.

## Dependencies

pg 8.23.1. Install with npm ci and preserve the lockfile.

## Work

Start at `enqueue` in lab.mjs. Run the existing synthetic fixtures before changing behavior. Preserve the guide's original scenario and add a regression check for each changed failure case.

## Constraints

All email is simulated. Do not add provider credentials, send email, deploy services, or use the application's database. Preserve unknown outcomes instead of claiming delivery. Do not turn the local demonstration into production authentication or a public mail relay. Explain the production setup separately.

## Verification

Run `npm ci`, `npm test`, and `npm run demo`; set GUIDE_DATABASE_URL as described in README.md first.

## Acceptance

Verify operation content and template version stay immutable. Test suppression before enqueue and claim, concurrent workers, rollback, restart, invalid modes and audited reconciliation on the original provider. Unknown outcomes must never trigger automatic fallback.
No real tokens, signing secrets, or recipient data appear in logs. All fixtures use synthetic values.

Check the download #

The ZIP contains 10,413 bytes. Compare its SHA-256 digest with this value before extracting. Downloads are free and require no signup.

SHA-256 · migrate-email-provider.ziptext
ddf942213a2521711327a2c1b7eb3f538ec29945fd1542f5ef3518a509557c12

Sources and further reading

Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.