Email campaigns

An email campaign sends one published content version to a consented audience from your own verified domain. Open the Email section and follow the Email campaigns link.

Before the first campaign you need a verified sending domain — see Email from your own domain. While the domain is unverified nothing is sent at all.

The editor

The campaign editor has four steps.

Message and sender. Campaign name (up to 160 characters), sending domain, sender name, reply-to address, subject (up to 255 characters) and preheader (up to 500 characters). The reply-to must be a bare address such as hello@example.com, without a display name. Carriage returns and line feeds are rejected in the sender name, reply-to, subject and preheader, because they would allow header injection.

Content and preview. Pick a published email content version, or create one from a template. The campaign stores the document id together with the exact version number: republishing the document later does not change a campaign that is already scheduled.

Audience and experiment. Either all email subscribers of the bot, or one ready segment. Optionally link an A/B experiment; the campaign then captures the experiment version when it is scheduled and reports against that snapshot.

Schedule and privacy. Send time, timezone mode, emails per minute (1–10 000) and the open-tracking switch. The send time is optional: launching a campaign without one starts it immediately.

Two timezone modes exist:

  • Campaign timezone — one absolute moment for everyone, computed in the campaign's timezone.
  • Recipient local time — the same wall-clock time in each recipient's own timezone.

The timezone list is a fixed set of common zones (Moscow, Belgrade, London, Berlin, Dubai, Almaty, Tbilisi, Yerevan, Tashkent, Bangkok, Tokyo, New York, Los Angeles, UTC).

Daylight saving is handled explicitly rather than silently:

  • the editor refuses a send time that does not exist in the campaign timezone on that date, and says so under the field;
  • in recipient mode, a recipient whose local wall time does not exist that day is reported as nonexistent_local_time by the recipient check, which blocks the launch until you pick another time;
  • when a wall time occurs twice, the earlier instant is used, consistently for everyone.

In recipient mode, a recipient without a usable timezone is suppressed rather than sent at an arbitrary hour.

Recipient check before you send

Check recipients never sends anything. It builds the audience against the current draft and consent rules and answers with the number that would receive the campaign, the number suppressed and why, plus content errors and warnings.

The answer uses raw codes, the same ones you will see in the interface.

An address is counted as suppressed when:

  • its subscription is not subscribed — the reason is then the subscription state itself: unsubscribed, bounced or complained;
  • the address is empty or unusable (invalid_email);
  • the same normalised address already appears in this campaign (duplicate);
  • recipient-local mode is on and the person has no timezone (missing_timezone), an unrecognised one (invalid_timezone), or a local send time that does not exist that day (nonexistent_local_time);
  • consent was revoked between materialisation and the send attempt (no_consent).

Errors are what actually blocks the launch:

  • domain_not_verified — the sending domain is not verified;
  • content_not_published — the selected content version is missing or not published;
  • audience_not_ready — the chosen segment does not exist or has not finished computing;
  • experiment_not_ready — the linked experiment is not running, or its current version does not assign by subscriber;
  • scheduled_in_past — the send time has already passed;
  • nonexistent_local_time — see above;
  • empty_audience — nobody would receive the campaign;
  • every content error listed in Content studio, reported against content_document_id and the offending block.

Content warnings come through as well, plus one the campaign check adds itself:

  • missing_plain_text — the content has no readable text at all, only media or buttons, so the plain-text part of the message would be empty.

The campaign is ready to launch only when there are zero errors. Warnings never block it.

Send test email delivers the campaign's own content to one already verified, still consenting email identity of this bot. It never touches the campaign's recipient rows and never accepts a typed address, so it cannot consume or corrupt a scheduled send.

Launching and controlling

A campaign can only be launched from Draft. Launching asks for an explicit confirmation and the revision you have on screen. At that moment the campaign is materialised: the content version, sender, audience, schedule and experiment version are frozen into an immutable send snapshot, and one recipient row is created per eligible address.

After that the campaign can no longer be edited. To change anything, use Create copy and launch the copy.

Statuses are Draft, Scheduled, Sending, Paused, Completed, Cancelled, Failed and Archived. Control actions and where they are allowed:

  • Pause — from Scheduled or Sending.
  • Resume — from Paused, back to Scheduled.
  • Cancel — from Draft, Scheduled, Sending or Paused. Recipients that have not been sent to are not sent to afterwards.
  • Archive — from Draft, Completed, Cancelled or Failed.

Every control action also carries the expected revision, so two operators cannot fight over the same campaign without one of them being told.

Sending itself is paced by the emails-per-minute value, runs in leased batches and is resilient to a worker restart. A recipient whose send fails temporarily is retried with a growing delay starting at 15 seconds and capped at 15 minutes. A send that was handed to the provider but whose outcome was never confirmed is recorded as Delivery unknown rather than silently retried, because a retry would risk a duplicate email.

Tracking, privacy and unsubscribes

Every tracked URL — open pixel, click redirect and unsubscribe alike — is a signed token on your own installation, in the form https://<your-host>/e/t/<token>. Tokens are HMAC-signed, scoped to one bot, campaign and recipient, and expire 90 days after the send. The signing key is derived from the installation's email webhook secret: without it, campaigns refuse to send rather than mail untracked links.

  • Open tracking is a 1×1 GIF. It is optional: turning off "Collect open statistics" simply omits it, and delivery, click, complaint and unsubscribe metrics keep working. Open counts are always a lower bound — see the explanation in Email from your own domain.
  • Click tracking rewrites https:// link buttons only. A click token carries the destination, and the destination must itself be an https:// URL with a host and no embedded credentials, so the redirect cannot be pointed anywhere else.
  • Unsubscribe tokens deliberately carry no link id and no destination at all: an unsubscribe is an effect, and an effect must never be able to redirect.

An invalid, expired, cross-tenant or unknown token answers 404 with no further detail, so a token cannot be used to probe for valid campaigns or recipients.

Unsubscribing follows RFC 8058 one-click. A GET shows a plain HTML confirmation page with no JavaScript; a POST with the single form field List-Unsubscribe=One-Click performs it. Replaying the same unsubscribe is a success, not an error — mail clients prefetch links and people click twice. The opt-out is recorded on the recipient row and as the canonical consent effect in the same transaction, so the link can never report success while consent stays subscribed.

Report

The campaign report separates: Recipients, Suppressed, Waiting, Sending, Accepted by provider, Delivery unknown, Delivered, Opened, Clicks, Bounced, Complaints, Unsubscribed, Failed and Cancelled. Clicked links are reported per link, and recipients can be listed page by page with their status and suppression reason.

Attributed revenue is reported per attribution model and per currency, and is never summed across them: different models legitimately attribute the same conversion differently, and a mixed-currency total would be meaningless.

Replies from recipients

An inbound route turns an address on a verified domain into a support conversation. The local part is stored only as an irreversible hash, so the address itself is never kept next to the route.

Routes have no screen in the dashboard yet. Manage them over the API with a token carrying settings:read / settings:write:

curl -X POST "$BASE_URL/api/bots/$BOT_ID/email/inbound-routes" \
  -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \
  -d '{"domain":"mail.example.com","local_part":"support"}'

The local part is lowercased and must match [a-z0-9][a-z0-9._+-]{0,62}. The domain must already be verified for this bot, otherwise the call answers 422; the same address twice answers 409. The response echoes the full address once, at creation — afterwards the list shows only the domain, because the local part is not stored. An installation with no email webhook secret configured answers 503: inbound mail is off entirely.

Incoming mail is normalised before it can touch a dialog:

  • the sender is resolved to a subscriber by the normalised address;
  • In-Reply-To and References are used to continue the existing thread, but only when the thread belongs to the same person;
  • quoted history is stripped from the body;
  • the raw message is capped at 8 MiB, the extracted text at 256 KiB, the subject at 255 characters and References at 32 entries;
  • at most 5 attachments of at most 1 MiB each are kept, stored under a content-addressed key; executable attachments (.bat, .cmd, .com, .dll, .exe, .msi, .scr and MS-DOS executable MIME types) are rejected;
  • an HTML-only message is reduced to text.

A message addressed to an unknown route is accepted and dropped, so a mistyped address cannot become a retry storm.

Provider contract

If you operate your own installation or write an adapter for an email service provider, these are the two public endpoints the provider talks to. Both authenticate with an HMAC-SHA256 signature over the raw request body, hex-encoded, in the X-Signature header, using the configured email webhook secret. A request with no signature, or with any signature when no secret is configured, is rejected with 401 — the endpoint fails closed.

Delivery and engagement events

POST /esp/{provider}/webhook, body limited to 1 MiB:

{
  "provider_id": "prv_00000000-0000-0000-0000-000000000000",
  "event": "delivered",
  "bounce_class": "hard",
  "url": "https://example.com/offer",
  "event_id": "evt_0000000001",
  "tag": "email-campaign:11111111-1111-1111-1111-111111111111:22222222-2222-2222-2222-222222222222"
}

This is the platform's own generic contract, not any one provider's native shape. Your adapter translates the provider callback into it.

  • event is one of delivered, opened, clicked, bounced, complained, unsubscribed. An unrecognised value is stored for inspection and changes no status.
  • bounce_class is hard or soft, and only meaningful for bounced.
  • tag is the stable metadata supplied at send time, in the form email-campaign:<campaign-id>:<subscriber-id>. It lets a callback that arrives before the provider id has been persisted still find its recipient.

Event to state mapping:

  • delivered → message and campaign recipient become Delivered;
  • bounced with bounce_class: hard → message Bounced, address suppressed, recipient Bounced;
  • bounced with bounce_class: soft → recorded only. A soft bounce says something about this attempt, not about the address, so it never suppresses and never moves the recipient;
  • complained → complaint recorded, address closed permanently;
  • unsubscribed → consent revoked, recipient Unsubscribed;
  • opened, clicked → recorded as events, no status transition.

An out-of-order callback can never walk a terminal state backwards.

Idempotency uses event_id when the provider sends one, and otherwise a SHA-256 of the raw body. The check, the effects and the receipt are written in one transaction, serialised per bot and key, so two concurrent deliveries of the same event cannot both apply.

Response codes tell your adapter what to do:

  • 200 — understood. This includes events that were deliberately ignored, such as a duplicate or a message id this installation never sent: retrying will not change the outcome.
  • 400 — malformed body, or provider_id/event missing.
  • 401 — bad or absent signature.
  • 413 — body over the limit.
  • 500 — a transient failure on our side. This is the only class you should retry.

Inbound email

POST /esp/{provider}/inbound, body limited to 12 MiB:

{
  "event_id": "in_0000000001",
  "raw_mime": "<base64 of the original MIME message>"
}

The decoded MIME must not exceed 8 MiB. 200 with {"created": true|false} means the message was accepted (false means it was a duplicate of an already-recorded event_id), 204 means the recipient address matches no route, 400 means the envelope or MIME could not be parsed, 401 means the signature failed, and 413 means the request body was over 12 MiB.

Neither endpoint ever returns provider credentials, recipient addresses that were not in the request, or the local part of an inbound route. Tracking and unsubscribe failures answer 404 without saying which check failed.

Troubleshooting

  1. Nothing sends and the domain card shows a warning — the sending domain is not verified yet. See Email from your own domain.
  2. "Campaign cannot be launched yet" — open the recipient check and fix every error; warnings alone never block a launch.
  3. Many recipients suppressed as missing_timezone — the campaign is in recipient-local mode but your people have no timezone. Switch to campaign timezone or fill the profile field.
  4. The send time is refused, or the check reports nonexistent_local_time — that clock time does not exist on that date because of a daylight-saving jump. Pick another time.
  5. Opens look far too low — expected, and not fixable. Compare campaigns by clicks instead.
  6. Provider events never arrive — check that the adapter signs the exact raw body and that the webhook secret matches; an unsigned callback is answered with 401.

What's next