This guide is the adopter contract for Mailglass one-click unsubscribe. It covers the config, router mount, read-only generator, built-in GET page, POST replay behavior, DKIM follow-up, and a safe secret_key_base rotation playbook.
1) Configure the compliance endpoint
Add the compliance subtree in config/runtime.exs:
config :mailglass, :compliance,
endpoint: MyAppWeb.Endpoint,
host: "unsubscribe.example.com",
scheme: "https",
mount_path: "/mailglass/unsubscribe",
previous_secrets: [],
redirect: nil,
lifecycle: MyApp.MailLifecycleKey points:
endpointis optional. When omitted, Mailglass falls back toMailglass.Tracking.endpoint/0.hostmust be the public unsubscribe origin. Mailglass rejects local, private, path-bearing, and malformed hosts.mount_pathis the canonical absolute path prefix used in generated links.previous_secretsis the rotation escape hatch for oldsecret_key_basevalues.redirectaffects GET only. POST one-click requests always return HTTP 200 with an empty body.lifecyclelets you extend the durable unsubscribe transaction with adopter-specific work.
2) Mount the router macro
Mount the built-in controller through Mailglass.Router:
defmodule MyAppWeb.Router do
use Phoenix.Router
import Mailglass.Router
scope "/" do
pipe_through :browser
mailglass_router_routes "/mailglass"
end
endThat macro expands to one GET route and one POST route:
GET /mailglass/unsubscribe/:tokenPOST /mailglass/unsubscribe/:token
Keep the macro path aligned with mount_path. mailglass_router_routes "/mailglass" and mount_path: "/mailglass/unsubscribe" are the matching default pair.
3) Use the generator as a checklist, not a code copier
Run the read-only checklist task:
mix mailglass.gen.unsubscribe
The task prints config, router, preflight, UAT, and DKIM instructions. It copies zero files. Re-running it should still copy zero files.
4) Understand GET vs POST behavior
Mailglass ships one controller for both mailbox-provider POSTs and user-visible browser GETs:
GET /mailglass/unsubscribe/:tokenrenders a built-in confirmation page by default.- If
redirectis configured, GET redirects to that path instead of rendering the library page. POST /mailglass/unsubscribe/:tokenis the RFC 8058 one-click endpoint.- POST returns
200with an empty body for the first click and for replayed clicks. - Replayed POSTs converge on the same durable
:unsubscribedevent instead of creating duplicates.
Use the built-in page if you want a safe default. Use redirect only when your app owns the confirmation UI.
5) Wire lifecycle hooks only for transaction-local side effects
lifecycle modules implement Mailglass.Lifecycle.handle_event/2 and receive the in-flight Ecto.Multi. Keep the hook limited to work that must commit atomically with the unsubscribe event.
defmodule MyApp.MailLifecycle do
@behaviour Mailglass.Lifecycle
@impl true
def handle_event(multi, %{event: :unsubscribed, tenant_id: tenant_id, delivery_id: delivery_id}) do
Ecto.Multi.insert(
multi,
{:audit_unsubscribe, delivery_id},
MyApp.UnsubscribeAudit.changeset(%MyApp.UnsubscribeAudit{}, %{
tenant_id: tenant_id,
delivery_id: delivery_id
})
)
end
endDo not use the lifecycle hook for post-commit fan-out. Mailglass keeps broadcast work outside the transaction.
6) Rotate secret_key_base without breaking in-flight links
previous_secrets exists for emergency or scheduled endpoint-secret rotation. Add the old raw secret_key_base values before switching the endpoint to the new secret:
config :mailglass, :compliance,
endpoint: MyAppWeb.Endpoint,
host: "unsubscribe.example.com",
scheme: "https",
mount_path: "/mailglass/unsubscribe",
previous_secrets: [
System.fetch_env!("MAILGLASS_OLD_SECRET_KEY_BASE")
]Rotation playbook:
- Deploy with the new endpoint secret and the old secret in
previous_secrets. - Confirm an old unsubscribe link still resolves.
- Wait out your unsubscribe token lifetime, or at least the window in which old emails are still likely to be clicked.
- Remove the old secret from
previous_secretsin a later deploy.
This keeps old links valid while all newly generated links use the current secret.
7) UAT checklist
Run these checks before rollout:
- Generate a real unsubscribe link from a bulk delivery.
- Browser GET check: visit
GET /mailglass/unsubscribe/:tokenand confirm the built-in page renders, or confirm the configured redirect lands on your app page. - One-click POST check:
POST /mailglass/unsubscribe/:tokenwith the same token and confirm the endpoint returns200without redirecting. - Replay POST check: repeat the same POST and confirm it still returns
200. - Generator check: rerun
mix mailglass.gen.unsubscribeand confirm it still copies zero files. - DKIM check: inspect an actual delivered message and verify both
List-UnsubscribeandList-Unsubscribe-Postappear in the DKIMh=list.
8) Troubleshooting
User sees "unsubscribe token invalid"
- Confirm the route really is
GET /mailglass/unsubscribe/:token. - Confirm
mailglass_router_routes "/mailglass"matchesmount_path: "/mailglass/unsubscribe". - Confirm the token was generated by the same environment and host configuration that is serving the route.
Old links broke after a deploy
- Add the previous raw
secret_key_basevalues toprevious_secrets. - Re-test an old link before removing the legacy secret.
Inbox UI does not show one-click unsubscribe
- Confirm the message stream is
:bulk, or:operationalwith explicit opt-in. - Confirm both unsubscribe headers are present.
- Confirm both headers are DKIM-signed in
h=. - Verify your ESP did not strip
List-Unsubscribe-Post.
POST created multiple unsubscribe rows
That is a bug. Mailglass is designed to converge replayed POSTs onto the same durable :unsubscribed event. Re-run the POST replay check and inspect event idempotency before rollout.