72. pepsi-stage-list-post¶
Moderate a post to a mailing list, transform it, and fan it out to the members.
72.1. Role¶
pepsi-stage-list-post receives a message that pepsi-stage-list
decided is a post to a list, and does two things to it in this order and with
these names: a chain of rules decides whether the post is accepted,
held, rejected or discarded, and then a pipeline of handlers decides
how an accepted post is transformed and delivered. Moderation and processing
are different steps with different vocabularies, and conflating them produces
something that is not Mailman — the rule, chain, handler and pipeline names
are a compatibility identifier that the REST API reports and that a list’s
posting_chain and posting_pipeline name. Reference:
pepsi-stage-list-post(1).
This subsystem is a reimplementation of GNU Mailman 3; see Mailing lists, which names what was taken from upstream.
72.2. Moderation¶
The default posting chain runs the DMARC mitigation check, the no-senders
check, the moderator-password bypass (approved), the loop and banned-address
rules, the site and per-list header matches, the emergency flag, and the member
and non-member moderation rules. Then eight rules only record what they find —
administrivia, implicit-destination, maximum-recipients, maximum-size,
news-moderation, empty-subject, digests and suspicious-header — and a final
any holds the post if one of them hit: eighteen rules and four terminal
chains (accept, hold, reject, discard).
Warning
A correct moderator password bypasses the ban list. That is upstream’s
behaviour and compatibility requires it, but it is a sharp edge: an
Approved: header carrying the list’s moderator password jumps straight to
accept, over the ban list, the header matches, the emergency flag and
both moderation rules.
The approved password never survives. A moderator may approve a post with
the password in an Approved: header or in the first line of the body;
whichever form it arrived in, it is removed before the message is archived or
delivered — in the header, in the plain-text body, and in the HTML alternative
a mail client generated alongside it. A password that reaches a public archive is
published permanently, and purging the archived copy does not recall the copies
already in five thousand mailboxes. The rule strips what it matched, and the
cleanse handler then deletes all four header names (Approve,
Approved, X-Approve, X-Approved) unconditionally, which is the only
thing that removes a wrong password.
72.3. Handlers¶
The pipeline is upstream’s, in upstream’s order: validate-authenticity,
mime-delete, tagger, member-recipients, avoid-duplicates,
cleanse, cleanse-dkim, cook-headers, subject-prefix,
rfc-2369, to-archive, to-digest, to-usenet, after-delivery,
acknowledge, dmarc, to-outgoing.
decorate and arc-sign are absent because upstream’s own default pipeline
has them commented out, for upstream’s reasons: all decoration is done in
delivery, and decoration in delivery would break a signature applied here. Both
belong to pepsi-stage-list-deliver and the signing tail.
to-usenet is an explicit, logged no-op — gateway_to_news stays settable
because removing it would change the resource a REST client sees, but Pepsi does
not gateway to NNTP, and a silent no-op would leave an operator believing their
gateway works.
72.4. Headers written¶
Mailman’s X-Mailman-* namespace is renamed to X-Pepsi-List-* —
Version, Rule-Hits, Rule-Misses, Approved-At, Hash-ID,
Duplicate, Copy — because nothing in Postorius or HyperKitty reads any of
it and the only consumer is a human reading a held post. Message-ID-Hash and
its legacy X-Message-ID-Hash spelling keep their names: they carry no
project’s name, every archive URL is derived from them, and they are the ones a
third party is likely to have built something on.
X-Pepsi-List-Hops has no upstream counterpart and is the only one the
software reads back in. It is the backstop against a subscription cycle through
two or more lists — the loop rule catches only a message returning to the
same list — and it travels in a header because a sibling row leaves over SMTP
and re-enters as ordinary mail, where row state does not survive. Because a
header is forgeable, pepsi-ingress strips an inbound copy from an
unauthenticated sender.
72.5. The fan-out¶
Every regular member with delivery enabled and not on digest gets one row,
unconditionally, each with its own single recipient and its own VERP envelope
sender <list>-bounces+<local>=<domain>@<host> — which is what lets the bounce
path attribute a failure to exactly one subscription without parsing the bounce.
What crosses the wire is the recipient list, not the message: headers and body
are copied inside the database, so a five-thousand-member post sends one
message and five thousand small JSON objects to PostgreSQL.
The fan-out is batched: at most [pepsi-list] LIST_FANOUT_BATCH rows (default 256) are
materialised at a time and the message pauses between batches with the remaining
recipients in its own state. Peak transient storage is the batch times the
message size rather than the membership times the message size — a 5 000-member
post with a 5 MB attachment peaks near 1.3 GB instead of 25 GB — and
pepsi-queue shows a fan-out in progress rather than a database that has
suddenly grown. The recipient list is snapshotted at the first batch, so an
unsubscribe halfway through a large send can neither skip nor double a
neighbour, and the source row is deleted only by the batch that finishes the
list, so a crash replays that batch and nothing more.
72.6. The posting autoresponder¶
autorespond_postings is the third of the three autoresponders, on the same
grace-period machinery as -request and -owner, and the one worth reading
about: it fires on the posting address, so a misconfigured one answers
everybody who writes to the list. On a posting address respond_and_discard
means the post is answered and not distributed — occasionally what an owner
wants (a retired list that answers “we have moved”) and never what they want by
accident, so the discard is logged at warn and the owner console labels the
value plainly. Two rules that are not obvious: an unrecognised value is
``none`` (a response action nobody can spell is not a licence to mail a
membership), and a post that could not be answered is still delivered — when
the autoresponder declines, respond_and_discard does not discard, because a
list that silently swallowed every message from an autoresponder would be the
worse failure. Every autoresponse passes the shared RFC 3834 rule set
(pepsi_common::autoreply) first; see pepsi-stage-vacation.
72.7. Configuration¶
[stage-<name>]: PROGRAM = pepsi-stage-list-post, DELIVERY_STAGE
(required, and it must run pepsi-stage-list-deliver),
BOUNCE_STAGE (required — here it means what it means everywhere except
pepsi-stage-list), RESPONSE_STAGE (required, and it must be the
signing tail: the delivery stage accepts only a member’s copy from the
fan-out, so a notice sent there never goes out), REMOVE_DKIM_HEADERS
(default no), SITE_HEADER_MATCH_CHAIN (default hold, site-wide so
that a list owner cannot turn the site’s spam rules into a discard), and the two
availability limits SENDER_POSTS_PER_HOUR (default 30: a sender’s posts to
one list beyond that in an hour are held for the moderator instead of sent to
every member) and MAX_HELD_MESSAGES (default 1000 per list: a post that
would be held beyond that is discarded, so an open list cannot be used to fill
the disk; the new post is dropped rather than the oldest, so a flood cannot
flush out what a moderator has not yet seen).
pepsi-setup checks all three targets and refuses a ``NEXT_STAGE``: every
path out of this stage is a terminal.
REMOVE_DKIM_HEADERS is off by default because a list that rewrites nothing
leaves a valid signature and stripping it discards real evidence; turn it on
for lists that add a footer or a subject prefix, where the surviving signature
would fail — and a failing signature is worse than none, because a failure is
evidence of tampering and an absence is not.
72.8. State¶
Inputs:
state.list.id(written by pepsi-stage-list),state.auth(the SPF/DKIM/DMARC verdicts ingress recorded), andstate.list.pending_rcpton a resumed fan-out.Outputs: a
state.listdescriptor on each sibling row — the member id, their language, their unsubscribe serial and the duplicate flag.Transitions: finish (the last batch of a fan-out, a hold, a discard), finish with a side clone (a rejection’s DSN), or pause (a fan-out with recipients remaining).
72.9. See also¶
Mailing lists, Archives, pepsi-stage-list, pepsi-stage-list-deliver, pepsi-list, pepsi-archive, pepsi-stage-list-post(1).