Skip to content

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

  1. Implement MessageChannelProvider as a Spring bean for its ContactChannel, returning one DeliveryResult per message and throwing ChannelTransportException only for a whole-batch failure (with outcomeUnknown when the request may have gone).
  2. Keep provider text out of results and logs; put ids in the message metadata so the provider's events can be matched to sends.
  3. Map the provider's events to DeliveryEvents, with a signed and size-bounded webhook as above, and queue them as DeliveryEventBatch.
  4. Select it with badger.messaging.provider (one provider per channel per environment today).