Skip to main content

Email authoring and seed measurement

Writing the email and proving it lands are two different jobs, and Orbit keeps them in two different lenses. This page covers the authoring half — the drag-and-drop block builder and MJML compilation, the billed test-send you fire before launch, and the seed-lab measurement that answers where it actually landed. The pre-send rating half — “will carriers and filters score this content as risky, before anything sends?” — lives on the Deliverability Lab page. Author here; rate against scoring there; measure against seeds here again.

1. Authoring: drag-and-drop blocks and MJML

The email builder consumes an ordered list of typed blocks — header, text, image, button, divider, spacer, columns, footer — plus an interactive amp-form / amp-carousel / amp-list set. Three endpoint surfaces serve the authoring loop under /api/v1/messages/email-builder/*:
  • Render. POST /api/v1/messages/email-builder/render compiles the block list into a complete HTML document: table-based layout with inline styles, the shape every major client (Gmail, Outlook, Apple Mail) renders consistently.
  • Render MJML. POST /api/v1/messages/email-builder/mjml/render accepts MJML source (<mjml>…</mjml>) instead of blocks and compiles it to the same cross-client HTML. If you author MJML by hand or import it from another tool, compile through this endpoint; the compiler reports unsupported components as explicit errors rather than silently dropping them.
  • Save and list. POST /api/v1/messages/email-builder/save persists the definition; GET /api/v1/messages/email-builder/list pages through what you have saved.
Every block type maps onto one of two renderers: the static HTML renderer for the multipart/alternative text/html part, and the AMP-for-Email renderer for interactive blocks. AMP-only blocks (forms, carousels, live lists) emit as the text/x-amp-html alternative part; non-AMP clients fall back transparently to the static HTML — exactly as a well-formed multipart email should degrade.

Multipart HTML/text, and the save-rate bucket

The builder writes a { body, blocks } envelope on save. body is a plain-text summary extracted in canvas order from the text-bearing blocks (header, text, button, footer) — that is what the unified template list renders as the snippet and what a send-time plain-text fall-back can draw on. blocks re-hydrates the canvas when you reopen the template. The extraction is capped at 8,192 characters, matching the template-content ceiling, so a full template cannot overflow the canonical /messages/templates path either. Re-saves run on their own per-tenant rate bucket (email-builder-save, 60/minute) so a save loop doesn’t fight the compose dialog’s preview traffic for the shared write budget. A re-save of the same name with no id updates the row in place; passing an id that collides with another template’s name returns 409 TEMPLATE_NAME_ALREADY_EXISTS instead of a raw error.

The URL and escape safety contract is the same in both renderers

Every URL a block or MJML tag can carry — logoUrl, href, src, an AMP form’s action endpoint — goes through one normalization predicate. It allows http:, https:, mailto:, tel:, sms:, protocol-relative //host, same-document #anchor, and a single {{merge_tag}} substituted at send time. Anything else — javascript:, data:, vbscript:, file:, strings with smuggling-prone characters — collapses to a safe # in rendered HTML. Legacy-saved ftp: URLs still render (the read side is forgiving), but new writes are rejected at the schema boundary (the write side is strict). This two-sided guard is why the render pipeline can escape every text value and every attribute without breaking older templates.

2. Test sends: billed, gated, webhook-visible

Before launch you want a real message to land in a real mailbox. POST /api/v1/messages/email-builder/send-test renders your blocks and sends them as a real paid send — not a synthetic row. That choice is deliberate: a preview that never left the platform wouldn’t let you verify rendering, and a preview that bypassed the billing gate would leak a real paid send with no wallet charge. The endpoint therefore runs the same safety chain as a production send:
  • Billing. The wallet debit lands before the provider send (submit-based ordering) at the channel rate — fail closed on insufficient funds, and never a free pass via an “I’m just testing” claim. A client retry of the same logical send replays one idempotency key instead of double-charging.
  • Recipient allow-list. On the platform’s shared email account the test recipient must be a verified org member; a per-tenant daily cap applies so a preview loop can’t burn shared-reputation sender status. Your own senders (BYO) skip the shared-account gate but the billing debit still runs.
  • Audit row + webhooks. Each test send persists a messages row (test_mode=true, status=test_sent or failed on provider error) so the send appears in the dashboard Messages tab beside your live traffic. That row drives the normal MESSAGE_SENT webhook fan-out, so anything listening for preview sends sees the same event shape you consume for real sends. The response is committed as soon as the provider verdict is known; the row persist and cache sweep happen after the reply returns, so a slow tail never holds the operator’s answer hostage.
The From address resolves the way production sends resolve it: your configured default sender (Settings → Email Senders isDefault row, or the channels-page verify-DNS source), falling back to the platform sender when nothing is set. A test send therefore exercises the same sender-resolution chain a real send would.

3. Seed-lab placement: the measured counterpart

A rate limit of “predicted” tells you what your history says; a seed lab answers what actually happened. Orbit’s seed surfaces work by registering your own test destinations and then scoring your real sends to them — the seed path never routes an outbound message; it only reads the receipts your normal sends generated. The SMS/RCS seed lab (campaigns route group) is the handset variant. Register a test handset with POST /api/v1/campaigns/deliverability-seed-lab/recipients (E.164, a friendly label, channel, and optionally the expected operator MCCMNC / country); read it back with GET /api/v1/campaigns/deliverability-seed-lab; remove with DELETE …/recipients/:id. The roster lives in your organization settings (messaging_seed_recipients), so it survives under row-locked read-modify-write and can never double-submit. For every registered handset the report aggregates your own terminal delivery receipts to that exact destination:
  • delivery rate on a terminal-status denominator (delivered over terminal), so a pile of in-flight rows can’t flatten the rate;
  • delivery latency (median, P95) from the timestamps the receipts carried;
  • the observed operator by mode-most-common MCCMNC, joined to the shared operator catalogue for a human label;
  • an inbound-reply corroboration — a reply from the handset is the strongest counter-signal to a delivery-receipt-that-says-delivered-but-never-arrived silent drop.
Each handset rolls up to a health verdict (healthy ≥ 90 / watch ≥ 75 / poor below, untested when no terminal receipts yet) with a confidence level that is honest about sample size (low under 3 receipts, high at 20+). Operators roll up per observed MCCMNC so one bad seed row surfaces as a per-carrier verdict, not a per-handset anomaly. The campaign review surfaces this same shape: a low-confidence row tells you which step is missing data, not just how the number reads. Email has its own seed surface (the email deliverability service): registered mailboxes seed a proof send, and the receiver’s captured headers plus parsed Authentication-Results classify where it landed — an authoritative folder header when the seed receiver reported one, Microsoft’s mailbox-delivery destination header as a strong signal, or a spam/bulk/inbox inference when neither was present. The authoring half above feeds this same surface: the test send renders your blocks, the receiver’s IMAP-aware seed captures the folder it landed in, and the placement report classifies it rather than predicting.

4. Boundary: authoring vs pre-send rating

Two lenses, two pages: The boundary matters because a page about authoring should never promise a pre-send verdict, and a page about pre-send scoring should never promise proof-from-the-wire. Author and measure here; rate and predict there. The campaign launch chain wires both — the composer calls the rating lens before the review step admits a send — but document and operate them as separate lenses.

5. Worked sample: MJML to launch

A draft you want to verify before committing an audience:
If the pre-send rating flags a URL shortener or insecure http:// link, the fix happens in the same composer — the authoring and rating lenses share the save surface so the rewrite is never a second save you forget to make.

See also