Mail Legion × Zapmail Teardown
Mail Legion · supply teardown · 2 Sep 2026

Mail Legion × Zapmail Teardown

The engine is built and running on our own fleets. Supply was the missing layer. This is the bird's-eye read of what Zapmail's API actually gives us against every feature we intend to ship, grounded in the retired-exclusion branch, the approved doctrine, and the CMR docs, with the questions that still need their engineers named one by one.

Code
head 83af013 (2 Sep), includes 0c82add · main is 57 commits stale
Build order
approved 27 Aug + amendments 27 to 31 Aug
Doctrine
six states, checkpoints, hospital, coroner
Status
shipped vs not built, how to test
Product
Maillegion Infra Breakdown.md
doctrine mapped to features, pricing, rev model
Supplier
Zapmail by Outbox Labs · CMR Integration API
Verdict

Zapmail can carry v1. Two commercial facts need handling before we price it.

18 / 25
Primitives covered outright
Documented and usable as-is
5
To confirm in Slack
Behaviour not in the docs, or ambiguous
1
Hard gap
No Azure provisioning
7 + 4
Doctrine components live
plus renews_at merged 2 Sep, 4 not built

The health engine, checkpoints, hospital, coroner and graveyard already run against real fleets. Everything Mail Legion sells is that intelligence; Zapmail is provisioning, domains, DNS, subscriptions and credential export underneath it. The API is deep enough for all of that. Warmup placement comes from Bison, not Zapmail, because the warming pools live there.

What needs attention is not the API. It is the 12-month price step-up to $5/mailbox/month, which puts a $3.50 to $5 managed price underwater on year-old subscriptions unless the engine runs their Recreation API reset, and the absence of Azure, which is 400 of the 624 icemail mailboxes on Pod 1 today. Both are decisions, not blockers.

Where the build is

The engine as it exists on retired-exclusion

Read this branch, not main. It is 57 commits ahead across 76 files (+10.8k lines) and carries all seven shipped doctrine components. Test suite at last sign-off: 1,110 passing (uv run pytest, 12 to 15 minutes, needs Docker).

The six living states, plus the terminal one

🔵 in schoolwarmup window, zero cold
🟢 on the jobhealthy, in rotation
🟡 under observationone degraded signal
🔴 in the hospitallimit floored, warmup on
🟣 on leaverotated out, warm
⚪ on callgraduated, benched
⚫ in the graveyardpronounced, awaiting burial

Worst signal wins. One bad night demotes, three clean nights promote. Graduation is 90+ on the 7-day rolling warmup score; age alone never raises volume.

Component status against issue #29

#ComponentStatusEvidence
1Six states + nightly tag engineshippedtests/state/test_inbox_states.py, 10 behaviours. Bison tags written with emoji prefix.
2Warmup telemetry (rolling 3d/7d + trend)shipped25 behaviours. Reads warmup/sender-emails in Bison; lifetime score never used for a gate.
3School checkpoints (day 7 / 14 / 21)shipped10 behaviours. Day 14 under 80 names the successor.
4Ramp interlockshipped37 behaviours. Fixed the live bug where score-17 inboxes ramped to 8/day by age.
5Hospitalshipped6 behaviours. Recorded stays, 30-day cap, escalation at cap.
6Peer reply sensornot builtSpec rewritten 31 Aug; unblocked by the azure calibration (25 inboxes/domain × 4 sends/day flat).
7Placement testing (EmailGuard Pro)not builtBison send slice approved 31 Aug with rails: one-off compose only, seed-list recipients, daily cap, dry-run default.
8renews_at + the billsmerged 2 SepSpec 2026-09-01-renews-at.md. icemail endpoint was reachable all along (workspace-sticky key, 30 req/min, urllib fingerprint-blocked). 613 of 2,692 live inboxes covered, $489/mo, all renewing September. Billing shape is provider-neutral (renewal_source).
9aCoroner + death certificateshipped23 behaviours. Pronounced only on evidence, never idleness. Retired is one-way.
9bEstate settlement (renewal-eve cancel)not builtSpends money; waits on component 8 (now in) and a provider cancel verb. This is where the Zapmail cancel semantics land.
10VocabularyhalfRenaming stored event types is unsafe on an append-only ledger; new events use the locked verbs.
11Graveyardshipped8 behaviours. Purge Q1 answered by live experiment 31 Aug: deleting a Bison sender does not alter campaign aggregates. Q2 (unibox threads) still open, so purge stays manual for senders with reply history.
+Inbox signals (v34), provisioned_at, retired exclusion, azure-fast day 21shippedComposed health verdict persisted per inbox so the read-only dashboard never holds a vendor key.
+Buy executionpreview-onlyRegistrar adapters (Porkbun, Dynadot, Spaceship) and the claimed / indeterminate ledger exist; live purchase stays gated by policy per Charles.

Vendor adapters today

AdapterSurfaceUnder Zapmail
bison.pyRead-only BisonReader (inboxes, warmup rows, campaign stats, unsubscribes) + BisonWriter with approved verbs only (daily limit, warmup limit, signature, pause campaign, remove leads, tags)unchanged Warmup placement and send metrics stay here
icemail.pyforwarding get/set, create_order, prewarm list/buy, list_mailboxes (billing rows), domain lookupreplaced by zapmail.py Same surface, mapped below
cloudflare.pyRedirect worker + KV, zones, nameservers, DNS CRUD, Pages custom domains, scoped token mintingunchanged Landing Factory zone owner
landing_factory.pySubprocess wrapper: status, build, verify, onboard, generate copy, deployunchanged
emailguard.pyBlocklist checks (10/day free cap, Pro needed for a sweep), placement tests pending the send sliceunchanged
porkbun / dynadot / spaceshipAvailability, register, expiry, auto-renew offkept for stealth buys Zapmail can also register, see below
Schema already anticipates this supplier

DESIGN.md §4.1 lists zapmail as a value for domains.inbox_provider, and §4.5 lists it under vendor_links.vendor. The billing columns landed in v43 are provider-neutral by design (R14 in the renews_at spec). Adding Zapmail is a new adapter and a new renewal_source value, not a migration.

What Zapmail is

The supplier in facts

Platform
CompanyOutbox Labs. Product: Zapmail. White-label brand: Cold Mail Reseller (CMR).
Base URLhttps://customers.coldmailreseller.com/api/v1/integration
AuthOne partner key, header cmr-x-api-key. Every resource call carries userId. Server-side only.
TenancyA User is an end customer: owns domains, mailboxes, subscriptions, orders, billing. Full isolation by userId. Delete requires 0 domains and 0 mailboxes.
ProvidersworkspaceType GOOGLE or MICROSOFT (M365). No Azure (confirmed on call).
Rate limit5 req/s per partner key, global. 429 with Retry-After. Webhooks don't count. (icemail was 30/min.)
Async modelOrders return an actionId; results arrive by webhook. Docs: never retry while waiting, duplicates charge twice.
SandboxSeparate base URL and key, cmr-x-sandbox-scenario header simulates 10 outcomes, webhooks fire to sandbox-only destinations, $0 charges.
Integrated in~30 platforms (Smartlead, Clay, Woodpecker, Reply). Battle-tested at scale.
Commercial & ops
WalletPrepaid. Charged per order and per renewal. Must stay topped up; notifications available.
Mailbox priceTiered by volume, shared in the call chat. Fill in from Kaustubh's message. Steps to $5/mailbox/mo when a domain passes 12 months (see landmines).
Domains.com ~$12, other TLDs $4 to $15, drops announced 15 days ahead (.co at $5 now). Auto-renew off by default. 45-day renewal window, 7-day grace.
Admin dashboardEvery order, renewal, domain, mailbox, export, OAuth callback and webhook visible. Our support can act inside a user's account (DNS, forwarding, exports).
SupportSlack Connect #leadgrow-zapmail. 12 to 24h turnaround, IST hours (covers ET 9 to 5, not PT afternoons). No paid support tier; we are front line.
Custom workOffered. They quote timelines up front (days to 2 weeks).
ContactsKaustubh Wadhwa (partnerships); founder/CEO; engineers Mitrajit and Goku.
WebhooksHMAC-SHA256 (cmr-x-signature, cmr-x-timestamp, 5-min replay window). Retries: immediate, +30s, +5min, then marked failed (manual retry in dashboard). eventId for idempotency.
Feature by feature

Every Mail Legion feature, the primitive it needs, and who builds what

covered documented, use as-is confirm ask in Slack before wiring custom ask worth asking them to build gap not available ours our engine, no supplier dependency
FeatureZapmail primitiveStatusHow it works under Zapmail
Workspace frameworkone workspace per end clientPOST /users · every call scoped by userIdcoveredMail Legion workspace ⇆ one Zapmail user. Their per-user model is the exact "no sticky current-workspace context" shape the doc demands, and the thing that burned us with icemail's workspace-bound keys. Our side: pod-per-customer with customer-owned Bison + Cloudflare creds (pods.bison_key_env, landing_pages.cf_account_key already exist). This is the v1 build.
Inbox rotationsix states, night roundsProvisioning status via mailbox.* and subscription.* events; GET /mailboxesoursSensors split three ways: Bison = warmup placement (rolling 7d) + sends/bounces/replies; we compute blocklist (correct resolvers), DNS auth, landing probe; Zapmail = provisioning and renewal state only. Nothing in the tag engine changes.
Auto-reserve / auto-replenishthe 50% → 10% machinePOST /subscriptions/toggle-autorenewal + natural expiry · periodEnd on every subscription event · expiryDate on mailbox.createdconfirmSuccessor named at day 14 from aged reserve (component 12, inventory draw, never a buy). The dying box is cut by turning auto-renew OFF and letting it expire at periodEnd. Do not call POST /subscriptions/cancel: it is immediate, permanent, no proration. icemail had a native scheduled delete; Zapmail does not, so renewal-eve execution is ours (component 9b). Confirm nothing sends after expiry and that expired mailboxes are gone from billing.
Stealth domainsdrip buying, screened firstPOST /domains/available · order via POST /orders/json · auto-renew off default · PATCH /domains/toggle-auto-renewcoveredOur buy plan, caps and pre-purchase DBL/SURBL screen stay as built. Zapmail becomes a fourth registrar lane beside Porkbun/Dynadot/Spaceship, useful for their announced drops. Lapse-by-default already matches doctrine.
Landing Factoryreal page per domain, BYO CloudflarePUT /dns/nameservers · POST/PUT/DELETE /dns · POST /domains/add-forwarding (forwardTo: null removes)confirmA domain sits on exactly one nameserver set. Our pages need it on the customer's Cloudflare; Zapmail needs mail auth on it. Two workable shapes: (a) flip NS to Cloudflare and have Zapmail hand us SPF/DKIM/MX values to write via our existing create_dns_record; (b) keep NS on Zapmail and point only the A record at Pages. Q1 below decides it. Bonus: their forwarding can be turned off per domain, which icemail cannot.
Aged domains marketplaceaged with receiptsDomain order + DNS + our ledgeroursProvenance is our append-only events + checks tables. Zapmail only registers. Unsold stock routes into our own lanes.
Pre-warmed inboxesgraduates only reach the shelfGET /pre-warmup · POST /pre-warmup/order (returns warmupDaysCompleted)coveredDay 1 shelf can be backed by their stock (Google/Microsoft filter, transfers into the user's account). Our own pipeline: provision via Zapmail, attach to a Bison warming pool, graduate at 90+ rolling. Zapmail's own warmup API is not used; confirm it isn't billed by default (Q9).
Credential export to BisonPOST /exports/platform-credential (EMAILBISON needs appBaseUrl, clientId, appName, workspace) · POST /exports/platform-exports · mailbox.exportedcoveredCredentials validated live at store time, then referenced by credentialId. One credential row per customer Bison workspace. Async; listen for mailbox.exported.
Billing awareness (renews_at)expiryDate at mailbox.created · periodEnd on all subscription events · price on renewal.successcoveredBetter than icemail: the date arrives at provision time instead of by backfill, closing the "capture at provision" gap the spec had to withdraw. Maps straight onto inboxes.renews_at / renewal_cost / renewal_cycle with renewal_source = 'zapmail'.
Orders queue with ETAsactionId + domain.order.* / mailbox.order.* / mailbox.createdconfirmStatus is event-driven and complete; an ETA is not exposed. Kaustubh said 24h turnaround. Ask if a status/ETA read exists (Q4).
Placement testsSaid to be on the API; not in the docs navconfirmDoctrine chose EmailGuard Pro as the lab (component 7). If Zapmail exposes one, it's a second arm, not a replacement.
Deliverability tier / benchmarksnoneoursA reporting view over checks and inbox_daily_stats. Zero supplier dependency.
MCP layernoneoursOver our platform API, through the same dry-run and approval gates as the CLI. Zapmail never in the picture.
Azure laneazure-aged, 25×4 flat capnonegapNot provisionable. See landmines for the decision.
For Charles

icemail.py → zapmail.py, verb by verb

The icemail adapter is the template. Same read/write split, same envelope validation, same secret strip at the boundary. Each row is one existing method and its Zapmail equivalent.

icemail.py todayZapmail equivalentNotes
create_order(data)POST /order → order idPOST /orders/json?userId=actionId
body: domains[], mailboxesPerDomain, billingCycle, firstName, lastName
Provider field not shown in quick-start (Q3). Results by webhook: domain.order.success then one mailbox.created per box. Never retry on timeout: record INDETERMINATE like buy_execute does.
list_mailboxes()billing rows, drops passwordGET /mailboxes?userId= + GET /subscriptions?userId=
admin creds: GET /mailboxes/admin-mailbox-details
Keep R3/R4/R11 verbatim: validate envelope, strip secrets, taint-test every sink. Pace under 5 rps.
set_forwarding(id, url)PUT /domain/{id}/forwardingPOST /domains/add-forwarding {domains[], forwardTo}forwardTo: null removes. icemail could not express "no redirect"; this can. Async, 200 = accepted.
get_forwarding(id)Domain read (Q: field name)Verify what the domain object returns for forwarding.
lookup_domains(names)POST /lookup/domainsGET /domains?userId= (list) · POST /domains/has-workspaceIdentity is domainId in vendor_links (vendor zapmail), same rule as R1.
list_prewarm() / buy_prewarm(ids)GET /pre-warmup?serviceProvider= · POST /pre-warmup/order?userId=Microsoft needs microsoftOauthRoute; Google export needs googleOauthRoute, clientId, appName.
(none: icemail managed DNS itself)GET/POST/PUT/DELETE /dns {host, value, recordType} · PUT /dns/nameserversNew capability. excludedDMARCtags only valid on the _dmarc TXT.
(none: scheduled DELETE /domain/mailboxes)POST /subscriptions/toggle-autorenewal then expiry · POST /subscriptions/cancel (immediate only)The one behavioural difference that matters. Renewal-eve is ours to schedule off renews_at.
(none)PATCH /domains/toggle-auto-renewDefault already off. Matches lapse-by-default.
(none)Exports: /exports/platform-credential, /exports/platform-exports, OAuth: /mailboxes/oauth, /domains/add-client-idBison is EMAILBISON with the metadata block. Store one credentialId per customer workspace.
(none: we poll)Webhooks, 15 event typesWe have no inbound surface today (dashboard is read-only, CLI is cron). See build order for the poll-first recommendation.
mailbox.created carries the password

mailboxDetails includes password, appPassword (Google) and secret. Whatever receives that webhook is a secret sink and inherits the renews_at spec's R11: strip at the boundary, never log a row, taint-test every JSON sink. Print sorted(keys), never a row.

Slack-ready

Questions for Mitrajit and Goku

Ordered by how much they gate. The first two decide Charles's provisioning-to-Landing-Factory test.

Q1DNS zone ownershipgates Landing Factory

"We host a unique landing page per sending domain on the customer's Cloudflare, so we want the domain on Cloudflare nameservers. Can you still set the mail records in that case, or will you hand us the SPF, DKIM and MX values so we write them into the Cloudflare zone ourselves? What exactly does PUT /dns/nameservers do: change delegation at the registrar, or only what your DNS layer answers with?"

If they require their own NS, Landing Factory falls back to an A record on their zone (weaker: no Pages custom domain, TLS from them). If they hand us records, our cloudflare.create_dns_record path already exists.

Q2Bring your own domaingates onboarding

"Can we attach a domain the customer already owns at another registrar and order mailboxes on it, with you configuring mail records only? Is there an import endpoint, or is registration-through-you the only path?"

Not documented. Agencies arrive with existing domains; icemail supported import_existing.

Q3Provider selection in the order payload

"The quick-start order body has no provider field. Where do we choose GOOGLE vs MICROSOFT per order, and can one order mix them across domains?"

Q4Order status and ETA

"Is there a read endpoint for an actionId (status, ETA, partial results), or are webhooks the only signal? What is the typical and worst-case time from order to mailbox.created for Google and for Microsoft?"

Our Orders page needs a live status; the docs only show push.

Q5Cancel at period endcustom ask

"We never want to pay a second month on an inbox we've decided to retire. Today that's toggle-autorenewal off and let it expire. Two checks: after expiry, is the mailbox fully stopped and off our wallet the same day? And would you add a cancelAtPeriodEnd flag, or a scheduled cancel endpoint, so it's one call?"

Nice-to-have; component 9b can schedule off renews_at without it.

Q6The 12-month $5 step-upcommercial

"Your docs say mailbox subscriptions move to $5/mailbox/month when a domain passes 12 months. Does that apply to partner wholesale tiers? And confirm the Recreation API: cancel and re-provision on the same domain resets the clock with mailboxes and OAuth preserved, no downtime, no re-warm?"

This one is for Kaustubh as much as the engineers. It changes the managed-inbox margin on any account older than a year.

Q7Placement tests on the API

"Kaustubh mentioned placement tests are available by API but they're not in the docs. Which endpoint, what does a result look like, and what does one test cost?"

Q8Webhook secrets

"mailbox.created ships the password and app password in the payload. Can we opt out and fetch credentials on demand from admin-mailbox-details instead, so the webhook receiver never holds secrets?"

Q9Warmup off, and not billed

"We run warmup in EmailBison's pools. Can mailboxes be provisioned with your warmup disabled, and is your warmup a separate line item we can leave off entirely?"

Q10Wallet operations

"Is there a low-balance webhook or a balance read endpoint, and can we set auto top-up? What happens to a renewal if the wallet is short: past_due then expired, on what timeline?"

Q11EmailBison export metadata

"For the EMAILBISON platform credential, what exactly are clientId and workspace? Our customers each run their own Bison workspace; confirm one credential per workspace and that exports honour Bison's per-workspace API keys."

Q12Azure roadmap

"Any plans for Azure-tenant Outlook mailboxes? If not, would you object to us running Azure through another supplier alongside you?"

Division of labour

What to ask them to build, what stays ours

Worth asking Zapmail for

  • Cancel-at-period-end flag or scheduled cancel (Q5). Small for them, removes a scheduler on our side.
  • Order status read by actionId with ETA (Q4).
  • BYO domain import if it doesn't exist (Q2).
  • Credential-free webhooks option (Q8).
  • Placement test endpoint documented (Q7).
  • Partner-tier exemption or notice on the 12-month step-up (Q6).

Ours, no supplier dependency

  • Six-state engine, checkpoints, hospital, coroner, graveyard (shipped).
  • Successor sourcing and spend policy (component 12).
  • Renewal-eve execution off renews_at (component 9b).
  • Blocklist checks via correct resolvers, DNS auth, landing probe.
  • Landing Factory pages and Cloudflare zone control.
  • Aged-domain provenance ledger and marketplace.
  • Pre-warm graduation via Bison pools.
  • Multi-tenancy, metering, Stripe, MCP layer, benchmarks.
Landmines

Things that will cost money if we don't decide them now

1. The 12-month price step-up

"When a domain completes one year and renews at the 12-month mark, the mailbox subscription price automatically increases to $5 per mailbox per month," applied to all mailboxes on that domain, with a single subscription.updated event as notice. At a $3.50 to $5 managed price that is zero or negative margin on year-old accounts. Options: (a) price the step in, (b) have the replenish engine run the Recreation API at month 11 (cancel + re-provision same domain, mailboxes and OAuth preserved, clock resets), (c) negotiate a partner-tier exemption. (b) is a natural fit for a system that already knows every renewal date; (c) is the ask. Decide before publishing prices.

2. No Azure

Zapmail provisions Google and M365 only. Today Pod 1's icemail workspace is 208 Google / 16 Microsoft / 400 Azure, and the doctrine carries an azure-aged lane (90-day hold, 25 inboxes/domain × 4 sends/day). Two honest paths: keep Azure on icemail/Hypertide as a second supply adapter (the schema is provider-neutral and renewal_source already names the provider), or drop Azure from the v1 customer offer, which is Aydan's lean since the target is 1k+ inbox agencies. Either way it is not a Zapmail problem, it is a product-scope decision.

3. Async everything, and duplicate charges

Every order is accepted immediately and fulfilled by webhook. The docs are explicit that retrying while waiting double-registers and double-charges. Our intent/execution event pairs and the INDETERMINATE outcome in buy_execute are the pattern; add an eventId idempotency store for webhooks and an actionId ledger for orders.

4. Push-based supplier, pull-based engine

infra-orchestrator has no inbound HTTP surface: the dashboard reads facts, the CLI runs on a schedule. Zapmail's contract is webhooks. Recommendation: poll first (GET /mailboxes and GET /subscriptions in the nightly run, 5 rps is ample for thousands of boxes), and add a webhook receiver as a second source later, writing into an inbound table the CLI consumes. That keeps "the surface asks, the CLI acts" intact and needs no new credential on the web host.

5. Cleartext credentials in webhooks and listings

Both mailbox.created and the mailbox listing expose passwords. Same rule as icemail: the adapter is the only thing that ever sees them, and a taint test proves they never reach checks, events, observations, alerts or exception text. One icemail password reached a transcript on 1 Sep and had to be rotated; do not repeat it.

6. Support hours

IST coverage means ET business hours are covered and PT afternoons are not. Fine for a beta with East-coast agencies; worth an SLA line in the partner terms before a 250-client onboarding.

Build order

What Zapmail changes in issue #29, and what it doesn't

Doesn't change: components 1 to 7, 9a, 10, 11. The engine is supplier-agnostic by construction and the doctrine page still wins on wording.

Changes:

  1. Component 8 gets a second source. A zapmail adapter mapping expiryDate / periodEnd / price into the normalised billing row ({external_id, email, renews_at, cost, currency, cycle, observed_at}) with renewal_source = 'zapmail'. No migration.
  2. Component 9b (estate settlement) is specified against toggle-autorenewal + expiry, scheduled by our monitor at renewal eve, never against immediate cancel. icemail keeps its native scheduled delete for the icemail-provisioned slice.
  3. Provisioning (provision.py) gains a live provider path. Today it mints local inboxes and an order sheet; the Zapmail path submits POST /orders/json under the same dry-run / --live gate and records the actionId as intent, then completes on mailbox.created (poll or webhook).
  4. A pod becomes a customer workspace. Pod today = our shared Bison workspace of ~5 clients. Mail Legion needs pod-per-customer carrying that customer's Zapmail userId, Bison base URL + key, and Cloudflare account. The env-name indirection (bison_key_env, registrar_accounts.api_key_env) is the existing pattern to extend. This is the multi-tenancy build the product doc names as the real v1 work.
  5. Recreation-at-month-11 joins the replenish engine's toolkit if Q6 confirms the step-up applies to us.

Charles's immediate test (one Google, one M365, provisioned through Zapmail and fronted by Landing Factory) is the right first live exercise. It is gated by Q1 and Q3. Everything else here can proceed in sandbox with simulate:order-success while those two answers come back.