Skip to content

One-click unsubscribe returns 204 without recording, and a broadcast can ship without List-Unsubscribe (ADR 0012) #28

Description

@Novohudonossor

What happens

1. internal/server/tracking.go — POST /e/u/{token} always answers 204

recordUnsubscribe(r.Context(), client, bus, target)
w.WriteHeader(http.StatusNoContent)

recordUnsubscribe has no return value, and it bails out early with a bare return when the destination is empty, the workspace is 0 or the source is empty. This endpoint is the target of the mailbox provider's one-click POST, so Gmail and Yahoo receive 204 and treat the opt-out as done. If the write failed — DB error, race, an empty field after token decoding — the contact stays subscribed and keeps receiving mail, while every layer above believes the unsubscribe succeeded.

ADR 0013 describes recordUnsubscribe as writing both the destination-keyed row and the email.unsubscribed event. Today neither the handler nor its caller can tell whether either of them happened.

2. internal/jobs/broadcast.go:342 — a broadcast can go out without List-Unsubscribe

if url, uerr := tracker.UnsubscribeURL(unsub); uerr != nil {
    logging.FromContext(ctx).Error("broadcast: unsubscribe url failed", ...)
} else {
    listUnsubURL = url
}

On error listUnsubURL stays empty and the message is still sent, with ListUnsubscribeURL: "" — that is, without the header at all. ADR 0012 treats that header as table stakes and ties it directly to keeping the spam-complaint rate low, so a silent per-recipient fallback to "no header" is the exact outcome the ADR is meant to prevent.

What I would expect

  • The handler propagates failure — recordUnsubscribe returning an error, 5xx on a failed write — so the provider retries instead of marking the opt-out done.
  • A recipient whose unsubscribe URL cannot be built fails the same way "contact has no email" already fails, rather than shipping a non-compliant message.

Both are the same shape: a failure that does not stop the flow and is invisible from the outside.

Provenance

Found with ReviewGate (https://reviewgate.dev), a local review gate I build, run over 0d10d33 and re-checked against main. The cross-check against ADR 0012 and 0013 is mine — the run had no access to your rules. Local run, my own model key, nothing left my machine.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions