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¶
- 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. - 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.
- 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.
- 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
cancelto 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.
schedulerefuses unlessconfirmAudienceequals the numbercampaigns audiencegives 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.
CampaignDispatchJoblistsCampaignService.dueForDispatchand callsCampaignDispatchService.dispatchwith an owner of hostname plus a UUID.claimDispatchis the lease (2 minutes, renewed after every page): the loser does nothing. Pages of 500 come fromMarketingAudienceService.pageafter the saved cursor; sends are inserted by idempotency keycampaign:{campaignId}:{contactId}:EMAIL, and a send's_idis 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 withincampaign-max-per-hour(a page is cut to the room left; with none, dispatch carries on next run). - Sending.
MessageSendProcessorImplfollows the numbered steps onMessageSendProcessor.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 FAILEDUNKNOWN_OUTCOME; a send left SENDING for 15 minutes by a dead worker is failed the same way byCampaignCompletionJob. 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-messageRETRYABLEand a short limiter wait; nothing is thrown into the listener's retries. After 120 such delays (an hour) the campaign is paused (PROVIDER_UNAVAILABLEorRATE_LIMITED) with its rows still QUEUED. A whole batch refused as invalid pauses itPROVIDER_REJECTED. When the shop's hour is spent, the rows wait on a message carryingnotBefore(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.CampaignCompletionJobcallsCampaignDispatchService.sweepfor every sending campaign: QUEUED rows 15 minutes pastdueAt(a lost message, or one parked onmessaging-sends-dead-queueafter 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 campaignSENDS_STALLED, so it shows in the ops inbox. - Rate limit.
MessagingRateLimiterImplruns one Lua script over two fixed windows in Redis:messaging:rl:site:{siteId}:{epochHour}(campaign-max-per-hour) andmessaging: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.
SuppressionLiftListeneracts only onDOUBLE_OPT_IN_CONFIRMED: it liftsOPTED_OUT,HARD_BOUNCEandINVALID, andCOMPLAINTwhen the click (the confirming record's time) is later than the row's last change. Every other grant for an address suppressedOPTED_OUT,COMPLAINTorHARD_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 byConsentService.record(needsConfirmation, which fails closed);MarketingSignupServicesends the confirmation for form, account, booking and registration grants, and checkout asks for it withrequestConfirmation.confirmPendingwithORDER_VERIFIEDconfirms nothing for such an address. - Provider suppression sync.
ProviderSuppressionSyncImplclears Mailtrap'sunsubscriptionentry 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 areject. 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.
DeliveryEventServiceImplwrites the event id tomessagingDeliveryEvent(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 whenMessageSendService.applyEventchanged 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. Arejectwhose reason is Mailtrap's suppression or unsubscription withdraws every list on email (PROVIDER_FEEDBACK,PROVIDER_UNSUBSCRIBE) and suppressesOPTED_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) orSENDS_STALLED(the sweep gave up). - Endpoints. Deliveries and bounces also update the contact's endpoint through
ContactService.recordDeliveryFeedback, one atomic update each:lastDeliveredAt,lastBouncedAtandconsecutiveSoftBounces, reset by a delivery that isn't older than the last bounce. The third soft bounce in a row sets an ACTIVE endpointBOUNCED(soft-repeated), which the audience rule leaves out, without a suppression. - Previews.
CampaignService.previewHtmlfills 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 sameCampaignRenderer.personalisea send uses, so the admin preview shows what a recipient gets. - One locale.
CampaignRendererImpl.renderSharedrenders once per campaign, in the shop's default locale (MailtrapEmailService.resolveLocale);Contact.localeisn't used for campaigns yet. The extension point is the render: a render per locale in the audience (CampaignRenderkeyed by campaign and locale), chosen inMessageSendProcessorImplby the recipient'slocalewith the default as fallback. Text the merchant writes would still need a per-language version of the content.