70.1.16. pepsi-stage-anti-spam¶
gate a message behind a GNU Taler payment
- Manual section:
1
70.1.16.1.1. Name¶
pepsi-stage-anti-spam - the pay-to-send anti-spam stage of the Pepsi pipeline.
70.1.16.1.2. Synopsis¶
pepsi-stage-anti-spam [GLOBAL-OPTIONS] worker
70.1.16.1.3. Description¶
pepsi-stage-anti-spam is a stage program run by pepsi-dispatch(1). It
loads a pepsi.ingress row (refusing to act unless its status is
running), reads its [stage-<stage>] section, and decides what to do from
the message’s state JSON, requiring a GNU Taler payment for an
otherwise-unclassified message before it may continue down the pipeline.
The decision is:
the message has the null envelope sender (a bounce or RFC 3834 automatic reply) — it is never held for payment and is never itself bounced. When proof-of-origin is enabled (a
[pepsi-origin]section, see PROOF OF ORIGIN below; it is on by default) the bounce is verified to be one we provoked and a verified bounce is forwarded to NEXT_STAGE. Any bounce we cannot verify — one with no validPepsi-Originproof, or every bounce when proof-of-origin is not configured — is routed to BOUNCE_TARGET_STAGE when one is wired (e.g. a quarantine or discard stage), otherwise forwarded to NEXT_STAGE unchanged (a bounce is never silently dropped).state.spam = true— the message is dropped (the row is deleted).state.spam = falseorstate.paid = true— the message is forwarded to NEXT_STAGE. An explicit classification (set by some other component) overrides the payment flow.state.paid = falseis present — an order was created earlier and the message has been woken again (by thepepsi-resumepayment webhook calling pepsi-httpd’s/resume, or by pepsi-dispatch(1) when the deadline lapsed). The stage asks the merchant backend whether the order is now paid:paid — forward to NEXT_STAGE;
unpaid and past the stored deadline — reject the message: reroute to the stage’s BOUNCE_STAGE (wire it to pepsi-stage-bounce(1) for a rejection DSN, or pepsi-stage-discard(1) to drop it silently); when no BOUNCE_STAGE is configured the row is simply deleted;
unpaid but still before the deadline — the message is re-queued as
pendingand a warning is logged. This should not happen in normal operation (a paused message only wakes on payment or at the deadline).
no
paidfield (first encounter) — the stage creates a v1 merchant order priced by ORDER_CHOICES (order_idis the message’s external token, so thepepsi-resumewebhook’s{{order_id}}resolves back to this message), then pauses the message until PAYMENT_DEADLINE, recordingpaid: falseand the absolute deadline instate.
The deadline is stored in state.pay_deadline (epoch seconds) because
pepsi-dispatch(1) clears the row’s timeout column when it requeues a
paused message, so it could not otherwise be recovered on the re-run.
After pausing the message on that first encounter, the stage also originates a
payment-request auto-reply to the sender (when BLOCK_RESPONSE_STAGE is
configured). The reply explains that the recipient requires unknown senders to
pay before their mail is accepted, and carries the taler://pay/… URI — both
as a link and as a QR code (an inline image/png part referenced by
cid:) — together with the acceptable payment options (the ORDER_CHOICES,
omitting any choice that involves a token family) and a pointer to
https://wallet.taler.net/ for senders without a wallet. The sender is
addressed by the display name from the original From: header when one is
available. The human-readable part is rendered from the
payment-request.<lang>.body Mustache template under [pepsi]
TEMPLATE_DIR. The language(s) are taken from state.language as set by
pepsi-stage-detect-language (run earlier in the pipeline): the reply renders
one chunk per detected language that has a template, concatenated in the detected
order, each with its payment options localized to that language. When the message
carries no detected language — or none of the detected languages has a template —
the PAYMENT_MESSAGE_DEFAULT_LANGUAGE template is used instead. The reply uses
the null sender <> (so it is never itself bounced or gated) and is
injected at BLOCK_RESPONSE_STAGE (normally a signing/relay chain), so it is
DKIM-signed and delivered like any other outbound mail.
The auto-reply also carries two machine-readable headers so that a sending
Pepsi can settle the demand automatically (see pepsi-stage-auto-pay(1)): a
Taler: header holding the taler://pay/… URI, and the original message’s
Pepsi-Origin: header copied verbatim (when the incoming message carried
one). Echoing the proof-of-origin lets the originating site verify the returning
demand is for mail it actually sent and meter what it spends per original message
(all demands for one original — e.g. a mailing-list fan-out — share one
Pepsi-Origin nonce). The header is copied as-is, not re-signed: only the
originator can verify it (its HMAC is keyed with the originator’s secret).
The reply is rendered before the message is paused: if no template can be
produced — notably when the PAYMENT_MESSAGE_DEFAULT_LANGUAGE template is
missing — the stage fails rather than pausing a message the sender could never be
prompted about. pepsi-setup validates that this template exists up front.
Injecting the already-rendered reply after the pause is best-effort: a database
error there is logged but does not stop the message from being paused for
payment.
The stage requires the shared [pepsi-payments] section (the merchant backend
and access token; see pepsi.conf(5)) and, for the payment webhook to
release paused messages, the [pepsi-httpd] RESUME_AUTHORIZATION_TOKEN
(see pepsi-httpd(1)).
70.1.16.1.4. Configuration¶
Options live in the stage’s own [stage-<name>] section
(PROGRAM = pepsi-stage-anti-spam): NEXT_STAGE, BOUNCE_STAGE,
BOUNCE_TARGET_STAGE (see PROOF OF ORIGIN below), the order
parameters (ORDER_CHOICES, PAYMENT_DEADLINE, DELAY_DSN_AFTER,
SUMMARY, FULFILLMENT_MESSAGE) and the auto-reply parameters (BLOCK_RESPONSE_STAGE,
BLOCK_RESPONSE_FROM, BLOCK_RESPONSE_SUBJECT,
PAYMENT_MESSAGE_DEFAULT_LANGUAGE). The stage also requires the shared
[pepsi-payments] section and the [pepsi-httpd]
RESUME_AUTHORIZATION_TOKEN. All are documented in pepsi.conf(5).
70.1.16.1.5. Proof of origin¶
Forwarding every null-sender bounce unconditionally is safe only because a
bounce is never itself bounced — but it lets an attacker inject backscatter
(forged bounces) that Pepsi then relays back to a forged “original sender”. To
reject such forgeries, the shared [pepsi-origin] section (pepsi.conf(5))
is enabled by default — pepsi-setup(1) generates a random secret on first
run if none is configured. Both relay stages
(pepsi-stage-relay-to-internet(1) and pepsi-stage-relay-to-smarthost(1))
then stamp every outbound message with a Pepsi-Origin header — an HMAC over
the originating sender account, a fresh 128-bit nonce, a timestamp and our
HOSTNAME — and record the nonce in pepsi.origin_nonce for two weeks.
A genuine bounce embeds the original message (at least its headers, and so the
Pepsi-Origin header) in its DSN body. This stage scans the whole returning
bounce for that header and trusts the bounce only if both checks pass, in
this order:
the header’s HMAC verifies against our secret and its
hostmatches ourHOSTNAME(a fast, purely cryptographic check); andonly then — the nonce is still present (and unexpired) in
pepsi.origin_nonce.
A bounce we cannot verify — one that carries no such header, fails the HMAC, or
whose nonce is no longer tracked, and every bounce at all when the
[pepsi-origin] section is absent (none can then be proven ours) — is routed
to BOUNCE_TARGET_STAGE when one is wired, otherwise forwarded to
NEXT_STAGE unchanged (never silently dropped).
BOUNCE_TARGET_STAGE should normally point at a quarantine mailbox or a review stage rather than a hard pepsi-stage-discard(1) sink: standard non-delivery reports from major MTAs (Postfix, Exim, Gmail, Exchange) return the original headers and so verify, but a real tail of legitimate bounces — spam-rejection notices that quote little of the original, header-stripping gateways, minimal/legacy bouncers, and bounces arriving after the two-week nonce window — cannot be verified, and discarding them outright loses non-delivery reports your own users need.
70.1.16.1.6. Files¶
<TEMPLATE_DIR>/payment-request.<lang>.bodyThe Mustache template for the payment-request reply body (
TEMPLATE_DIRis the[pepsi]option, default${DATADIR}/templates, i.e./usr/share/pepsi/templatesfor a--prefix=/usrinstallation). The template for PAYMENT_MESSAGE_DEFAULT_LANGUAGE (defaultpayment-request.en.body) is required; add the languagespepsi-stage-detect-languagemay detect alongside it. The template is given the variablesrecipient_name/has_name,protected_mailbox,original_subject,pay_uri/pay_link,qr_cid,wallet_urland thepayment_optionslist (eachamountplus optionaldescription, localized to that template’s language).
70.1.16.1.7. State¶
Inputs: state.spam / state.paid (booleans, optional) classify the
message; state.pay_deadline (epoch seconds) is the deadline this stage wrote;
state.dsn carries the recipient’s notify/orcpt (echoed into a
rejection).
Outputs: on the first encounter the stage merges {"paid": false,
"pay_deadline": <epoch>} into state and pauses. At the DELAY_DSN_AFTER
checkpoint it adds state.delay_sent = true once a delay DSN has been sent (so
it fires at most once) and re-pauses. On rejection it reroutes to
BOUNCE_STAGE with a state.bounce object (kind = permanent) for
pepsi-stage-bounce(1). The state layout is described in
pepsi.state(7).
Transitions (driven by state.spam/state.paid, PAYMENT_DEADLINE
and the merchant order status):
whitelisted (
state.spam = false) or already paid (state.paid = true) → advance to NEXT_STAGE;explicit
state.spam = true→ finish (the row is deleted);first encounter → create a Taler order, inject the payment request at BLOCK_RESPONSE_STAGE, and pause until PAYMENT_DEADLINE;
woken (resume webhook or deadline) and now paid → advance to NEXT_STAGE;
woken, still unpaid, deadline passed → reroute to BOUNCE_STAGE (or finish, if no BOUNCE_STAGE is configured);
woken, still unpaid, deadline not yet reached → with DELAY_DSN_AFTER configured this is the delay checkpoint: send the sender a one-shot delayed DSN (RFC 3461, only if they requested
NOTIFY=DELAY) without releasing the message, then re-pause until PAYMENT_DEADLINE; without DELAY_DSN_AFTER an early wake-up is anomalous and the message is re-queued (pending).
70.1.16.1.8. Commands¶
- worker
Run as a persistent pepsi-dispatch(1) worker, reading message ids on standard input.
70.1.16.1.9. Global Options¶
These global options precede the subcommand.
- -c FILE, –config FILE
Read the configuration from FILE instead of searching the default locations.
- -L LOGLEVEL, –log LOGLEVEL
Set the logging verbosity (default
info).- -v, –verbose
Show log messages from all sources.
- -h, –help; -V, –version
Print a usage summary / the version and exit.
70.1.16.1.10. Exit Status¶
- 0
The message was processed (dropped, forwarded, paused for payment, or rerouted for rejection).
- 1
An error occurred (message not found or not
running, misconfigured stage, no[pepsi-payments]backend, a merchant-backend error, or a database error). The reason is written to the log.
70.1.16.1.11. Examples¶
Process message 42 (it must be running):
pepsi-stage-anti-spam -c /etc/pepsi/pepsi.conf 42
A stage that charges one KUDOS and rejects unpaid mail after two days:
[stage-anti-spam]
PROGRAM = pepsi-stage-anti-spam
NEXT_STAGE = srs
BOUNCE_STAGE = bounce
BLOCK_RESPONSE_STAGE = dkim-sign
PAYMENT_DEADLINE = 48 h
ORDER_CHOICES = [{"amount":"KUDOS:1"}]
70.1.16.1.12. See Also¶
pepsi-config(1), pepsi-stage-bounce(1), pepsi-stage-discard(1), pepsi-httpd(1), pepsi-dispatch(1), pepsi-setup(1), pepsi.conf(5), pepsi.state(7)
70.1.16.1.13. Bugs¶
Report bugs to the Pepsi issue tracker.