feat: add Anypost as a bounce webhook provider#3145
Open
danhstevens wants to merge 1 commit into
Open
Conversation
Adds Anypost (anypost.com) as a bounce webhook provider at
/webhooks/service/anypost. Anypost POSTs batches of events signed with a
Stripe-style signature header (Anypost-Signature: t={ts},v1={hmac}, one
v1 per active signing secret during rotations); the handler verifies the
HMAC-SHA256 signature against the raw body with a 300s timestamp
tolerance and maps events to bounces:
- email.bounced -> hard (permanent) or soft (transient)
- email.complained -> complaint
- email.suppressed -> hard (data.suppression.reason=permanent_bounce) or
complaint (=complaint); unsubscribed/manual reasons
are opt-outs, not bounces, and are ignored
Mapping email.suppressed keeps listmonk's subscriber state in sync with
Anypost's suppression list even when listmonk never saw the original
bounce/complaint (e.g. it was recorded before the integration was wired
up, or on a different send path).
Campaign attribution rides on these events: Anypost echoes customer-set
X-headers, so the X-Listmonk-Campaign header set on outgoing campaign
mail comes back as data.headers.x_listmonk_campaign.
Per the v7 frontend freeze (knadh#3073) this deliberately makes no admin UI
changes; the settings live under bounce.anypost and are configurable via
the settings API (documented in docs/content/bounces.md). Since the
settings form has no Anypost fields to strip the masked dummy key,
UpdateSettings treats an all-mask key as unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Anypost as a native bounce webhook provider at
/webhooks/service/anypost, following the pattern of the existing providers.What it does
Anypost POSTs batches of events (
{"batch_id", "timestamp", "events": [...]}) signed with a Stripe-style header:The handler verifies HMAC-SHA256 over
{t}.{raw_body}with a 300s timestamp tolerance. The header carries onev1per active signing secret (two during a secret rotation), and a match on any one passes — so verification keeps working through rotations.Event mapping:
email.bounced(bounce.type: permanent)hardemail.bounced(bounce.type: transient)softemail.complainedcomplaintemail.suppressed(suppression.reason: permanent_bounce)hardemail.suppressed(suppression.reason: complaint)complaintemail.suppressed(suppression.reason: unsubscribed/manual)email.suppressedfires when Anypost drops a send because the address was already on the account's suppression list. Itsdata.suppression.reasonsays why, which is what lets us map it safely:permanent_bounceandcomplaintare bounce-like and record a bounce, so listmonk converges with Anypost's state even if it never saw the original event;unsubscribedandmanualare opt-outs and are ignored (recording them as bounces would blocklist recipients who merely opted out). A suppressed event with nosuppressionblock is skipped.Campaign attribution rides on these events: Anypost echoes customer-set X-headers back, so the
X-Listmonk-Campaignheader listmonk already sets on campaign mail comes back asdata.headers.x_listmonk_campaignand resolves to the campaign. Feedback-loop complaints and out-of-band bounces carry no headers, so those record against the subscriber without a campaign.Deliberately no admin UI changes
Per the v7 frontend freeze in CONTRIBUTING.md / #3073, this PR touches no frontend files. The settings live under
bounce.anypost({"enabled": bool, "key": string}) and are configurable via the settings API;docs/content/bounces.mddocuments the exact steps. Because the settings form has no Anypost fields to strip the masked dummy key,UpdateSettingstreats an all-mask key as unchanged, so UI settings saves can't clobber a stored secret.I've kept this backend-only per the freeze. I also have the admin-UI additions (Bounces-tab fields + an
smtp.anypost.comSMTP preset) prototyped against the current Vue UI — happy to contribute the equivalent to the new SSR admin UI once v7 lands, or to fold the Vue version into this PR now if you'd prefer it before the migration.Testing
email.suppressedmapping covered per reason:permanent_bounce→ hard,complaint→ complaint,unsubscribed/manual/no-block → skipped, with campaign attribution from the echoed header.v1rotation case.--installruns with the newschema.sqlrow; thev6.2.0migration inserts the setting idempotently for upgrades (appended to the pending v6.2.0 migration, mirroring how the Azure ACS setting was added).