Messaging Channels¶
Marketing messages (email campaigns now; text messages, WhatsApp and push notifications later) go
through one provider interface, MessageChannelProvider, so each channel plugs in without changing
how campaigns are queued, sent or counted. Transactional email (order and account emails) keeps
EmailService and its own queue.
The provider interface¶
| Type | Role |
|---|---|
MessageChannelProvider |
One provider for one ContactChannel: id(), isConfigured(site), capabilities(), send(site, batch) |
OutboundMessage |
One message: the send id, the address, the rendered content, headers and metadata. Metadata (bsid send id, bcid campaign id, bsite site id) is never personal data; providers hand it back on their events. toString() prints the send id only |
EmailContent, SmsContent, WhatsAppContent, PushContent |
The rendered message for each channel |
DeliveryResult |
One per message, in order: ACCEPTED (with the provider's message id), REJECTED (refused for good, with a code), RETRYABLE, or UNKNOWN |
ChannelTransportException |
The whole batch failed. code(): AUTH, RATE_LIMITED, UNAVAILABLE, TIMEOUT, BAD_REQUEST. outcomeUnknown(): the request may have been accepted, so the sends must not be retried |
MessageChannelRegistry |
The provider named by badger.messaging.provider for a channel, when it is configured for the site |
Error codes are codes, never a provider's text: providers' messages often quote the recipient's address.
The Mailtrap bulk provider¶
MailtrapBulkProvider (id mailtrap, email) calls Mailtrap's batch endpoint
(bulk.api.mailtrap.io/api/batch, at most 500 messages a call) through the Mailtrap Java SDK, one
request per message with its own recipient, headers and custom_variables. In sandbox mode
(MAILTRAP_SANDBOX_MODE, or the site's mailtrapSandboxMode) it sends to the sandbox inbox instead.
| What happened | Result |
|---|---|
| A message in the batch succeeded | ACCEPTED, with its message id |
| A message in the batch failed | REJECTED, code invalid_address, suppressed or other |
| HTTP 401 or 403 | ChannelTransportException AUTH, not sent |
| HTTP 429 | RATE_LIMITED, not sent |
| HTTP 503 | UNAVAILABLE, not sent |
| HTTP 500, 502, 504 or any other 5xx | UNAVAILABLE, outcome unknown: the batch may have been taken before the error, and batch sends have no idempotency key, so the sends are never retried |
| Another 4xx, or a body the SDK refuses | BAD_REQUEST, not sent |
| The connection never opened | UNAVAILABLE, not sent |
The connection failed after the request went, no answer within badger.messaging.mailtrap.request-timeout-seconds (60), or an answer that doesn't match the batch |
TIMEOUT or UNAVAILABLE, outcome unknown |
| No usable Mailtrap account | AUTH |
capabilities().maxRequestsPer10s is badger.messaging.mailtrap.max-requests-per-10s (100, under
Mailtrap's 150 per token).
Which Mailtrap account¶
MailtrapAccountResolver.forSite(site) is the only place that decides. With the site's
mailtrap-account-id set, it is the shop's own account, with mailtrap-bulk-api-key, else the
site's mailtrapApiKey; a shop account is never used with the platform's token. Otherwise it is
the platform account: system-mailtrap-account-id (or the MAILTRAP_ACCOUNT_ID environment
variable), with system-mailtrap-bulk-api-key, else system-mailtrap-api-key (or MAILTRAP_API_KEY).
MailtrapAccount.fingerprint() (PLATFORM:{id} or SHOP:{id}) is stored on the sending domain, so a
domain created in one account isn't used once the shop resolves to another.
MailtrapAccountClient is the account API in that account: sending domains (create, read, delete,
and configureDomain, which always turns Mailtrap's automatic unsubscribe link off), company details
for a domain, the domain's bulk-stream webhook (its signing secret is returned once, at creation),
and the two calls that clear a bulk unsubscription suppression: findUnsubscriptions (one search
call, returning the ids for one address on the shop's domain) and deleteSuppression (one delete call
per id). An entry with no domain is account-wide: it is returned only in the shop's own account, never
in Badger's shared platform account, where it may belong to another shop.
A 4xx from createDomain or findDomain comes back as MailtrapRefusedException with the HTTP
status. On a 422 in the shop's own account, SendingDomainService.add looks the domain up in the
account's list and takes it over (never in the platform account, where it could be another shop's);
otherwise the admin is told why by status (401 token, 403 permissions, 422 Mailtrap's reason).
findDomain reads the list through MailtrapClients.httpFor, because the SDK's list call expects a
bare array and the API answers {"data": [...]}.
MailtrapClients keeps one SDK client per account and token.
Provider events¶
Mailtrap posts its events to POST /api/public/v1/messaging/webhooks/mailtrap/{webhookKey} on the
shop's own domain (rest, no JWT; the shop comes from the Host). MessagingWebhookRawBodyFilter
keeps the exact bytes (up to 1 MB); MailtrapWebhookService then checks, in order, the webhook key
(an active sending domain of this shop), the Mailtrap-Signature header (hex HMAC-SHA256 of the raw
body, keyed with the site's campaign-webhook-signing-secret), and only then parses the body
({"events": [...]} or JSON Lines). Answers: 200 accepted, 400 no signature or an unreadable body,
401 a wrong signature, 404 not a shop or an unknown key, 413 too big.
MailtrapEventMapper turns each event into a DeliveryEvent (event id, type, send id, message id,
time, a short reason code). It keeps an event only when it is from the bulk stream, has an
event_id, and carries our bsid and this site's bsite; an event_id repeated in one body is kept
once. The address, the provider's response text, IP addresses, user agents and click URLs are dropped
there. The events are queued as DeliveryEventBatch messages of up to 500 on
messaging-delivery-events-queue, and the worker applies each event once (messagingDeliveryEvent
ledger, by event id, kept 7 days). The signature has no timestamp, so the worker also drops any event
whose own timestamp is more than 6 days old: a signed body replayed after its ledger rows expired
changes nothing. If queueing fails the request fails, and Mailtrap retries it.
| Mailtrap event | DeliveryEventType |
|---|---|
delivery |
DELIVERED |
soft bounce |
SOFT_BOUNCE |
bounce |
HARD_BOUNCE |
spam, spam_complaint |
COMPLAINT |
unsubscribe |
UNSUBSCRIBE |
open, click |
OPEN, CLICK |
reject |
REJECT |
suspension |
SUSPENSION |
The log provider for development¶
LogMessageChannelProvider (id log) sends nothing: it accepts every email, answers with a
log-{uuid} message id and logs the count. Choose it with badger.messaging.provider=log. With
badger.messaging.log-provider.deliver=true it also reports each message DELIVERED on the delivery
events queue, so sending, the counters and the report run locally without Mailtrap.
badger.messaging.provider defaults to mailtrap in every profile yml. The local stack
(dev-scripts/devEnv.sh) passes BADGER_MESSAGING_PROVIDER=log to web, rest and the worker unless
~/devEnv.conf sets it otherwise, so a dev stack (often on a copy of real data) never emails a real
list. Set BADGER_MESSAGING_PROVIDER=mailtrap there to send for real, and
BADGER_MESSAGING_LOG_PROVIDER_DELIVER=true to have the log provider report deliveries. Any other run
takes the property (or the BADGER_MESSAGING_PROVIDER environment variable) the same way.
The provider only replaces the sending. Scheduling still needs a verified sending domain, and the
domain is checked against the shop's Mailtrap account, so a stack with no Mailtrap account can't
schedule a campaign. CampaignSendEngineIntegrationTest runs the dispatch, send and delivery loop
end to end with the log provider and a stubbed domain instead.
Adding a channel¶
- Implement
MessageChannelProvideras a Spring bean for itsContactChannel, returning oneDeliveryResultper message and throwingChannelTransportExceptiononly for a whole-batch failure (withoutcomeUnknownwhen the request may have gone). - Keep provider text out of results and logs; put ids in the message metadata so the provider's events can be matched to sends.
- Map the provider's events to
DeliveryEvents, with a signed and size-bounded webhook as above, and queue them asDeliveryEventBatch. - Select it with
badger.messaging.provider(one provider per channel per environment today).