.. This file is part of PEPSI. Copyright (C) 2026 GNUnet e.V. PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. ======================== pepsi-stage-list-deliver ======================== *Make one member's copy of a list post that member's own copy.* Role ==== :doc:`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: :manpage:`pepsi-stage-list-deliver(1)`. This subsystem is a reimplementation of GNU Mailman 3; see :doc:`../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 :doc:`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. 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. Decoration ========== The header and footer are rendered from ``[pepsi]`` ``TEMPLATE_DIR`` as ``list-header..body`` and ``list-footer..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. 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 :doc:`../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. Configuration ============= ``[stage-]``: ``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. 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. See also ======== :doc:`../mailing-lists`, :doc:`pepsi-stage-list-post`, :doc:`pepsi-stage-dkim-sign`, :doc:`pepsi-list`, :manpage:`pepsi-stage-list-deliver(1)`.