Emailing voters when a feature ships: unsubscribe, link scanners and sending once

Who should get the shipped email, what RFC 8058 one-click unsubscribe requires, why the unsubscribe link must not act on GET, and how to avoid sending twice.

7 min read

A shipped request with 42 votes, and one email sent to each voter carrying the List-Unsubscribe header from RFC 8058.

People vote on a feature request for one reason: they want to hear when it happens. The email that tells them is two sentences long, and it is still easy to get wrong. It goes to the wrong people, it arrives twice, or its unsubscribe link is clicked by a virus scanner before the reader ever opens it.

This is a walk through the mechanics, from choosing recipients to the HTTP request a mail client sends when someone presses "unsubscribe". The standards are short and worth reading in full. The links are at each step.

Who should get it

Start from the request, not from a mailing list. The people who care about a shipped request are the ones who followed it, and following should happen without anyone thinking about it:

  • The author follows automatically. They asked.
  • Voters follow automatically. A vote is a request to be told.
  • Anyone can follow or unfollow by hand, without voting.
  • Merging carries followers across. When a duplicate is merged into the main request, its followers move too. Otherwise the people who phrased the request slightly differently are the ones who never hear back.

A changelog entry often covers several requests. Someone who followed three of them should get one email, not three, so the recipient list is the union of followers across the entry's linked requests, with each person counted once.

Then subtract: people who unsubscribed from this board, and addresses your mail provider has marked as bounced or complained. Sending to a suppressed address does nothing for the reader and costs you sending reputation.

What it should say

The reader voted weeks or months ago and has forgotten. The email has to remind them in the first line, so name the request they followed, not only the entry's title. Something like:

Shipped: CSV export for invoices

Acme posted an update on the API board: CSV export for invoices. It answers a request you follow: Export invoices as CSV.

Two small decisions are worth copying:

  1. Say "Shipped" only when it is true. If every request the entry links is done, the subject can say so. If the entry is a partial step, call it an update. A "shipped" email for half a feature generates the replies you were trying to avoid.
  2. Say why they are getting it. One sentence in the footer, such as "You are getting this because you voted for or followed a request on this board", turns a confusing email into an obvious one.

Link to the changelog entry rather than putting the whole entry in the email. Emails cannot be corrected after they are sent, and pages can. Readers who prefer a feed to email can follow the same entries by RSS, which has its own small set of rules.

There are two unsubscribe mechanisms and a shipped notice should have both.

The header. RFC 2369 defines List-Unsubscribe, a header that tells the mail client where to send an unsubscribe. RFC 8058 adds List-Unsubscribe-Post: List-Unsubscribe=One-Click, which tells the client it may unsubscribe the reader with a single HTTPS POST, with no page to visit. That is what powers the "Unsubscribe" button some mail apps show next to the sender's name.

List-Unsubscribe: <https://example.com/api/unsubscribe/q8Zr...>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

RFC 8058 is two pages of rules and most of them matter:

  • The List-Unsubscribe header must contain one HTTPS URI.
  • The message must carry a valid DKIM signature that covers both headers. Without it, the receiver "SHOULD NOT offer a one-click unsubscribe."
  • The POST must not include cookies or authorization, so the URI itself has to identify the reader and the list. The RFC recommends "an opaque identifier or another hard-to-forge component" rather than a plain email address.
  • The server "MUST NOT return an HTTPS redirect", because redirected POSTs have historically turned into GETs.

Is it required? Google's email sender guidelines say that senders of more than 5,000 messages a day to Gmail accounts must support one-click unsubscribe for "marketing messages and subscribed messages" and include "a clearly visible unsubscribe link in the message body." Google's sender guidelines FAQ excludes transactional mail and recommends honouring unsubscribes within 48 hours. Yahoo's sender best practices ask for the same header, recommend the RFC 8058 POST method, and say requests should be processed within two days.

A shipped notice sits between the categories: the reader asked for it, but nothing they did just now triggered it. Do not argue the classification. The header costs one route and a signed token, and it is what keeps annoyed readers on the unsubscribe button instead of the spam button.

The link. The footer link in the body is for readers whose mail app shows no button. Google's FAQ says it does not have to be one-click and can open a preferences page.

Mail security products open links. RFC 8058 states the problem in its first paragraph: "anti-spam software often fetches all resources in mail header fields automatically, without any action by the user, and there is no mechanical way for a sender to tell whether a request was made automatically." Link scanners in corporate mail gateways do the same with links in the body. A GET that changes state will unsubscribe people who never clicked anything, and they will not know why the updates stopped. Practitioners report exactly this in discussions such as this Hacker News thread.

The pattern that survives scanners:

  1. GET shows a page and changes nothing

    "Stop emails from the API board?" with one button.

  2. The button submits a form with POST

    A scanner does not submit forms.

  3. The page offers an undo

    Someone who unsubscribed by mistake can take it back from the same page.

  4. The one-click POST endpoint is separate

    The endpoint from the header answers a plain 200 with no redirect, and it is only reached by a mail client acting on a person's tap.

What one unsubscribe should stop

A product with several public boards should unsubscribe per board, not globally. The person who no longer cares about your API board may still want to hear about the mobile app. Put the board and the person in the token. Then the confirmation page can say exactly what stops ("updates from the API board"), and the change is easy to reverse.

Sending exactly once

The failure that does the most damage is the duplicate: an entry is published, a worker crashes halfway through, the job retries, and the first half of your voters get the email twice.

A shape that holds up under retries:

  1. One announce job per published entry

    It reads the recipient list once and enqueues one send job per person, in the same database transaction that records the fan-out. If the announce job runs twice, the second run finds the work already queued.

  2. Key each send by entry and person

    For example entry-id/user-id, so the queue refuses a second job with the same key.

  3. Each send is its own retry unit

    One bad address does not hold up or repeat the others.

  4. Call the mail provider outside any database transaction

    Pass an idempotency key derived from the same entry and person.

The last two keys look redundant and are not. A provider's idempotency key covers a limited window. Resend, for example, keeps keys for 24 hours. A job retried after a long outage, or an entry unpublished and published again next week, needs your own record of who was already told.

Checklist

  • Recipients are followers of the entry's linked requests, one email per person, minus unsubscribed and suppressed addresses.
  • Authors and voters follow automatically; merges move followers.
  • The subject says "Shipped" only when every linked request is done.
  • The body names the request the reader followed and says why they are getting the email.
  • List-Unsubscribe with an HTTPS URI and an opaque token, plus List-Unsubscribe-Post: List-Unsubscribe=One-Click, both covered by DKIM.
  • The one-click endpoint takes a POST without cookies and answers without a redirect.
  • The footer link opens a confirmation page; GET changes nothing.
  • Unsubscribe is scoped to one board and can be undone.
  • Fan-out is keyed by entry and person, and the provider call happens outside the transaction.

Done well, the email is the least remarkable thing you send: one short message to the right people, once, with an exit that works.