73. pepsi-stage-list-deliver

Make one member’s copy of a list post that member’s own copy.

73.1. Role

pepsi-stage-list-post has already made one row per recipient; pepsi-stage-list-deliver is what makes each of them that member’s copy rather than another copy of the same message. One row in, one row out. It finalises List-Unsubscribe with that member’s one-click https: URI alongside the mailto: form and adds List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058); it decorates the message with the list’s header and footer in the member’s language when the list’s personalize is individual or full; and it munges To: to name the member, for full only — upstream’s personalize_to is a no-op for individual, and so is this. Reference: pepsi-stage-list-deliver(1).

This subsystem is a reimplementation of GNU Mailman 3; see Mailing lists, which names what was taken from upstream.

Warning

This stage must run before signing. Every header it touches belongs in the DKIM h= list, and List-Unsubscribe is exactly the header a mailbox provider inspects when it decides whether to show an unsubscribe button. A pipeline that signed first would produce mail that is signed and then modified, which fails DKIM everywhere. So NEXT_STAGE points at pepsi-stage-dkim-sign, not at a relay, and pepsi-setup refuses the reverse. Upstream says the same thing in the comment that removes arc-sign from their own pipeline.

73.2. One-click unsubscribe

The token in the https: URI is a keyed hash over the list, the member’s address and a serial the member can rotate. It is not stored: a List-Unsubscribe URI is emitted once per member per post, so a table of them would grow with deliveries rather than with members. Three things follow.

  • The token never travels between stages — this stage holds the secret and computes it per copy.

  • Revoking every URI ever emitted for one subscription is a bump of that member’s unsubscribe_serial: a counter update rather than a table sweep.

  • There is nothing to expire, because a subscription that no longer exists fails the membership lookup whatever the token says.

The secret is [pepsi-list] UNSUBSCRIBE_SECRET. With no secret, or no BASE_URL to build a URI from, the mailto: form stands alone, as upstream’s does: an unverifiable URI is worse than none. Rotating the secret invalidates every URI already sitting in every subscriber’s mailbox.

73.3. Decoration

The header and footer are rendered from [pepsi] TEMPLATE_DIR as list-header.<lang>.body and list-footer.<lang>.body, the member’s language first and en as the fallback. A list with no template files is not decorated, which is the common case and costs nothing.

The footer is appended to the first ``text/plain`` part and to nothing else. Appending to a multipart/signed part breaks the signature and appending to a PDF corrupts it, so a message with no plain part is delivered undecorated rather than mangled.

A copy that avoid-duplicates flagged — the member was already named in the message’s own To or Cc and kept their list copy anyway — is stamped X-Pepsi-List-Copy: yes, so the reader can see why they have two.

73.4. Two invariants

Sibling rows start here, never at the pipeline head. Re-entering at init would re-run ARC verification and re-enter the router, which explodes combinatorially on an umbrella list.

A member’s personal settings do not reconfigure list delivery. Pepsi layers per-address pepsi.settings overrides automatically (see Configuration), and a sibling row’s single recipient is a member, whose vacation or language override has no business changing this stage’s targets — a NEXT_STAGE taken from it could route the copy past signing. So NEXT_STAGE, the [pepsi-list] options and the [pepsi] TEMPLATE_DIR are read from the site-wide configuration, which no domain: or address: configuration override and no pepsi.settings row reaches, and the member’s language comes from the descriptor the fan-out wrote. Nothing is taken from the overlay.

73.5. Configuration

[stage-<name>]: PROGRAM = pepsi-stage-list-deliver and NEXT_STAGE (required — the signing tail; pepsi-setup refuses a relay). Everything else it needs is the member descriptor the fan-out wrote and the list’s own attributes, plus [pepsi-list] BASE_URL and UNSUBSCRIBE_SECRET from the site section.

73.6. State

  • Inputs: state.list — the list id, the member’s language, their unsubscribe serial and the duplicate flag — and the row’s single rcpt_to, which is the authority on the address.

  • Outputs: a rewritten header block and body. No state is changed.

  • Transitions: advance to NEXT_STAGE, the only transition.

73.7. See also

Mailing lists, pepsi-stage-list-post, pepsi-stage-dkim-sign, pepsi-list, pepsi-stage-list-deliver(1).