Skip to content

feat: add Anypost as a bounce webhook provider#3145

Open
danhstevens wants to merge 1 commit into
knadh:masterfrom
anypost:anypost-bounce-webhook
Open

feat: add Anypost as a bounce webhook provider#3145
danhstevens wants to merge 1 commit into
knadh:masterfrom
anypost:anypost-bounce-webhook

Conversation

@danhstevens

Copy link
Copy Markdown

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:

Anypost-Signature: t={unix},v1={hex_hmac}[,v1={hex_hmac}]

The handler verifies HMAC-SHA256 over {t}.{raw_body} with a 300s timestamp tolerance. The header carries one v1 per active signing secret (two during a secret rotation), and a match on any one passes — so verification keeps working through rotations.

Event mapping:

Anypost event listmonk bounce
email.bounced (bounce.type: permanent) hard
email.bounced (bounce.type: transient) soft
email.complained complaint
email.suppressed (suppression.reason: permanent_bounce) hard
email.suppressed (suppression.reason: complaint) complaint
email.suppressed (suppression.reason: unsubscribed / manual) ignored
anything else ignored

email.suppressed fires when Anypost drops a send because the address was already on the account's suppression list. Its data.suppression.reason says why, which is what lets us map it safely: permanent_bounce and complaint are bounce-like and record a bounce, so listmonk converges with Anypost's state even if it never saw the original event; unsubscribed and manual are opt-outs and are ignored (recording them as bounces would blocklist recipients who merely opted out). A suppressed event with no suppression block is skipped.

Campaign attribution rides on these events: Anypost echoes customer-set X-headers back, so the X-Listmonk-Campaign header listmonk already sets on campaign mail comes back as data.headers.x_listmonk_campaign and 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.md documents the exact steps. Because the settings form has no Anypost fields to strip the masked dummy key, UpdateSettings treats 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.com SMTP 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

  • Verified end-to-end against a running instance: signed batches from Anypost's actual webhook worker are accepted (200), hard/soft/complaint rows recorded with campaign attribution resolved, bounce actions (blocklist) fire, tampered bodies and expired timestamps are rejected (400).
  • email.suppressed mapping covered per reason: permanent_bounce → hard, complaint → complaint, unsubscribed/manual/no-block → skipped, with campaign attribution from the echoed header.
  • Cross-checked the verifier against payloads produced by Anypost's production signing code, including the dual-v1 rotation case.
  • Fresh --install runs with the new schema.sql row; the v6.2.0 migration inserts the setting idempotently for upgrades (appended to the pending v6.2.0 migration, mirroring how the Azure ACS setting was added).

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant