Skip to content

Email Campaigns

Send news and offers to the people who asked for them, from your own email domain, in your shop's branding. A campaign goes to one of your mailing lists, only to subscribers who confirmed, with an unsubscribe link in every email that works in one click from the mail app. Bounces, spam reports and unsubscribes come back into Badger, so the next campaign leaves those people out.

Campaigns build on Customer Messaging and Consent: who is subscribed, to which list, and how they agreed is recorded there.

Your sending domain

Campaigns go out from your own domain, never a shared Badger one, so your reputation with mailbox providers is yours. Add it under Customers, Sending domain. Campaign emails then come from news@ that domain (the Campaign sender address (before the @) setting changes news), with your shop's name as the sender unless you set Campaign sender name.

Use a subdomain. We recommend something like news.yourshop.co.uk rather than yourshop.co.uk itself. Marketing mail then builds a reputation of its own, and a bad week for a campaign can't affect your order confirmations or your staff's email. Your main domain works too. Badger's own domains (bdgr.co.uk and the like) can't be used, and a domain can belong to only one shop.

The DNS records. Once you add the domain, the page lists the records to publish at your DNS provider (where you bought the domain, or whoever runs its DNS), each with a copy button: a few CNAME records that prove the domain is yours and sign your mail (DKIM), and the records that tell mailbox providers the mail is allowed (SPF and DMARC). Add them exactly as shown. DNS changes can take from a few minutes to a day to be seen.

Checking. Badger checks the domain for you: every 5 minutes on the day you add it, hourly for the next week, then daily. Check now asks straight away (at most once every couple of minutes). Each record shows whether it passes. When they all pass, Badger sends your company details to Mailtrap (our email provider) and Mailtrap reviews the domain, which usually takes a couple of minutes. Once it's approved the domain shows Verified and campaigns can be scheduled.

What each problem means.

Shown What to do
A DNS record is failing Add or correct that record at your DNS provider, then check again. A record still shown as missing usually just hasn't been seen yet.
Company details are missing Fill in Legal entity name and Registered address in Settings (Legal entity) (the address as lines or comma-separated parts ending with the town and postcode). Mailtrap needs them before it will approve a sender.
Under review Nothing: Mailtrap is reviewing it. This usually takes a couple of minutes.
Not approved Mailtrap didn't approve the domain. Contact support.
Suspended Mailtrap stopped sending from the domain. Any campaign that was sending is paused. Contact support.
Couldn't reach Mailtrap A temporary problem: Badger tries again at the next check.
Account changed The shop now uses a different Mailtrap account (see below). Remove the domain and add it again.

Removing it. Remove takes the domain off the shop and out of Mailtrap. It can't be removed while a campaign is scheduled, sending or paused: cancel that first. Removing it frees the domain to be added again, by you or another shop.

Changing Mailtrap account. If you change which Mailtrap account the shop uses, the domain still belongs to the old account, so campaigns stop and the domain shows Account changed. Remove it and add it again: it's created in the new account, usually with new DNS records to publish (each account signs mail with its own keys). Bounces and complaints from the old account stop counting. Your do-not-email list stays as it is: it belongs to your shop, not to the account.

Your own Mailtrap account

Campaigns go through Badger's Mailtrap account unless you set your own in Settings, Email campaigns:

You set Campaigns are sent from With the key
nothing Badger's Mailtrap account Badger's
Your own Mailtrap account ID your Mailtrap account Your own Mailtrap bulk API key, or, if you leave it blank, your Mailtrap API Key (one Mailtrap key works for both kinds of email)

Your order and account emails keep using your Mailtrap API Key whichever account campaigns use. If you set your own account ID but no key at all, campaigns can't be sent: Badger never uses its own key with your account. The keys are never shown again once saved (leave a key blank to keep it), and they aren't copied when a shop's settings are moved to another site.

Your sending domain and the connection that reports bounces and unsubscribes live in the account in use. If you switch account, campaigns stop until your sending domain is verified again in the new one: remove it and add it again, then publish the DNS records shown (they change with the account).

The do-not-email list

Customers, Do-not-email list holds the addresses no campaign will ever go to, whatever their subscriptions say. Every campaign checks it for each recipient just before sending.

Why an address is on it.

Reason How it gets there
Spam complaint The recipient reported a campaign as spam. They are also unsubscribed from every list.
Hard bounce The address doesn't exist or refuses mail for good. A few soft bounces in a row (a full mailbox, say) stop campaigns to the address too, but don't put it on this list.
Invalid The address can't receive mail.
Added by staff You added it, one at a time or by importing a list.
Unsubscribed Someone who had unsubscribed was erased (see Keeping and deleting contacts), or Mailtrap refused a campaign email because the address had unsubscribed through Mailtrap.

No addresses are stored. The list keeps a fingerprint of each address (a keyed hash, different for every shop), not the address itself. That is why it can outlive the contact: erasing someone removes their name and address, while the fingerprint still matches if the same address turns up again. It's also why the list shows reasons and dates rather than addresses. To see whether someone is on it, use Check an address: it answers yes or no, with the reason.

Adding addresses. Add one at a time, or Import a CSV: one address per line, or a file with an email column (for example the unsubscribes exported from your previous email provider). Lines that aren't email addresses are skipped and counted, and addresses already on the list are left as they are. Only entries added by staff can be removed again; the others are there because of what the recipient did.

Unsubscribing works the same way, on the list or not. Someone who unsubscribed themselves (with the link in an email, their mail app's unsubscribe button, the mail service's own link, a spam report, or by unticking a list or choosing Stop all marketing emails on their account) is only subscribed again by clicking a confirmation link. Ticking the box at checkout, a sign-up form or their account's Preferences tab sends them the confirmation email instead, and they get no campaigns from that list until they click it. An unsubscribe made by staff, or a contact erased by staff or the nightly clean-up, doesn't count as their own.

Coming off it. Only the person can take their address off the list, by clicking the link in a confirmation email. Nothing else does: not ticking the box at checkout, a sign-up form (even with Confirm newsletter sign-ups by email off), ticking a list on their account's Preferences tab, a booking, registering, an import, staff, or an order they place while signed in. When any of those happens for an address on the list as unsubscribed, a spam complaint or a hard bounce, the subscription is recorded as waiting for confirmation, the confirmation email goes (at most once a day per address), and the address stays on the list until the link is clicked. Checkout still goes through as normal. The customer's record and a subject access request show the subscription as waiting for confirmation.

What the click lifts:

  • Unsubscribed: they asked again and confirmed from their own inbox.
  • Hard bounce and invalid: the confirmation email reached them, so the address takes mail now.
  • Spam complaint: only when they click after the complaint. A confirmation email sent before someone reported spam can't undo the report.
  • Added by staff: never. Only staff remove those.

The UK soft opt-in at checkout (being signed up because they didn't tick "don't email me") records nothing at all for someone on the list, and sends no email.

Mailtrap's own list. Mailtrap keeps a list of its own, and refuses to send to an address it saw unsubscribe. If Mailtrap refuses a campaign email for that reason, Badger records it as an unsubscribe: the person comes off every list and onto this one. When someone like that subscribes again and confirms it, Badger asks Mailtrap to forget its entry, a few at a time (Mailtrap allows only a handful of such requests a minute), so it can take a few minutes after they re-subscribe. Bounces and complaints are never removed from Mailtrap's list, and on Badger's shared Mailtrap account only entries for your own sending domain are ever touched.

Writing a campaign

A campaign has a name (only staff see it), the list it goes to, a subject line, optional preview text (the line most inboxes show after the subject) and up to 40 blocks:

Block What it holds
Heading Up to 200 characters, large or medium
Text Up to 10,000 characters of Markdown: paragraphs, bold, italics, lists and links. HTML typed into it is shown as text, not run. Links must be https://, http:// or mailto:
Image A picture from your media library, its description for screen readers, and an optional link
Button A label of up to 60 characters and a link
Product grid Products from one of your collections, with prices (below)
Divider A thin line between sections

Links and buttons go to a page on your shop (/sale) or any https:// address. Links to your own shop get utm_source=badger&utm_medium=email&utm_campaign=<campaign id>, so your analytics can tell campaign visits apart. Images and links are always written as full https:// addresses, because an email has no page to work them out from.

Personalisation is kept small on purpose. A subject, heading or text block can greet people by name with {{first_name}}, or {{first_name|friend}} to say "friend" when the name isn't known. Nothing else can be merged in. The name is always shown as text, never as part of the email's code.

What every campaign email says at the bottom. You can't remove or edit the footer: it says which list the person signed up to and from which shop, links to unsubscribe from that list and from all your emails, links registered customers to their email preferences, and gives your business name and address from Settings (legal entity). Your email branding (logo, colours, font and footer text) applies as it does to your order emails: a campaign opens with the same banner in your primary colour, with your logo (or your shop's name), and ends with the same footer text.

Both unsubscribe links open the same unsubscribe page, which offers both choices: leave this list, or stop all your emails. The link in the email's headers (the button mail apps show) unsubscribes from the campaign's list in one click.

One language per campaign. A campaign is written once and sent in your shop's default language: the footer, the test banner and every other word Badger adds come from that language, whatever language a customer chose. Write a campaign per language, each to its own list, if you sell in more than one.

Badger checks a campaign before it can be scheduled, and the editor lists anything that needs fixing: a missing name, list, subject or block content, an image or collection that no longer exists, a product grid with nothing in stock to show, no verified sending domain, no business address, nobody on the list who can be sent to, or an email over 100 KB (Gmail cuts off longer emails).

Only a draft can be edited. Duplicate a scheduled or sent campaign to reuse it.

Product grids

A product grid shows up to 12 products (6 unless you choose otherwise) from one collection, in 2 or 3 columns, in the same order as the collection page. Like the collection page, it leaves out disabled products and events that have ended. It also leaves out anything that can't be bought now. A product with options shows if any option is in stock, priced from its cheapest option ("From £12.50") when the options cost different amounts. Prices are in your shop's currency, and you can hide them.

The products are chosen once, when the campaign starts sending, and every recipient gets the same email. The report's preview shows exactly what was sent, even after prices or stock change. A grid with nothing to show when sending starts is left out of the email.

Test sends

Send a test to up to 5 addresses at a time to see the campaign in a real inbox. A test:

  • uses today's products and prices, and greets the reader as "Alex";
  • goes out through your normal order-email sender, so it works before your sending domain is verified;
  • has "[Test]" in front of the subject and a note at the top, and its unsubscribe link does nothing;
  • counts towards a limit of 20 test emails a shop an hour;
  • isn't sent to an address on your do-not-email list: that address is counted as refused.

Only admins can send tests. The addresses you test with aren't kept or logged.

Scheduling

Send a campaign now, or schedule it up to 90 days ahead. You pick the date and time in your shop's time zone (Settings, the shop's time zone), and the editor says which zone that is. Badger handles the clock changes: a time that doesn't exist on the night the clocks go forward (01:30 in the UK) moves forward by the hour, and a time that happens twice on the night they go back is the first of the two.

When you schedule, Badger freezes the sender, the reply-to address and the audience estimate, and asks you to confirm how many people it will go to. Only admins can schedule, resume or send. Until it starts sending you can unschedule a campaign (it goes back to being a draft you can edit) or cancel it. Once sending starts you can pause, resume or cancel it; a sent or cancelled campaign can't be changed.

Who gets a campaign

A campaign goes to one mailing list. Each person on it gets the email only if, at the moment it is sent, all of these are true:

  • they are subscribed to that list and have confirmed: anyone still waiting to click the confirmation link, and anyone who unsubscribed, is left out;
  • their contact hasn't been erased;
  • they have an email address, and it still works: an address that bounced, or whose owner marked a campaign as spam, is left out;
  • while Confirm newsletter sign-ups by email is on, their address is known to be theirs: they clicked a confirmation link, the address was used for an order by the account with that email, or they subscribed at checkout (the address took the order);
  • the address isn't on your do-not-email list.

This is the same rule the subscriber export uses, so the file and a campaign to the same list reach the same people.

The rule is checked twice: once when the campaign starts, to build the list of recipients, and again just before each email goes, so someone who unsubscribes while a large campaign is sending doesn't get it. People left out are counted in the campaign's report (skipped or suppressed); nothing is sent to them.

Each person gets a campaign at most once, however the sending is interrupted: a restart or a retry never sends it twice. If the mail service can't tell us whether an email went (it stopped answering after the email was handed over), that email is counted as failed and isn't tried again, because a second copy is worse than a missing one.

The audience number shown before you send is an estimate (subscribers less known bounces and blocked addresses). The exact count is worked out as the campaign starts.

How sending works

Each campaign email is its own message, to one person, sent in batches of up to 500 through Mailtrap's bulk stream, which keeps marketing email apart from your order emails. Every email carries an unsubscribe link in its footer and the headers mail apps use for their own one-click unsubscribe button; both lead to Badger's unsubscribe page, which records the choice. Mailtrap's own unsubscribe footer is turned off on your sending domain.

Mailtrap tells Badger what happened to each email: delivered, bounced, reported as spam, unsubscribed through Mailtrap, and opened or clicked when tracking is on. These reports are signed by Mailtrap and checked before Badger reads them, and each report counts once even if Mailtrap sends it again. They carry no email addresses into Badger's queues or logs.

If Badger can't tell whether Mailtrap accepted a batch (the connection dropped after it was sent, or Mailtrap answered with a server error such as 500, 502 or 504), those emails are marked as failed rather than sent again, so nobody gets a campaign twice. When Mailtrap clearly didn't take the batch (it was busy, asked Badger to slow down, or couldn't be reached), the emails wait and are tried again.

If Mailtrap refuses an email because it has the address on its own unsubscribe list, Badger treats it as an unsubscribe it missed: the person is unsubscribed from every list and added to the do-not-email list. Badger only asks Mailtrap to forget such an unsubscribe after the person subscribes again and confirms it.

Sending speed

A shop sends at most 5,000 campaign emails an hour (counted per clock hour, across every campaign the shop is sending). A larger list goes out over several hours: a list of 12,000 takes about two and a half. Badger also keeps under the mail service's own limits, sharing them fairly between shops.

Badger lines up at most an hour's worth of emails at a time for the whole shop, shared by every campaign it is sending, and adds the next people as those go. Emails waiting for the next hour simply wait: they are never counted as failed for waiting their turn.

If the mail service keeps failing (or keeps asking Badger to wait) for an hour, or a batch of emails stops moving, the campaign is paused and says why. Nothing is lost: the emails that hadn't gone are still waiting, and Resume sends them.

Pause stops a sending campaign within a minute: emails already handed to the mail service go, and the rest wait. Resume carries on where it stopped. Cancel stops it for good; nobody else gets it.

The hourly limit is set by Badger support (superadmin setting campaign-max-per-hour). If your shop's domain stops being able to send (for example its DNS records changed) or the mail service refuses your account's details three times in a row, sending campaigns are paused automatically and the campaign says why; resume once it's fixed.

What the numbers mean

Number What it counts
Audience Everyone on the list when the campaign started
Queued Those who passed the rule above and were lined up to get it
Skipped Left out: not confirmed, unsubscribed, erased, no working address, unverified address, or the campaign was cancelled before their turn
Suppressed Left out because the address is on the do-not-email list, or the mail service is blocking it
Sent Handed to the mail service
Failed Not sent: the mail service refused the address, the email couldn't be made, or the outcome is unknown
Delivered The receiving mail server accepted it
Soft bounced A temporary delivery problem (a full mailbox); the mail service retries. Three in a row to one address, with no delivery between, stop campaigns to it (it isn't added to the do-not-email list)
Bounced The address doesn't exist; it is added to the do-not-email list and gets no more campaigns
Complained The person marked it as spam; they are unsubscribed from every list and added to the do-not-email list
Unsubscribed They unsubscribed through the mail service's own unsubscribe (Badger's own link shows in the contact's history)
Opened, Clicked People who opened it or clicked a link at least once, when tracking is on

Each person counts once in each number, however many times the mail service reports the same thing. Failures are listed by reason in the report.

Delivery, bounce and complaint numbers come in from the mail service over the following minutes and hours, so they keep changing after the campaign says Sent.

In the admin

Everything is under Customers in the admin menu, and only shop admins can use it.

Setting up the sending domain

  1. Open Customers, Sending domain and enter the domain you want to send from, for example news.yourshop.co.uk. A subdomain is best: marketing email then has its own reputation and can't affect your everyday mail. It can't be a Badger address, or one another shop already uses.
  2. The page lists the DNS records to add: type, name and value, each with a copy button. Add them at the company that hosts your domain's DNS, copying them exactly. Some hosts want the name without your domain on the end.
  3. Press Check now, or wait: Badger checks every few minutes at first, then hourly, then daily. Each record shows Correct, Wrong value or Not found yet. DNS changes can take a few hours to show up.
  4. When everything passes the status becomes Verified and the page shows the address campaigns go out from. Until then you can write and test campaigns but not send them.

If something is holding the domain back, the page says what and what to do: missing company details (add them to your legal entity details in Settings), records to fix, a review in progress, or a suspension. Remove domain asks you to type the domain name, and isn't allowed while a campaign is scheduled, sending or paused. To use a different domain, remove this one and add the new one.

A domain already in your Mailtrap account. If you send through your own Mailtrap account and the domain is already there (you added it in Mailtrap yourself, for example), Badger takes that one over instead of adding it again. Its DNS records are the ones already in place, so Check now usually verifies it straight away. When Mailtrap refuses a domain, the page says why: the API token wasn't accepted, the token can't manage sending domains (give it admin access to the account in Mailtrap, Settings, API Tokens), or Mailtrap's own reason, such as a plan that allows only one sending domain.

Which Mailtrap account. The page says whether campaigns go through Badger's Mailtrap account (the default) or your own. It shows your own account's number but never a token. If you set or change your Mailtrap account in Settings, Email campaigns, your existing domain is marked The Mailtrap account for this shop has changed: sending campaigns pause, and you remove the domain and add it again to verify it in the new account. Its DNS records will usually be different.

Using the editor

Email campaigns, New campaign opens a draft. Give it a name (only you see it), choose the list it goes to, then write the subject and the preview text.

Build the email from blocks: Heading, Text, Image, Button, Products (a grid from one of your collections) and Divider. Move blocks with the arrows, delete the ones you don't want. The unsubscribe footer is added for you and can't be removed.

The draft saves by itself a moment after you stop typing, and the preview beside it refreshes after each save. If something would stop the campaign being sent, such as a missing subject or an empty collection, it's listed under the blocks. If someone else saves the draft first, the editor tells you and stops saving rather than overwrite their work: reload to see their version.

Sending yourself a test

Send a test takes up to five addresses. The email is marked as a test, and its unsubscribe link does nothing. A test uses what's saved, so the button waits until your changes have saved. Test emails can go before your domain is verified.

Sending or scheduling from the editor

Send shows how many people are subscribed to the list now. Choose Schedule it and pick a date and time in your shop's time zone (the page names it), or Send now. You then type the audience number to confirm, so a campaign never goes to a list you didn't mean. The exact audience is worked out when sending starts: people who unsubscribed in the meantime are left out.

A scheduled campaign can go Back to draft to change it. Once it's scheduled you can't edit it, but Copy as new draft starts another from it.

The report

A campaign that's scheduled or sent opens its report: how many were sent, delivered, bounced, complained or unsubscribed, with rates; opens and clicks if tracking is on; how many were skipped or left out because they're on the do-not-email list; why any failed; a timeline; and the email as it went. While a campaign is sending, the numbers update every few seconds.

  • Pause stops sending; Resume carries on. A campaign the system paused (for example because the sending domain stopped being verified) says why.
  • Cancel campaign stops it for good. Emails already sent can't be recalled. You type cancel to confirm.

Managing the do-not-email list

Customers, Do-not-email list shows addresses campaigns never go to, by reason: unsubscribed, spam complaint, address bounced, invalid, or added by staff. It holds a fingerprint of each address rather than the address, so it keeps working after someone is erased, and it can't show you who is on it. Instead:

  • Check an address tells you whether it's on the list and why. It doesn't keep what you type.
  • Add an address puts it on the list so it never gets a campaign.
  • Import a CSV adds many at once, one address per line or a column headed "email", for example unsubscribes from your last email provider. The result gives counts only.
  • Remove is offered only for addresses staff added. A person who unsubscribed, complained or bounced has to confirm again themselves.

API and agents

Everything you can do with campaigns in the admin, an integration or an AI agent can do too.

REST API

The endpoints are under /v1/admin/messaging, tagged Email Campaigns in the API reference, and are for admin and superadmin tokens only. Each call acts on the shop the token belongs to; a campaign from another shop is a 404.

Call What it does Other answers
GET /campaigns?status=&page=&size= A page of campaigns, newest first
POST /campaigns Creates a draft 422 with problems for content that can't be saved
GET /campaigns/{id} One campaign 404
PUT /campaigns/{id} Changes a draft; send the version you read 409 if it isn't a draft or was changed since; 422
DELETE /campaigns/{id} Deletes a draft 409 if it isn't a draft
POST /campaigns/{id}/schedule sendAt (with its offset, up to 90 days ahead) or sendNow: true, and confirmAudience: the number GET /audience gives for the campaign's list right now 409 if it isn't a draft; 422 with problems if it can't be sent as it is (such as an unverified sending domain) or confirmAudience is missing or out of date (AUDIENCE_NOT_CONFIRMED, with the current number)
POST /campaigns/{id}/{unschedule,pause,resume,cancel,duplicate} Changes its state; duplicate returns the new draft 409 if the status doesn't allow it; 422 if it can't resume
POST /campaigns/{id}/test Up to 5 addresses, 20 test sends a shop an hour; sent and refused (not email addresses) 400 for more than 5; 429 over the hourly limit, with rateLimited: true and nothing sent
GET /campaigns/{id}/preview The rendered email and what stops it being sent
GET /campaigns/{id}/stats Counts and rates; open and click rates are left out while tracking is off
GET /audience?topic= How many people a list reaches now (general if left out)
GET, POST, DELETE /sending-domain, POST /sending-domain/check The shop's sending domain, its DNS records and status 404 with none; 409 if one exists already or campaigns are using it; 422 for a host that isn't accepted
GET, POST /suppressions, POST /suppressions/check, DELETE /suppressions/{id} The do-not-email list 422 (INVALID_ADDRESS) for something that isn't an email address; 404 for an unknown entry; 409 removing an entry that isn't manual

The do-not-email list never gives an address back: each entry shows its reason, where it came from, when, and the first 8 characters of its fingerprint. Addresses go in request bodies (never the URL) and are not logged.

Agents (MCP)

campaigns reads (topics, list, get, stats, text preview, audience, sending domain) and manageCampaigns changes (create, update, duplicate, delete, test send, schedule, unschedule, pause, resume, cancel, and set, check or remove the sending domain). campaigns topics lists the shop's mailing lists (key, name, description, how many confirmed subscribers, whether archived), so an agent picks a list before it reads the audience or creates a campaign. legalDocuments (read-only) shows the terms of sale and privacy notice: the page and label in use, whether the page has changed since customers were shown it, and the stored texts with when each was first shown and how many acceptances carry it. They work for the connected shop only and use the same checks as the admin. Two rules keep an agent from emailing people it shouldn't:

  • Scheduling needs the audience confirmed. schedule refuses unless confirmAudience equals the number campaigns audience gives for the campaign's list right now. If the list changed in between, the refusal says the new number and nothing is scheduled.
  • No recipients. A campaign only ever goes to the list's confirmed subscribers who aren't on the do-not-email list. No action takes an address for a real send, and the do-not-email list isn't available over MCP. Only test sends take addresses (at most 5, marked as tests).

Changing the sending domain over MCP needs the mcp:admin scope. removeSendingDomain also needs the domain's name in domainName, the same typed confirmation as the admin: a different name removes nothing, and it is refused while a campaign is scheduled, sending or paused. opsInbox has a campaigns line for campaigns the system paused and a sending domain that needs action. Details, scopes and the tool conventions are in MCP.

Personal data requests, deleting a site and copying one

  • A subject access request lists the campaign emails sent to the person in the last 13 months (campaign name, result, and when it was sent, delivered, opened and clicked) and any of their addresses on the do-not-email list.
  • Deleting a site deletes its campaigns, sends, delivery events, do-not-email list and sending domain (and the domain and webhook at Mailtrap, best effort).
  • Copying a shop leaves all of that behind, along with the settings that name the shop's Mailtrap account.

For developers

Piece Role
MessageChannelProvider, MessageChannelRegistry One provider per channel; email campaigns use the Mailtrap bulk stream
MailtrapAccountResolver Which Mailtrap account a shop's campaigns use: Badger's, or the shop's own
MailtrapAccountClient Sending domains, company details, webhooks and suppressions in that account
MailtrapWebhookService Checks the signature of Mailtrap's event webhooks and queues the events
SendingDomainService The shop's sending domain: DNS records, verification, whether campaigns may send
SuppressionService, ProviderSuppressionSync The do-not-email list (fingerprints, never addresses) and keeping Mailtrap's own list in step
MarketingAudienceService Who may get a marketing email: the one rule campaigns and the subscriber export share
CampaignService, CampaignRenderer, CampaignBlocks Campaigns, their state, content blocks and rendering
MessageSendService, CampaignDispatchService, MessageSendProcessor, MessagingRateLimiter One row per recipient, queuing the audience, sending in batches, and the sending rate
MessageSendPublisher Puts send batches (ids only) on messaging-sends-queue, or on the 30-second delay queue
CampaignDispatchJob, CampaignCompletionJob (worker) Each minute: dispatch due campaigns under the lease; for every sending campaign, fail stuck sends, mark finished campaigns sent, and re-publish queued sends whose message is overdue
MessageSendListener, DeliveryEventListener (worker) Consume send batches and provider event batches, with bounded retry and a parking queue
DeliveryEventService Applies delivery, bounce, complaint, unsubscribe, open and click events once each

The send engine

  • Dispatch. CampaignDispatchJob lists CampaignService.dueForDispatch and calls CampaignDispatchService.dispatch with an owner of hostname plus a UUID. claimDispatch is the lease (2 minutes, renewed after every page): the loser does nothing. Pages of 500 come from MarketingAudienceService.page after the saved cursor; sends are inserted by idempotency key campaign:{campaignId}:{contactId}:EMAIL, and a send's _id is derived from its site and key, so a page replayed after a crash adds no rows and re-publishes the ids the crashed run may not have. A page's counts (QUEUED, AUDIENCE, SKIPPED, SUPPRESSED) go with its cursor move in one conditional update (CampaignService.advanceDispatch: lease owner and the old cursor must still match), so a page replayed wholly or partly is counted once. The status and lease owner are read again before each page, so pause, cancel and a lost lease stop dispatch at the next page. Back pressure is per shop: the sends QUEUED or SENDING across all its campaigns stay within campaign-max-per-hour (a page is cut to the room left; with none, dispatch carries on next run).
  • Sending. MessageSendProcessorImpl follows the numbered steps on MessageSendProcessor. claim (QUEUED to SENDING, a findAndModify per row) happens before the provider call, so a redelivered message sends nothing twice. An unknown outcome (a timeout after the request went, a missing per-message result) is FAILED UNKNOWN_OUTCOME; a send left SENDING for 15 minutes by a dead worker is failed the same way by CampaignCompletionJob. A known-not-sent transport failure (401/403, 429, 503, network before sending) releases the rows onto the delay queue (30 seconds), as do the provider's per-message RETRYABLE and a short limiter wait; nothing is thrown into the listener's retries. After 120 such delays (an hour) the campaign is paused (PROVIDER_UNAVAILABLE or RATE_LIMITED) with its rows still QUEUED. A whole batch refused as invalid pauses it PROVIDER_REJECTED. When the shop's hour is spent, the rows wait on a message carrying notBefore (the next hour), which cycles through the delay queue untouched and isn't counted as a delay. A cancel skips only QUEUED rows; one another worker holds (SENDING) is left to it.
  • Sweep. Each row records dueAt, when the message carrying it is due. CampaignCompletionJob calls CampaignDispatchService.sweep for every sending campaign: QUEUED rows 15 minutes past dueAt (a lost message, or one parked on messaging-sends-dead-queue after an unexpected error) are published again; publishing twice is harmless, since rows are claimed atomically. A row re-published three times without being worked pauses the campaign SENDS_STALLED, so it shows in the ops inbox.
  • Rate limit. MessagingRateLimiterImpl runs one Lua script over two fixed windows in Redis: messaging:rl:site:{siteId}:{epochHour} (campaign-max-per-hour) and messaging:rl:provider:{providerId}:{epoch10s} (badger.messaging.mailtrap.max-requests-per-10s, 100). Partial grants are allowed. It fails closed (nothing granted, retry in 30 seconds).
  • Lifting. SuppressionLiftListener acts only on DOUBLE_OPT_IN_CONFIRMED: it lifts OPTED_OUT, HARD_BOUNCE and INVALID, and COMPLAINT when the click (the confirming record's time) is later than the row's last change. Every other grant for an address suppressed OPTED_OUT, COMPLAINT or HARD_BOUNCE, or for a topic whose newest counting decision is the person's own refusal (staff, erasure, retention and imports don't count; pending grants are passed over), is stored pending by ConsentService.record (needsConfirmation, which fails closed); MarketingSignupService sends the confirmation for form, account, booking and registration grants, and checkout asks for it with requestConfirmation. confirmPending with ORDER_VERIFIED confirms nothing for such an address.
  • Provider suppression sync. ProviderSuppressionSyncImpl clears Mailtrap's unsubscription entry only after a confirmation click made later than the opt-out (SuppressionLiftListener), and only when nothing is left on our list for the address; never on a reject. Each HTTP call (the search, then each delete) takes one unit of the shared budget of 8 a minute across every worker. In the platform account it only touches entries for the shop's own domain.
  • Delivery events. DeliveryEventServiceImpl writes the event id to messagingDeliveryEvent (unique per site, provider and event id) before applying it, and removes it again if applying fails, so the retry applies it afresh. Counters move only when MessageSendService.applyEvent changed the row. An event more than 6 days old by its own timestamp is dropped (the ledger keeps 7 days), so a replayed body can't apply again. A reject whose reason is Mailtrap's suppression or unsubscription withdraws every list on email (PROVIDER_FEEDBACK, PROVIDER_UNSUBSCRIBE) and suppresses OPTED_OUT.
  • Error codes on a send: UNKNOWN_OUTCOME, CANCELLED, RENDER_ERROR, NOT_ELIGIBLE:{reason}, PROVIDER_REJECT, or the provider's own code. Never text from the provider.
  • Pause reasons. A campaign the system paused carries statusReason: DOMAIN_NOT_VERIFIED (the domain can't send: unverified, suspended, or the Mailtrap account changed), PROVIDER_AUTH (three AUTH failures in a row), PROVIDER_NOT_CONFIGURED (no provider for the channel for the shop), PROVIDER_REJECTED (a whole batch refused as invalid), PROVIDER_UNAVAILABLE (an hour of provider failures), RATE_LIMITED (an hour of short limiter waits) or SENDS_STALLED (the sweep gave up).
  • Endpoints. Deliveries and bounces also update the contact's endpoint through ContactService.recordDeliveryFeedback, one atomic update each: lastDeliveredAt, lastBouncedAt and consecutiveSoftBounces, reset by a delivery that isn't older than the last bounce. The third soft bounce in a row sets an ACTIVE endpoint BOUNCED (soft-repeated), which the audience rule leaves out, without a suppression.
  • Previews. CampaignService.previewHtml fills a frozen render's placeholders (%%BDGR_FIRST_NAME%% and its %%BDGR_FIRST_NAME|fallback%% form, %%BDGR_UNSUBSCRIBE_URL%%, %%BDGR_PREFERENCES_BLOCK%%) for the sample recipient through the same CampaignRenderer.personalise a send uses, so the admin preview shows what a recipient gets.
  • One locale. CampaignRendererImpl.renderShared renders once per campaign, in the shop's default locale (MailtrapEmailService.resolveLocale); Contact.locale isn't used for campaigns yet. The extension point is the render: a render per locale in the audience (CampaignRender keyed by campaign and locale), chosen in MessageSendProcessorImpl by the recipient's locale with the default as fallback. Text the merchant writes would still need a per-language version of the content.