Mandrill bounce handling: supported in core but (AFAICT) undocumented

Core has a working Mandrill bounce endpoint - POST /webhooks/mandrill in WebhooksController - which processes hard_bounce and soft_bounce events, applies hard_bounce_score / soft_bounce_score, and disables addresses past bounce_score_threshold. Matching relies on the X-MC-Metadata message_id header that Email::Sender adds when DISCOURSE_SMTP_ADDRESS is exactly smtp.mandrillapp.com.

I was trying to set this up but hit some issues which I thought worth sharing in case it helps others. I know it’s a niche use-case, since Mandrill stopped doing ‘just SMTP’ over a decade ago, and only now provides the SMTP service as an add-on to the Mailchimp transactional email product. Nevertheless a customer of mine had exactly this setup and so here we are!

1. It isn’t documented anywhere I can find. The VERP topic doesn’t mention it, which is reasonable, because VERP is not how Mandrill works: Mailchimp’s own docs say it handles bounces itself even when you configure a custom Return-Path domain, so per-message bounces never reach a self-hosted mail-receiver. The provider webhooks are the only route, and there’s no obvious pointer to them for anyone starting from the VERP topic.

2. The webhook can’t actually be created. WebhooksController#mandrill returns 406 whenever mandrill_authentication_key is blank. That behaviour is deliberate - it’s the fix for CVE-2026-26077 (Discourse 2025.12.2 / 2026.1.1 / 2026.2.0), which closed unauthenticated forging of bounce payloads across the SendGrid, Mailjet, Mandrill, Postmark, SparkPost and Mailpace endpoints. So this isn’t a request to loosen it; it’s that the fix left Mandrill with no way to bootstrap.

Mandrill validates a new webhook with a POST (not the HEAD that mandrill_head was written for), sees the 406, and refuses to save the webhook - so the key you need is never viewable. The API path fails identically:

{"status":"error","code":-98,"name":"ValidationError","message":"Unable to validate webhook URL"}

Origin log for a UI attempt:

"POST /webhooks/mandrill HTTP/2.0" "Mandrill-Webhook/1.0" 406

The other providers have account-level tokens, so an admin can set an arbitrary secret before creating the webhook. Mandrill’s key is issued per webhook, only after the webhook saves - so it is a Catch-22.

Pretty sure the flow could be fixed, perhaps by responding 200 to the first verification (which has no mandrill_events payload), allowing creation on Mandrill, then the secret can be copied to DIscourse manually, and we’re away. Happy to open a PR if that would be acceptable.

3. Workaround, if anyone needs it today. Create the webhook against a URL that already returns 200, with no triggers (bounce payloads contain recipient addresses, so never point a triggered webhook at a placeholder), then install the key and move it:

# 1. create against a placeholder, no events
curl -sS -X POST https://mandrillapp.com/api/1.0/webhooks/add.json \
  -H 'Content-Type: application/json' \
  -d '{"key":"<api-key>","url":"https://httpbin.org/status/200","description":"Discourse bounce handling","events":[]}'

# 2. put the returned auth_key into the mandrill_authentication_key site setting

# 3. repoint at the forum and enable triggers - this now passes validation
curl -sS -X POST https://mandrillapp.com/api/1.0/webhooks/update.json \
  -H 'Content-Type: application/json' \
  -d '{"key":"<api-key>","id":<id>,"url":"https://<forum>/webhooks/mandrill","description":"Discourse bounce handling","events":["hard_bounce","soft_bounce"]}'

Tested on 2026.9.0-latest.