85.1.20. pepsi-stage-list-post

moderate, transform and fan out a post to a mailing list

Manual section:

1

85.1.20.1.1. Name

pepsi-stage-list-post - the mailing-list posting stage of the Pepsi pipeline.

85.1.20.1.2. Synopsis

pepsi-stage-list-post [GLOBAL-OPTIONS] worker

85.1.20.1.3. Description

pepsi-stage-list-post is a stage program run by pepsi-dispatch(1). A message that pepsi-stage-list(1) decided is a post to a list arrives here, and two things happen to it, in this order and with these names:

  1. a chain of rules decides whether the post is accepted, held, rejected or discarded;

  2. a pipeline of handlers decides how an accepted post is transformed and delivered.

Moderation and processing are different stages with different vocabularies, and conflating them produces something that is not Mailman. The rule, chain, handler and pipeline names are a compatibility identifier: the REST API reports them and a list’s posting_chain and posting_pipeline name them.

The stage reads the database once, at the start; computes everything in memory; and writes once, in a single transaction that also takes the message’s own terminal. A crash anywhere in the middle replays the whole thing.

85.1.20.1.4. Chains and rules

The default posting chain runs, in order: 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. There are eighteen rules and four terminal chains (accept, hold, reject, discard).

A correct moderator password bypasses the ban list. This is upstream’s behaviour and compatibility requires it, but it is a sharp edge and it is said here rather than three chapters away: 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.

85.1.20.1.5. The approved-password invariant

A moderator may approve a post by putting the list’s moderator 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 survives into a public archive is published permanently, and purging the archived message does not recall the copies already in five thousand mailboxes.

The rule strips what it matched; cleanse then deletes all four header names (Approve, Approved, X-Approve, X-Approved) unconditionally, which is the only thing that removes a wrong password.

85.1.20.1.6. 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, with upstream’s reasons: all decoration is done in delivery, and decoration in delivery would break a signature applied here. Both are pepsi-stage-list-deliver(1)’s and the signing tail’s.

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.

85.1.20.1.7. Headers written

Mailman’s X-Mailman-* namespace is renamed. Nothing in Postorius or HyperKitty reads any of it, and the only consumer of any of them is a human reading a held post:

Mailman 3

Pepsi

X-Mailman-Version

X-Pepsi-List-Version

X-Mailman-Rule-Hits

X-Pepsi-List-Rule-Hits

X-Mailman-Rule-Misses

X-Pepsi-List-Rule-Misses

X-Mailman-Approved-At

X-Pepsi-List-Approved-At

X-Mailman-Hash-ID

X-Pepsi-List-Hash-ID

X-Mailman-Duplicate

X-Pepsi-List-Duplicate

X-Mailman-Copy

X-Pepsi-List-Copy

(no counterpart)

X-Pepsi-List-Hops

X-Message-ID-Hash

X-Message-ID-Hash (unchanged)

Message-ID-Hash and its legacy X- spelling keep their names: they carry no project’s name, they are what every archive URL is derived from, and they are the ones a third party is likely to have built something on.

X-Pepsi-List-Hops is the only one the software ever 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(1) strips an inbound copy from an unauthenticated sender.

85.1.20.1.8. The fan-out

Every regular member with delivery enabled and not on digest gets one row, unconditionally. Each carries 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: the headers are copied inside the database and the body is shared by reference, so a five-thousand-member post sends one message and five thousand small JSON objects to PostgreSQL rather than five thousand messages.

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. Every sibling row references one stored copy of the (cooked) body in pepsi.workqueue_body rather than carrying its own, so a 5 000-member post with a 5 MB attachment stores the attachment once, not 5 000 times; the batching bounds the number of rows in flight, and pepsi-queue(1) shows a fan-out in progress rather than a queue that has suddenly grown.

The fan-out also obeys the queue admission limits ([pepsi] MAX_QUEUE_ROWS, MAX_QUEUE_BYTES, MIN_FREE_SPACE; see pepsi.conf(5)). When the queue is at one of them the stage emits an empty batch — the whole remainder stays in state — and pauses for [pepsi] QUEUE_THROTTLE_DELAY (default 60 s) before looking again, so a post to a large list cannot keep filling a queue that pepsi-ingress(1) is already refusing mail for. A first batch that is held back still archives the post and moves its counters, exactly once, as usual. The recipient list is snapshotted at the first batch, so an unsubscribe halfway through a large send cannot skip or 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.

85.1.20.1.9. 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.

Three values, as everywhere: none, respond_and_continue and respond_and_discard. On a posting address the last means the post is answered and not distributed — a list that swallows every post while replying politely to each sender. That is 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.

  • A post that could not be answered is still delivered. When the autoresponder declines — RFC 3834 said no, the grace period had not expired, the envelope sender was null — respond_and_discard does not discard. A list that silently swallowed every message from an autoresponder would be a worse failure than a missing reply.

Every autoresponse goes through pepsi_common::autoreply’s RFC 3834 rule set first — the null sender, an Auto-Submitted that is not no, Precedence: bulk|list|junk, any List-*, a multipart/report, self-mail, and a post an upstream stage called spam. That rule set is shared rather than restated here, because getting it wrong is how an auto-responder builds a mail loop, and a loop on a posting address is a loop with a whole membership in it.

85.1.20.1.10. Configuration

DELIVERY_STAGE (required)

Where each member’s copy goes. It must run pepsi-stage-list-deliver(1), and pepsi-setup checks that it does.

BOUNCE_STAGE (required)

Where a rejected post is turned into a DSN for its sender. Here this means what it means everywhere except pepsi-stage-list(1).

RESPONSE_STAGE (required)

Where notices are injected: the moderator’s held-post notification and the poster’s acknowledgement. The signing tail, not the list delivery stage: pepsi-stage-list-deliver(1) only accepts a member’s copy from the fan-out and fails anything else, so a notice sent there never goes out. pepsi-setup(1) refuses the wrong value.

REMOVE_DKIM_HEADERS (default no)

Strip the originator’s DKIM-Signature, DomainKey-Signature and Authentication-Results. Upstream’s [mailman] remove_dkim_headers. Off by default: 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.

SITE_HEADER_MATCH_CHAIN (default hold)

Which chain a site-wide header match jumps to; upstream’s [antispam] jump_chain. Site-wide because the matches it applies to are the site’s, and a list owner must not be able to turn the site’s spam rules into a discard.

SENDER_POSTS_PER_HOUR (default 30)

How many posts from one sender one list distributes per hour. A post over the limit that the chain would have accepted is held for the moderator instead, with the reason saying so; a moderator’s release is never counted. The window opens at the sender’s first post and lasts an hour (pepsi.list_post_rate). Site-wide rather than a list attribute because it is an availability limit: a list multiplies every accepted post by its membership. 0 disables it.

MAX_HELD_MESSAGES (default 1000)

How many messages one list may hold for moderation. A post that would be held beyond it is discarded — not stored, and no notice sent — with a warning in the log. Held posts are kept as their full wire bytes until a moderator acts or the list’s max_days_to_hold (default: never) expires them, so without a cap an open list would store whatever anybody sent it. The new post is dropped rather than the oldest so that a flood cannot flush out the posts still waiting for a moderator. Concurrent workers may overshoot by a few. 0 disables it.

NEXT_STAGE is meaningless here and pepsi-setup refuses a section that sets it: every path out of this stage is a terminal.

85.1.20.1.11. State

Inputs: state.list.id (written by pepsi-stage-list(1)), state.auth (the SPF/DKIM/DMARC verdicts pepsi-ingress(1) recorded), and state.list.pending_rcpt on a resumed fan-out.

Outputs: a state.list descriptor on each sibling row — the member id, their language, their unsubscribe serial and the duplicate flag.

Transitions: finishes (the last batch of a fan-out, a hold, a discard), finishes with a side clone (a rejection’s DSN), or pauses (a fan-out with recipients remaining).

85.1.20.1.12. Commands

worker

Run as a persistent pepsi-dispatch(1) worker, reading message ids on standard input.

85.1.20.1.13. Global Options

The usual set — -c/–config, -L/–log, -v/–verbose, -h/–help and -V/–version — behaving as they do for every Pepsi program; see pepsi-config(1).

85.1.20.1.14. Errors and retries

A post that names a list which no longer exists (it was deleted after the router routed the message) is reported to the sender: one DSN per recipient through BOUNCE_STAGE, saying the mailing list no longer exists, and the post is gone. A message the MIME parser refuses (nested or split too deeply to process) is failed at once. Any other error – the database, a template, the archive – is a fault of the host: the post is paused and retried with back-off until MAX_LIFETIME (see pepsi-dispatch(1)). A section that does not parse keeps the worker from starting at all, so the queue waits for the fix instead of failing post after post.

A retry must not change what the post does. SENDER_POSTS_PER_HOUR and the posting autoresponder’s grace period are both claims recorded before the post is committed, so both are keyed on the message’s queue token: a post retried after its commit failed gets the answers its first attempt got, and is neither counted twice (and held for our own retry) nor told it was already answered today. The autoresponse itself is committed in the same transaction as the post – with the first fan-out batch, or with the deletion of a respond_and_discard post – so a post is never answered without being handled, or handled without its answer.

85.1.20.1.15. Exit Status

0

The post was accepted, held, rejected or discarded.

1

The worker could not run (its database could not be opened, or standard input/output failed). The reason is written to the log.

85.1.20.1.16. Examples

[stage-list-post]
PROGRAM = pepsi-stage-list-post
DELIVERY_STAGE = list-deliver
BOUNCE_STAGE = bounce
RESPONSE_STAGE = dkim-sign
REMOVE_DKIM_HEADERS = no

85.1.20.1.17. See Also

pepsi-list(1), pepsi-stage-list(1), pepsi-stage-list-deliver(1), pepsi-stage-dkim-sign(1), pepsi-ingress(1), pepsi-dispatch(1), pepsi.conf(5), pepsi.state(7), pepsi-setup(1)

85.1.20.1.18. Bugs

Report bugs to the Pepsi issue tracker.