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.
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 demoRead 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.
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.
| File | Purpose |
|---|---|
| lab.mjs | The example behavior shown in this article. |
| test.mjs | Acceptance checks and synthetic failure cases. |
| demo.mjs / expected-output.json | A repeatable local experiment and its recorded result. |
| README.md | Setup commands, expected behavior and production boundaries. |
| BUILD-BRIEF.md | The coding-agent brief below. |
| package.json / package-lock.json | Pinned dependencies and runnable commands. |
| LICENSE | MIT 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.
{
"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 #
| Operation | After routing rolls back to A |
|---|---|
| New operation | Uses the current routing rule and may go to A. |
| Queued operation already pinned to B | Stays on B unless deliberately reconciled and reassigned before any attempt. |
| Uncertain B attempt | Remains unresolved on B; no automatic A resend. |
| Already accepted operation | Keeps its provider ID and acceptance evidence. |
| Suppressed recipient | Remains 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.
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.
# 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.
ddf942213a2521711327a2c1b7eb3f538ec29945fd1542f5ef3518a509557c12Sources and further reading
- AWS: making retries safe with idempotent APIs
- PostgreSQL: row-level locks
- Amazon SES: account-level suppressions
Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.