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 valid Pepsi-Origin proof, 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 = false or state.paid = true — the message is forwarded to NEXT_STAGE. An explicit classification (set by some other component) overrides the payment flow.

  • state.paid = false is present — an order was created earlier and the message has been woken again (by the pepsi-resume payment 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 pending and a warning is logged. This should not happen in normal operation (a paused message only wakes on payment or at the deadline).

  • no paid field (first encounter) — the stage creates a v1 merchant order priced by ORDER_CHOICES (order_id is the message’s external token, so the pepsi-resume webhook’s {{order_id}} resolves back to this message), then pauses the message until PAYMENT_DEADLINE, recording paid: false and the absolute deadline in state.

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:

  1. the header’s HMAC verifies against our secret and its host matches our HOSTNAME (a fast, purely cryptographic check); and

  2. only 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>.body

The Mustache template for the payment-request reply body (TEMPLATE_DIR is the [pepsi] option, default ${DATADIR}/templates, i.e. /usr/share/pepsi/templates for a --prefix=/usr installation). The template for PAYMENT_MESSAGE_DEFAULT_LANGUAGE (default payment-request.en.body) is required; add the languages pepsi-stage-detect-language may detect alongside it. The template is given the variables recipient_name / has_name, protected_mailbox, original_subject, pay_uri / pay_link, qr_cid, wallet_url and the payment_options list (each amount plus optional description, 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.