.. 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 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. ============= Mailing lists ============= This subsystem is a reimplementation of GNU Mailman 3 ===================================================== Before anything else, and before the first configuration example, the debt has to be stated plainly, because it is large. Pepsi's mailing-list subsystem is a **reimplementation of GNU Mailman 3**. Its data model, its rule/chain and handler/pipeline architecture, its REST API, its e-mail command vocabulary and its notice-template names are the GNU Mailman project's design and not ours. The GNU Mailman project and the Free Software Foundation hold the copyright on the original. Pepsi exists to be *compatible* with it: an unmodified ``mailmanclient``, Postorius or HyperKitty must be able to drive this implementation, which is the acceptance test the whole design is built around. Upstream's documentation at describes concepts that apply directly to Pepsi's implementation, and is worth reading alongside this manual. Where this chapter says "the ``hold`` action" or "the ``list:user:notice:welcome`` template", it means exactly what Mailman means. Some of Pepsi's files were not written by us at all. The notice templates, the settings explanations and a number of test fixtures come from GNU Mailman, Postorius and HyperKitty; they carry their original copyright and licence notices, they remain under the GNU General Public License version 3 or later, and they are listed in ``vendor/PEPSI-VENDORING.md``. GPL version 3's section 13 permits combining such a work with Pepsi's AGPLv3+; it permits nothing about relicensing it, and Pepsi has not. ``COPYING`` says the same thing for anyone auditing the licensing rather than reading the manual. Why reuse the text rather than write our own: those notices have been read, complained about and repaired by real list members for two decades, in thirty-four languages. Rewriting them from scratch would have been weeks of work whose best possible outcome was to arrive back where upstream already is, in fewer languages. What a list is ============== A mailing list belongs to a **domain** and is identified two ways, which are not interchangeable: *name*\ ``@``\ *domain* The **posting address**, which is what people type and what appears in ``To:``. *name*\ ``.``\ *domain* The **list id**, with a dot. This is what the REST API uses as a resource identifier. ``pepsi-list`` accepts either spelling everywhere a list is named. The distinction matters when you talk to the API directly, and it is upstream's. Every list answers on nine addresses: the posting address itself, and eight sub-addresses — ``-request``, ``-join``, ``-leave``, ``-subscribe``, ``-unsubscribe``, ``-owner``, ``-bounces`` and ``-confirm+``\ *token*. Unlike GNU Mailman, **Pepsi generates no alias file**. Mailman has to write Postfix or Exim alias and transport maps for those nine addresses of every list and tell the MTA to reload; Pepsi routes on the recipient inside its own pipeline, so there is nothing to regenerate, nothing to reload and nothing to get out of sync. .. _list-architecture: How the subsystem is put together ================================= Five stages, two libraries and three surfaces. Everything else in this chapter is a detail of one of them. :: inbound mail | v +------------------+ not a list +----------------------------+ | pepsi-stage-list | -------------> | the rest of your pipeline | +------------------+ +----------------------------+ | | | post | -owner | -bounces| | -join | | | etc. | | v v v +----------+ +---------+ +--------+ | ...-post | | command | | bounce | +----------+ +---------+ +--------+ | | | | one row \ | scores, disables, | per member \ | warns, unsubscribes v v v +-------------+ +------------------------+ | ...-deliver | | replies, notices and | +-------------+ | probes, injected at | | | RESPONSE/NOTICE_STAGE | v +------------------------+ pepsi-stage-dkim-sign -> relay -> the member | +--> the archive (pepsi-archive), and the digest accumulator **The five stages.** :doc:`programs/pepsi-stage-list` routes; it is the only one that runs on every message. :doc:`programs/pepsi-stage-list-post` moderates a post and fans it out one row per member. :doc:`programs/pepsi-stage-list-deliver` turns one of those rows into *that member's* copy. :doc:`programs/pepsi-stage-list-command` answers everything a list does by mail that is not a post. :doc:`programs/pepsi-stage-list-bounce` consumes the failures that come back. **The two libraries.** ``pepsi-list`` holds the list model, the attribute table, the moderation decisions, the digest builder and the importers — and is what the CLI :doc:`programs/pepsi-list`, the REST API and the owner console all call, so the three surfaces cannot disagree about what a setting means. ``pepsi-archive`` holds the archive: storage, threading, search, export, import and purge, behind the CLI :doc:`programs/pepsi-archive`. **The three surfaces**, each on a listener flag of its own, are the reason a single hostname is not a single trust boundary: .. list-table:: :header-rows: 1 :widths: 18 20 62 * - Surface - Listener flag - Who it is for * - ``/api/v1``, ``/ui`` - ``ADMIN = yes`` - the **operator** — ``pepsi.admin_account``, a session in ``pepsi_session``; bind it to loopback. See :doc:`web-ui`. * - ``/3.0``, ``/3.1`` - ``LIST_API = yes`` - programs — ``mailmanclient``, Postorius, HyperKitty, over HTTP Basic. See :doc:`mailman-api`. * - ``/lists``, ``/archives`` - ``LISTS = yes`` - **list owners**, **moderators** and **members** — ``list_user``, a session in ``pepsi_list_session``. See :doc:`web-ui` and :doc:`archives`. Three identities, and the words for them ---------------------------------------- The chapter uses these three words in exactly these senses, and so do the interface, the notices and the man pages. **Operator** Whoever runs the Pepsi installation. Holds a ``pepsi.admin_account``, reaches ``/ui`` and ``/api/v1``, edits ``pepsi.conf``, and can do anything to any list. There is no per-list operator. **List owner** (and **moderator**) Whoever administers one list. Holds a ``list_user`` account and a ``list_member`` row with ``role = owner`` or ``moderator`` — authority is a *roster query*, not a granted permission, which is Postorius' model and means adding an owner is subscribing them. An owner changes a list's settings; a moderator decides its held messages. **Member** One **subscription**, not one person: an address on a list's roster. Somebody with two addresses on one list is two members, with two delivery modes, two bounce scores and two unsubscribe URIs. The word **subscriber** means the same thing in prose; *member* is what the API, the interface and this manual use in reference material. A list owner is not a Pepsi operator, and the two account systems never meet; see `Two account systems`_ below, and :ref:`two-account-systems` for the tables, cookies and the one hostname that can confuse them. Where to go next ---------------- * Running a list, its settings and its digests — the rest of this chapter. * Migrating from GNU Mailman 2.1 or 3 — :doc:`installation`, then `What a migration does not bring`_. * Driving Pepsi with upstream's own clients — :doc:`mailman-api`. * The archive, its search and its purge — :doc:`archives`. * The browser surfaces — :doc:`web-ui`. * The standards involved — :doc:`rfc-index`, which collects RFC 2369, RFC 5064, RFC 1153, RFC 3464 and RFC 8058 in one place. Getting started =============== Register a domain, create a list, give it an owner: .. code-block:: console # pepsi-list domain add lists.example.org --base-url https://lists.example.org added list domain lists.example.org # pepsi-list list create announce@lists.example.org --style announce-only created announce@lists.example.org with style legacy-announce # pepsi-list owner add announce@lists.example.org alice@example.org alice@example.org is now an owner of announce@lists.example.org # pepsi-list owner reset-password announce@lists.example.org alice@example.org password for alice@example.org: xzfkh-9gehq-r7y57-6np8r (printed rather than mailed: this path has to work when mail is broken) # pepsi-list check note: site: archive search: full text and trigram (substring, fuzzy) 1 finding, none fatal Two warnings about that transcript. **The domain must also be one Pepsi accepts mail for.** ``pepsi-list domain add`` registers a domain *for lists*; ``[pepsi-ingress] ACCEPTED_DOMAINS`` decides what the server takes at ``RCPT``. A list in a domain ingress does not accept is refused at the SMTP boundary, and nothing in the list subsystem's logs mentions it, because the message never reached it. **``members add`` bypasses the subscription policy.** That is what a command-line tool is for — migrating a roster, fixing a mistake — and it is also the one thing in this subsystem that can turn a mailing list into a spam cannon. The confirmation workflow exists so that an address proves it wants to be there; the only paths that skip it are this command and an explicitly ``pre_verified`` + ``pre_confirmed`` call from an authenticated REST client. Styles ====== A **style** is a named set of attribute defaults applied when a list is created. ``GET /3.0/lists/styles`` reports three, which are upstream's, with upstream's names, descriptions and default: .. list-table:: :header-rows: 1 :widths: 22 20 58 * - Name - Also accepted as - What it is * - ``legacy-default`` - ``discussion``, ``default`` - Ordinary discussion list. The default. * - ``legacy-announce`` - ``announce-only``, ``announce`` - Announce only: members may not post. * - ``private-default`` - ``private`` - Discussion list with private archives; not advertised, and subscriptions need both a confirmation and a moderator. One further style is Pepsi's own. It is accepted by ``pepsi-list`` and by the owner console, and is **not** listed over REST: ``moderated`` A discussion list on which every member's post is held for a moderator. An unknown style name is an **error**. Upstream's ``create_list`` silently applies no style at all when it does not recognise a name, which leaves a list with every attribute at its column default and nothing said anywhere; a migrating site's provisioning script can fail that way for months. Per-list settings ================= A list carries the 99 attributes GNU Mailman 3.3.10 exposes over REST. ``pepsi-list list show`` prints them; ``--explain`` adds a line of prose for each: .. code-block:: console $ pepsi-list list show announce@lists.example.org | head -6 accept_these_nonmembers acceptable_aliases admin_immed_notify yes admin_notify_mchanges no administrivia yes advertised yes Seventeen of them are read-only, including the eight derived addresses (``posting_address``, ``owner_address``, ``bounces_address`` and the rest), which are computed from the list's identity and never stored — a second copy of a list's identity is a second place it can be wrong. Six more, the ``*_uri`` template attributes, exist only in API version 3.0; version 3.1 reaches them through the template manager instead. ``list show`` marks both kinds. .. warning:: **``unsubscription_policy`` does not govern the one-click unsubscribe link.** When ``[pepsi-list] BASE_URL`` and ``UNSUBSCRIBE_SECRET`` are both set, every message Pepsi sends to a list carries an RFC 8058 ``List-Unsubscribe-Post`` header, and a mailbox provider's "unsubscribe" button calls that target with a single ``POST``. A valid token removes the member **immediately**, whatever this attribute says — including ``moderate`` and ``confirm``. That is deliberate and it is not a gap. RFC 8058 allows no further interaction: a provider that gets a redirect, a confirmation page or a "your request is awaiting approval" has no way to act on any of them, and many will simply mark the mail as spam instead. A header that promises one-click and then does not honour it is worse than not sending one. The policy still governs everything a *person* does: the ``-leave`` address, the unsubscribe form on the list's page, and the REST ``DELETE``. If a list genuinely must moderate every departure, the answer is to stop emitting the header — leave ``[pepsi-list] UNSUBSCRIBE_SECRET`` unset and no ``https:`` form is emitted at all — and to accept that the list will look worse to every mailbox provider that checks. Three settings that are asked about more than the other ninety-six ------------------------------------------------------------------ ``advertised`` Whether the list appears on the public index. It is **not** a privacy setting: an unadvertised list is still reachable at its own URL, still accepts posts, and its archive is governed by ``archive_policy`` and not by this. That is upstream's meaning and changing it would be a compatibility break. "Unadvertised" reads like "hidden"; it means "not in the catalogue". ``archive_policy`` ``never``, ``private`` or ``public``. ``never`` stores nothing — the archive is not written, so there is nothing to make public later. ``private`` stores the messages and refuses anonymous readers: the archive URLs answer as though the list did not exist, because a "forbidden" would confirm that it does. ``public`` is public to the internet, including search engines, and ``/robots.txt`` asks them to index the archive and leave the search endpoint alone. ``member_roster_visibility`` Who may see **who is subscribed**: ``public``, ``members`` or ``moderators``. The list's own page shows the roster only for ``public``. This is the attribute to reach for when somebody says "unadvertised" and means "the subscribers should not be a public list of who corresponds with us". All ninety-nine live on eleven screens of the owner console, in Postorius' grouping, and the screens are generated from the same declaration that drives ``list show`` and the REST API — so a value the console refuses is refused by both of the others, in the same words. :ref:`two-account-systems` has the tour. Settings Pepsi invented ----------------------- ``pepsi-list list set-ext`` manages a second, much smaller set of per-list settings that Pepsi added and that **the REST API never shows**: ``search_trigram`` ``off``, ``short`` or ``full`` — how far this list's substring and fuzzy search index reaches. Clamped to the site's ``SEARCH_TRIGRAM`` ceiling. ``archive_show_addresses`` Show posters' full e-mail addresses to anonymous readers of this list's public archive. Off by default. ``archive_retention_days`` Delete archived messages older than this. Absent means the site default. They are invisible to REST **on purpose, and it is not a stylistic choice.** Upstream's whole-resource ``PUT /3.0/lists//config`` requires *every* writable attribute to be present in the request. A client built against Mailman 3.3.10 does not know about anything Pepsi added, so it would omit ours and be rejected — one invented attribute would break every such ``PUT`` from every existing client. So Pepsi's own settings live outside the attribute set entirely, reachable from the command line and the owner console and from nowhere a Mailman client can see. Configuration ============= Per-list configuration lives in the database. ``pepsi.conf`` holds only what the *operator* owns: .. code-block:: ini [pepsi-list] SITE_OWNER = postmaster@example.org BASE_URL = https://lists.example.org API_USER = restadmin API_PASS = a long random string SEARCH_TRIGRAM = short ARCHIVE_PARTITIONS = 32 NOTICE_STAGE = dkim-sign See :manpage:`pepsi.conf(5)` for the full set. Three of them deserve a note here. ``NOTICE_STAGE`` **is where the periodic passes put their mail.** Most notices are sent by a stage, which has a ``RESPONSE_STAGE`` of its own; the bounce warn-and-remove passes are run by a timer, which does not. Without this option those passes can change a member's state but cannot tell the member, so ``pepsi-setup`` refuses a configuration that processes bounces without it. ``ARCHIVE_PARTITIONS`` **is fixed at install.** The archive is hash-partitioned on the list, and PostgreSQL has no online re-partitioning: changing the modulus means a new table and a full copy of every archived message. ``pepsi-setup`` creates the partitions once and refuses to change them silently afterwards, and ``pepsi-list check`` reports a configuration that disagrees with what is on disk. Pick it once. Thirty-two is comfortable well past the point where something else becomes the bottleneck. ``SEARCH_TRIGRAM`` **is a ceiling, not a default.** Each list chooses ``off``, ``short`` or ``full`` up to it. Lowering the site value therefore shrinks the index at the next reindex, rather than only affecting lists created afterwards. Substring search is optional ============================ Pepsi's archive search uses two PostgreSQL mechanisms, and they answer different questions. Full-text search — stemming, ranking, snippets — answers "find me the thread about certificates". Trigram search, from the ``pg_trgm`` extension, answers what full text is bad at: a substring, a misspelling, a hostname, a config key, a line from a traceback. On a technical mailing list that is a large share of what people actually search for, and a stemming dictionary throws exactly that material away. ``pg_trgm`` ships in ``postgresql-contrib``, which minimal container images often omit. **A site without it still installs and still works**; it simply has full-text search only. ``pepsi-list check`` says which mode the site is in: .. code-block:: console $ pepsi-list check warning: site: archive search: full text only -- pg_trgm is not installed, so substring and fuzzy search are unavailable install postgresql-contrib and re-run pepsi-setup; the archive then needs `pepsi-archive reindex` to populate trgm_text Two account systems =================== **List owners are not Pepsi operators**, and the two identities never meet. ``pepsi.admin_account`` and the ``/ui`` console are the *site operator's*, which the manual tells you to bind to loopback and reach over an SSH tunnel. List owners obviously cannot work that way. So the owner console lives on the public listener and derives its authority from the list roster — a row in ``list_member`` with ``role = owner`` or ``moderator`` — which is precisely Postorius' model, where authorisation is a roster query rather than a permission grant. Two account systems, on purpose, with separate tables, separate session cookies and separate password hashes, and neither grants the other anything. A member session can never satisfy an administrative guard, in either direction. That matters more than it looks: cookies are scoped by host rather than by listener, so an operator who serves both surfaces from one hostname is relying on the cookie names and the guard checks alone. :ref:`two-account-systems` sets the two side by side, names the tables and cookies, and says what happens if you serve both from one hostname. An owner looking for what they can actually *do* wants the same chapter's account of the console. Bounce processing ================= A mailing list sends to addresses that stop working, and somebody has to notice. Pepsi's answer is GNU Mailman 3's, step for step, because every knob in it is visible through the REST API and a site's documentation has to keep being true. The shape of it: a delivery failure comes back to the list's ``-bounces`` address, it is attributed to the member it concerns, that member's **bounce score** goes up by at most one per calendar day, and when the score reaches the list's threshold their delivery is **disabled**. Being disabled is not being unsubscribed — a disabled member is warned a few times over the following weeks, and only then removed. Attribution is not guesswork ---------------------------- Every copy of every post Pepsi sends carries an envelope sender naming the member it is for: ``announce-bounces+alice=example.net@lists.example.org``. A bounce of that copy therefore says who it is about without anything having to read the bounce itself. Mailman can only do this when an operator turns VERP on; Pepsi's fan-out is per recipient always, so it is never off. The ``+`` is the site's ``[pepsi] RECIPIENT_DELIMITER`` — the one option the fan-out writes with and the router splits on, so the two cannot disagree. That matters for a practical reason. Upstream's seventeen heuristic bounce detectors — one standard (RFC 3464) and sixteen that name a vendor or a site — exist because attribution by envelope was not available. Pepsi ports all seventeen (they are ``flufl.bounce``'s, Apache-2.0, with their own fixtures), but they only ever read a bounce that arrived some other way. How often that happens is counted in ``pepsi.event_log`` under ``list.bounce.unrecognized``, and that count is the honest answer to "does this site need more detectors". **A bounce nothing recognises reaches a human**, with the original attached verbatim, unless the list's ``forward_unrecognized_bounces_to`` says ``discard``. That forward is deliberate: the sample is the only way the next detector gets written. The nine attributes ------------------- .. list-table:: :header-rows: 1 :widths: 40 60 * - Attribute - What it decides * - ``process_bounces`` - Whether this list scores bounces at all. Off means they are dropped unread. * - ``bounce_score_threshold`` - How many scoring days disable a member (default 5). * - ``bounce_info_stale_after`` - How long a score survives without a new bounce (default 7 days). Past it, the next bounce starts again at 1. * - ``bounce_you_are_disabled_warnings`` - How many warnings a disabled member gets before removal (default 3). **Zero means removal with no warning at all.** * - ``bounce_you_are_disabled_warnings_interval`` - How long between those warnings (default 7 days). * - ``bounce_notify_owner_on_bounce_increment`` - Tell the owners about every increment (default off — on a large list this is a lot of mail). * - ``bounce_notify_owner_on_disable`` - Tell the owners when a member is disabled (default on). * - ``bounce_notify_owner_on_removal`` - Tell the owners when a member is removed (default on). * - ``forward_unrecognized_bounces_to`` - ``administrators`` (default), ``site_owner`` or ``discard``. A worked example ---------------- A list with the defaults, and an address that has stopped accepting mail: .. list-table:: :widths: 18 82 * - Day 1 - a permanent failure arrives. Score 1. Nobody is told. * - Day 1 - four more failures the same day. **Still score 1** — at most one increment per calendar day. * - Days 2–4 - one failure each day. Score 4. * - Day 5 - one more. Score 5 = the threshold: delivery is **disabled** and the owners are told, with the triggering DSN attached. * - Day 5 - the member is sent a warning saying their subscription has been disabled and why. Warnings sent: 1. * - Day 12 - warning 2. * - Day 19 - warning 3. * - Day 26 - the warnings have run out and the interval has elapsed again: the member is **unsubscribed** and the owners are told. So an address that dies takes about four weeks to leave the list, and a bad afternoon at a provider costs one point. A bounce storm — a thousand failures in an hour — costs exactly one point too. Everything after "disabled" is done by ``pepsi-list tasks --once``, which a timer runs daily. If nothing runs it, members are disabled and then never warned and never removed. Probes ------ ``[pepsi-list] BOUNCE_PROBES = yes`` changes what crossing the threshold does: instead of disabling the member, Pepsi sends *them* a message with a one-use token in its envelope sender, and resets their score to zero. If that message bounces, the token names them exactly and they are disabled at once. If it does not, they were fine and nothing happens. It is off by default, as it is upstream. The trade is one more message to an address that is probably dead, against not disabling somebody whose provider had one bad afternoon. What a migration does not bring ------------------------------- A site imported from Mailman starts every member at score zero. Upstream's own importer does not read bounce history either, so this matches — but it means a long-dead address gets one more delivery attempt after the cutover. Known limitations ================= The subsystem covers the data model and the command-line tool, the posting path with its moderation chain and per-member fan-out, the e-mail command interface, bounce processing, the archive and its search (:doc:`archives`), the GNU Mailman 3 REST API (:doc:`mailman-api`), the public web interface, the member accounts and owner console (:doc:`web-ui`), the importers (:doc:`installation`) and both digest formats. What it does not do: ``/plugins`` is always empty and there is no plugin interface, there is no NNTP gateway (``gateway_to_news`` is settable and logged as the no-op it is), thread tagging and categories are not in the archive, and a *rejected* held message is dropped without the rejection notice upstream sends. Each of those is stated again where a reader would meet it. .. _digests: Digests ======= Both formats, MIME ``multipart/digest`` and RFC 1153 plain, because a client can read ``digest_is_default`` and ``mime_is_default_digest`` back and a member can ask for ``plaintext_digests`` — shipping one format and accepting the attribute for the other would be the quiet kind of lie the compatibility contract exists to prevent. How a digest accumulates and how it is sent ------------------------------------------- Accumulation is part of delivering a post: when ``digests_enabled`` is on, the posting path appends the message to one row per list, inside the same transaction as everything else the post does. Upstream appends to an MMDF mailbox file under a lock; a row is one of the places a database is simply better. **The row carries the messages themselves, not references to an archive.** A list may perfectly well have ``digests_enabled`` with ``archive_policy = never`` — ``pepsi-list check`` even points out that digests are then the only record of what was posted — so a digest built from archive references would be empty for exactly the lists that need it most. ``digest_size_threshold`` bounds how much accumulates. Sending has two triggers, and **upstream has no periodic runner at all**: its ``maybe_send_digest_now`` fires synchronously on a post once the size threshold is crossed, and ``mailman digests --send`` is meant to be put in the operator's cron. Pepsi keeps both halves and puts the second where it belongs: * **size** — ``digest_size_threshold``, in **kilobytes** (upstream's unit, which the column name does not say). Zero means size never triggers a send, so a list can be periodic-only. * **periodic** — ``pepsi-list digest periodic`` from a systemd timer, honouring ``digest_send_periodic`` per list. The same arrangement ``pepsi-list tasks`` has, and the reason no long-lived digest service is written. ``pepsi-list digest send`` forces an issue regardless of the threshold, and ``digest bump`` advances the volume without sending — upstream's two flags. Volume and issue number ----------------------- ``digest_volume_frequency`` (``yearly``, ``monthly``, ``quarterly``, ``weekly``, ``daily``) decides whether the *period* has advanced since ``digest_last_sent_at``: * a list that has never sent a digest keeps its current volume and number, so a list's first issue is issue 1 rather than issue 2; * if the period has advanced, the volume increments and the number **resets to 1**; * if it has not, the number increments. The period is compared rather than the elapsed time, which is what makes the answer the same on the 1st of a month as on the 31st: two posts a day apart across a month boundary are in different months, and two posts thirty days apart inside one are not. What the two formats look like ------------------------------ The **MIME** digest is a ``multipart/mixed`` of five parts in order: the masthead (carrying the issue's subject as its ``Content-Description``, which is what makes a digest legible in a client that lists parts), the digest header, the table of contents, an inner ``multipart/digest`` whose children are the original messages as ``message/rfc822``, and the footer. The header and footer parts are omitted when their template is empty — which ``list:member:digest:header`` is, upstream and here. The **RFC 1153** digest is one flat ``text/plain`` body, and every count in it is load-bearing because readers have been splitting digests on these lines since 1990: seventy hyphens once after the table of contents, thirty before every message *except the first*, the footer as a **fake extra message** with its own ``Subject: Digest Footer`` rather than as a trailer, and a sign-off followed by a line of asterisks of the same length. RFC 1153 is plain text, so an attachment cannot survive it. Every non-text part therefore becomes a **placeholder naming what was removed** — its filename, its media type and its size — because a plain digest that silently dropped a spreadsheet would leave a reader believing they had seen the whole message. A ``multipart/alternative`` yields its plain text and says nothing about the HTML twin, since nothing was taken away. Who gets which -------------- Digest members with delivery enabled split by ``delivery_mode``: ``plaintext_digests`` get the RFC 1153 issue, and ``mime_digests`` **and ``summary_digests``** both get the MIME one. Upstream has no separate summary format and treats the two identically; inventing one would mean a member could ask for a format no other Mailman produces. Each format is built once and fanned out per recipient, on the same terms as any other delivery — a digest carries a ``List-Unsubscribe`` too. .. _declared-differences: Declared differences from GNU Mailman 3 ======================================= Collected in one place, because an operator evaluating the two systems needs it in one place and a support conversation needs something to point at. Each of these is a decision with a reason, not an accident. .. list-table:: :header-rows: 1 :widths: 34 66 * - Difference - Why * - **RFC 8058 one-click unsubscribe** - We emit ``List-Unsubscribe-Post`` and upstream does not, and a valid token removes the member **immediately whatever ``unsubscription_policy`` says**. RFC 8058 allows no further interaction, and a header that promises one click and then does not honour it is worse than no header. * - **The fan-out is always per recipient** - ``personalize`` controls whether the message is *rewritten* for each member, not whether each member gets their own row — because every copy carries that member's own unsubscribe URI. * - **Archived sender addresses are obscured** - For anonymous viewers, by default, with a per-list override. Not a security control: what it stops is the bulk harvest that makes a public archive a spam source. A signed-in member sees the whole address. * - **A purge leaves a 90-day tombstone** - So that a re-delivery or a re-run import cannot resurrect a message somebody deleted on purpose. ``--forget`` skips it. * - **``max_days_to_hold`` is honoured** - It is **inert upstream**: a grep of Mailman 3's whole non-test tree finds the column, the style default and the REST validator, and no job that reads it. Here ``pepsi-list tasks --once`` discards held messages past the limit, logs each one and tells the moderators a count. A strict superset — the default ``0`` means "never expire". * - **An unknown ``style_name`` is an error** - Upstream's ``create_list`` looks the style up, gets nothing, and creates the list with **no style applied at all** — every attribute at its column default, no error anywhere. A provisioning script with a typo would silently produce lists with none of the intended defaults. We refuse, which cannot break a client that passes a name it read from ``/lists/styles``. * - **Template override URIs are fetched under limits** - A list owner — not the operator — can set one, so a ``file:`` URI is refused on every write path and never read, the size and time are bounded, and redirects are constrained. Upstream has no such limits. * - **``archive_rendering_mode = markdown`` is stored and not rendered** - The attribute exists because the attribute set is the contract; both values render as text. Rendering user-supplied markdown into our own origin is what the pages' Content-Security-Policy exists to make impossible. * - **No JavaScript anywhere** - On either browser surface. The cost is the interactive parts of HyperKitty; the benefit is a policy with no ``script-src`` at all. * - **``X-Mailman-*`` is renamed ``X-Pepsi-List-*``** - With a mapping table, because procmail and Sieve rules are a migrating *user's* real cost. * - **No NNTP gateway, no pluggable archivers, no plugin API** - The six NNTP attributes stay in the attribute set and are inert, and ``gateway_to_news`` is **logged when it would have applied** rather than ignored — a silent no-op would leave an operator believing their gateway works. * - **Passwords, bounce scores and in-flight tokens are not migrated** - Upstream's own importer carries none of them either. See :ref:`migrating-from-mailman`.