Skip to content

Add RFC 8058 one-click unsubscribe - #238

Open
JonoB wants to merge 1 commit into
masterfrom
one-click-unsubscribe
Open

JonoB wants to merge 1 commit into
masterfrom
one-click-unsubscribe

Conversation

@JonoB

@JonoB JonoB commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Gmail and Yahoo require bulk senders to support RFC 8058 one-click unsubscribe. SendPortal set no List-Unsubscribe header, and unsubscribing took two steps (GET page, then a PUT form).

What changes

  • Headers on every campaign email. List-Unsubscribe: <https://…/subscriptions/unsubscribe/{hash}>, plus List-Unsubscribe-Post: List-Unsubscribe=One-Click when that link is HTTPS (RFC 8058 doesn't allow one-click over plain HTTP, so APP_URL must be https://). All 7 adapters set them in their provider's format: SES Content.Simple.Headers, Postmark Headers, SendGrid headers, Mailgun h: fields, Mailjet Headers, Postal headers, SMTP MIME headers. Automation emails use the same dispatch path and get them too.
  • One-click endpoint. POST subscriptions/unsubscribe/{messageHash} (the same URL as the page) unsubscribes straight away, using the same code as the existing form (MANUAL_BY_SUBSCRIBER). GET still only shows the page, so link scanners can't unsubscribe anyone. The route skips CSRF verification. Excluding the base class also covers its subclasses: VerifyCsrfToken on Laravel 12, PreventRequestForgery on Laravel 13.
  • SES moves to the SESv2 SendEmail operation. The v1 operation can't set custom headers. v2 needs the same ses:SendEmail IAM permission, and AWS recommends it for one-click. The quota check stays on v1.
  • Test sends. Campaign test sends carry the same headers as real sends. Their link uses a placeholder hash that matches no message, so following it returns 404. Email-service test sends get no headers. The placeholder changed from abc123 to an all-zero UUID because abc123 made the unsubscribe and webview pages return 500 on Postgres, where hash is a uuid column.
  • No mailto. SendPortal has no inbound mail processing, so a mailto address would collect requests nobody acts on. Gmail and Yahoo only require the HTTPS one-click.

Breaking: MailAdapterInterface::send() takes a new array $headers = [] argument, so custom adapters must add it. This is noted in the CHANGELOG.

Testing

  • tests/Unit/Adapters/ListUnsubscribeHeadersTest.php checks the request each adapter actually sends: HTTP bodies via mocked HTTP clients, the SendGrid payload, and the raw MIME message for SMTP. All 7 tests fail without the adapter changes.
  • tests/Feature/Subscriptions/OneClickUnsubscribeTest.php dispatches a real campaign message and POSTs to the URL from its header. It also covers HTTP URLs, test sends, and a POST with the CSRF check switched on. It fails if the CSRF exclusion or the header pass-through is removed.
  • The full suite (267 tests) passes on MySQL and Postgres. The suite also passed on Laravel 12 before the final placeholder-hash change.
  • End to end: I ran the real SendPortal app (Laravel 13) over HTTPS, with its API, sp:campaigns:dispatch and a queue worker, comparing the base commit with this branch on MySQL and Postgres. The providers were fakes answering on their real hostnames, and SMTP went to Mailpit.
    • 0/28 → 28/28 emails carry both headers.
    • Each header URL matches that recipient's message.
    • A cookie-less List-Unsubscribe=One-Click POST went from 405 to 200, and the subscriber was unsubscribed.
    • The next campaign skipped them, and the campaign report lists them.
    • The existing form, and CSRF on it, behave as before.
    • A plain Laravel 12 app gave the same results.
  • Not covered: real provider accounts, real SES signing and IAM, and a real Gmail or Yahoo inbox.

Existing issues noticed, not changed here

  • ThrottlesSending compares SesException::getMessage() with 'Maximum sending rate exceeded.', but the SDK wraps that text in a longer message, so the retry never fires. The SDK's own retries still handle throttling.
  • SmtpAdapter leaves the DSN scheme empty unless encryption is tls. An SMTP service saved without encryption fails with The "" scheme is not supported.

https://ampcode.com/threads/T-01a14f00-30c9-7739-a0b3-15e53b5f9917

Gmail and Yahoo require bulk senders to support one-click unsubscribe.

- Campaign emails (and campaign test sends) carry List-Unsubscribe pointing
  at the unsubscribe link, plus List-Unsubscribe-Post: List-Unsubscribe=One-Click
  when that link is HTTPS. Every adapter sets them in its provider's format.
- POST to the unsubscribe link unsubscribes straight away, recorded like the
  existing form (Manual by Subscriber). The route skips CSRF verification on
  Laravel 12 (VerifyCsrfToken) and 13 (PreventRequestForgery).
- SES sends move to the SESv2 SendEmail operation, which can set headers and
  needs the same ses:SendEmail permission. The quota check stays on v1.
- MailAdapterInterface::send() takes an optional $headers argument (breaking
  for custom adapters).
- Test sends use a well-formed placeholder hash, so their links 404 instead of
  erroring on Postgres.

Amp-Thread-ID: https://ampcode.com/threads/T-01a14f00-30c9-7739-a0b3-15e53b5f9917
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