85.1.17. pepsi-stage-auto-pay

pay GNU Taler delivery demands automatically

Manual section:

1

85.1.17.1.1. Name

pepsi-stage-auto-pay - the automatic pay-to-send settlement stage of the Pepsi pipeline.

85.1.17.1.2. Synopsis

pepsi-stage-auto-pay [GLOBAL-OPTIONS] worker

85.1.17.1.3. Description

pepsi-stage-auto-pay is a stage program run by pepsi-dispatch(1) as a persistent worker reading message ids on standard input. It has two roles, chosen per message:

  • On the inbound path it is the sending counterpart to pepsi-stage-anti-spam(1): when mail this site relayed is held at the far end behind a pay-to-send gate, that gate e-mails the original sender a payment demand, and this stage detects such demands and settles them automatically, so the held message is released and delivered.

  • On the submission path it is a wallet self-service endpoint: a locally submitted (authenticated) message addressed to the wallet control address lets the sender operate their own GNU Taler wallet by e-mail — check a balance, top it up, accept a peer push payment, or initiate a peer pull request. This role is enabled only when RESPONSE_STAGE is configured (see Wallet self-service below).

The two roles share one binary and are auto-detected, so a single PROGRAM = pepsi-stage-auto-pay may be placed on either pipeline (or both, as two [stage-<name>] sections).

A demand is recognised by the Taler: header that pepsi-stage-anti-spam(1) adds to its payment-request auto-reply, carrying one or more taler://pay/… URIs. Only taler://pay/ URIs are ever settled — any other taler:// scheme in that header (a pay-pull or withdraw URI, which would be a wallet-drain surface) is ignored — and a URI repeated across the header(s) counts once. A message with no Taler: header is ordinary mail and is forwarded to NEXT_STAGE untouched, so the stage is safe to place early on the inbound path.

Pepsi only pays for mail it provably originated. The same auto-reply echoes the original message’s Pepsi-Origin: proof-of-origin header (see pepsi-stage-anti-spam(1) and pepsi-stage-relay-to-internet(1)). This stage verifies it the same way a returning bounce is verified — the HMAC must check against our secret and the nonce must still be tracked in pepsi.origin_nonce. A demand that carries no valid proof (including every demand when [pepsi-origin] is not configured) is never paid: it is forwarded to NEXT_STAGE unchanged.

Warning

The header authenticates the deployment, not the individual bounce. It is stamped on the message that is delivered, so every recipient of that message holds a valid copy of it, and verification is an HMAC check plus an existence check of the nonce — the nonce is not consumed, not limited to one use, and not bound to the message it was minted for. Any recipient can therefore replay the header on a message of their own for the rest of the nonce’s validity window (14 days) and have this stage treat the Taler: demands they attach as demands about mail we originated. MAX_TOTAL is the only bound on what that costs, and it is per nonce — so set it to an amount you are willing to lose per outbound message, not per pipeline.

The proof also names the account the original message was sent for, and that account is the only one whose wallet may settle the demand: the demand’s envelope recipient must be that same account, compared case-insensitively. This binding is what makes the proof mean anything about who pays. On its own it says only that this deployment sent some message under that nonce — and every correspondent who has ever received Pepsi-relayed mail holds a valid, still-tracked copy of such a header — so a demand carrying somebody else’s Pepsi-Origin and addressed to a different local user would otherwise be settled out of that user’s wallet. A demand whose recipient is not the account named is forwarded to NEXT_STAGE unpaid. (Note this also means a message whose envelope sender was SRS-rewritten before relay — forwarded third-party mail — draws no automatic payment: the proof names the rewritten address, not the recipient the bounce comes back to.)

For a verified demand, the stage processes each taler://pay/… URI in turn, driving the wallet through the privileged pepsi-helper-auto-pay(1) helper:

  1. quote its price (the helper’s preview operation reads the order’s contract terms without paying);

  2. book it against a cumulative budget for the original message, capped by MAX_TOTAL, and against the paying wallet’s budget for the last 24 hours, capped by MAX_TOTAL_PER_DAY (when set); and

  3. if it fits under both, settle it (the helper’s pay operation spends wallet coins).

Every helper invocation is bounded (five minutes; the child is killed if it runs over) and a timeout counts as a failure, so the demand goes unpaid and the bounce is forwarded. A payment that timed out may nonetheless have gone through, which nothing here can tell; it is logged as an error saying so. The bound exists because the merchant backend a demand names is chosen by whoever wrote the Taler: header: an unbounded wallet command would leave the row running with its worker unable to take another message, and PARALLELISM such demands would stop the stage — including the wallet self-service role, if it shares the section.

The budget is keyed on the Pepsi-Origin nonce, which identifies the original outbound message. A message fanned out to a mailing list draws several demands — one per member whose site charges — that all carry the same nonce, so they share one budget: the per-message ledger in pepsi.origin_nonce.amount is advanced atomically as each demand is settled (the auto_pay_try_spend function), and a demand that would push the running total past MAX_TOTAL is refused. The cap is single-currency: a demand whose currency differs from MAX_TOTAL is not paid. An individual account can raise or lower its own cap with pepsi-stage-edit-settings(1) (the override applies to mail addressed to that account — i.e. the original sender, who receives the demand).

The daily budget. MAX_TOTAL_PER_DAY bounds what one wallet spends on demands in any 24 hours, however many original messages they are spread over. It is metered per wallet because the wallet is the one thing the stage always knows: under WALLET_MODE local-user each user’s own wallet; under shared with WALLET_SCOPE per-sender each sender’s wallet database; under unified the one wallet everybody shares, so the limit is then the whole deployment’s. It is deliberately not per correspondent: mailing lists and aliases mean the stage never knows for sure who is demanding. Every booking is recorded in pepsi.auto_pay_spend by the same auto_pay_try_spend call that advances the per-message ledger — both checks and both writes in one statement, serialised per wallet, so concurrent demands for different messages cannot together pass the limit either — and a demand refused by either budget books nothing against the other. The window is rolling, not a calendar day (which would allow twice the limit across midnight), and rows that have left it are pruned by the same function. The limit is single-currency and must be in MAX_TOTAL’s currency. Unset, only the per-message budget applies.

What MAX_TOTAL does and does not bound. The Taler: header — the merchant host, the order id, and therefore the price — is chosen entirely by the far end and is covered by nothing: the Pepsi-Origin MAC binds the version, our hostname, the originating account, the nonce and a timestamp, and so proves only that this deployment sent a message under this nonce for this account. It does not bind the demand, the merchant, or an amount. Every correspondent obtains a valid, 14-day-live proof simply by reading the mail you sent them, so a correspondent can always present a self-priced demand for up to MAX_TOTAL and be paid. MAX_TOTAL is therefore a bound per original message, not per correspondent and not per period: total exposure to one determined correspondent scales with how many messages you have sent them. Set it to a sum you are willing to pay for a single delivery, and set MAX_TOTAL_PER_DAY to what a wallet may lose in a day when every correspondent holding a proof cashes it in — that is the spend limit.

When every demand on a message has been settled, the now-redundant bounce is discarded (the row is deleted). If any demand cannot be priced, would exceed either budget, or differs in currency, the message is instead forwarded to NEXT_STAGE, so it proceeds as an ordinary bounce and the original sender is informed of the delivery problem as usual. A demand that was booked under the budget but then fails to pay is rolled back — the reservation is refunded to the per-message ledger, and its booking deleted from the daily one, by the auto_pay_refund function (so the failed, un-spent payment consumes neither budget) — and the message is likewise forwarded, with the error logged. Auto-pay tries to pay; if it cannot, it does nothing and lets the bounce through.

The exception is a helper that cannot be started at all (a missing binary, a lost setuid bit): that is a fault of the host, not of the demand, so before any demand of the message has been paid the message is retried rather than let through, and auto-pay does not silently stop paying. Once a demand has been paid, no error on the same message is retried — a retry would quote, book and pay the settled demands a second time — so the bounce is forwarded with the error logged.

85.1.17.1.4. Wallet account

The wallet the helper pays from is chosen by WALLET_MODE:

local-user

The helper drops to the paying account’s own local login — the account the verified Pepsi-Origin names, which is also the demand’s envelope recipient (the original sender) — and uses that user’s default wallet. It must resolve to a permitted local account (the same LOCAL_DOMAINS/TARGETS/RECIPIENT_DELIMITER test as pepsi-stage-relay-to-maildir(1)); a non-local account cannot be charged and the message is forwarded. The helper refuses a uid below /etc/login.defs UID_MIN in this mode, and pepsi-setup(1) warns about a TARGETS token that reaches below it.

shared (default)

The helper drops to a single dedicated account (WALLET_USER, default pepsi-wallets) whose home holds the wallet database(s). WALLET_SCOPE selects whether each original sender gets its own wallet database (per-sender, the default) or everyone shares one (unified — e.g. a business where individual wallets make no sense).

All wallet interaction is performed by the setuid-root pepsi-helper-auto-pay(1) helper; this stage spawns it and never touches a wallet directly. The stage binary is therefore installed SGID pepsi-wallets so its unprivileged dispatcher worker may exec the helper (see Installation).

85.1.17.1.5. Wallet self-service

When RESPONSE_STAGE is set, a locally-originated message (state.local_origin true; the submission listener must authenticate and bind the envelope sender) addressed to <CONTROL_LOCAL_PART>@<served-domain> (default local part pepsi-wallet; the domain is one of [pepsi-ingress] ACCEPTED_DOMAINS) is a wallet-control message, operating the sender’s own wallet (resolved by WALLET_MODE/WALLET_SCOPE exactly as for the paying role, but keyed on the sender). Any other message is forwarded to NEXT_STAGE untouched, so the stage is safe to place anywhere on the submission path.

The command is taken from the message Subject: line — one verb plus its arguments (the verb is case-insensitive):

balance

Report the wallet balance.

withdraw CURRENCY:VALUE EXCHANGE-URL (also spelt topup / top-up)

Set up a manual top-up of the given amount against the GNU Taler exchange at the given base URL (which must be an http:// or https:// URL). The exchange is named in the request (not configured on the server) because a wallet is multi-currency and can withdraw from many exchanges. The reply carries the bank payto:// target and the wire-transfer subject to use; once the user’s bank transfer arrives, the funds appear in the wallet.

pull CURRENCY:VALUE [subject]

Initiate a peer pull payment; the rest of the Subject line, if any, becomes the payment subject. The reply carries a taler://pay-pull/… URI to forward to whoever should pay.

push taler://pay-push/… (also spelt accept)

Accept an incoming peer push payment. The URI may be given in the Subject or in the message body.

help

Reply with the usage instructions — as an empty Subject, an unknown verb, or a missing or malformed argument also does.

When the sender resolves to no wallet at all (WALLET_MODE = local-user and a sender that is not a permitted local account), nothing is run and the reply says so.

The result is mailed back to the sender as a localised auto-reply (null sender, Auto-Submitted: auto-replied), built from the operator-customisable wallet.<lang>.body template and injected at RESPONSE_STAGE (where it is DKIM-signed and relayed); the original control message is then deleted. A request that cannot be understood is answered with usage instructions. A request that was understood but failed is answered with a fixed “could not be completed” message: the wallet’s own diagnostic goes to the operator log only, since it describes a wallet (shared by everyone under WALLET_SCOPE = unified) rather than the request.

The reply template is rendered once before the wallet command runs, so a missing template is retried with the wallet untouched. Once the command has run it is never run again for the same message: a reply that then cannot be rendered or queued is logged as an error and the request deleted.

If the wallet hits a KYC (identity-verification) requirement, the reply instead carries the KYC link for the user to open — this happens only for the self-service role, never while paying inbound demands.

85.1.17.1.6. Configuration

Options live in the stage’s own [stage-<name>] section (PROGRAM = pepsi-stage-auto-pay): the required MAX_TOTAL (a GNU Taler amount CURRENCY:VALUE bounding total spend per original message), the optional MAX_TOTAL_PER_DAY (the same currency, bounding one wallet’s spend in any 24 hours), a required NEXT_STAGE, and the wallet knobs WALLET_MODE, WALLET_USER, WALLET_SCOPE, WALLET_CLI, WALLET_PAY_OPTIONS and HELPER. The wallet self-service role adds RESPONSE_STAGE (its presence enables the role), CONTROL_LOCAL_PART, RESPONSE_FROM and RESPONSE_SUBJECT (a withdraw names its own exchange base URL in the Subject, so no exchange is configured). They are documented in pepsi.conf(5). The proof-of-origin secret is the shared [pepsi-origin] section (see pepsi-stage-relay-to-internet(1)); without it the stage can verify no demand and therefore pays none (it does not affect self-service).

85.1.17.1.7. Installation

Because the stage execs the setuid-root wallet helper, its binary is installed standalone (not folded into the unified pepsi binary) and SGID pepsi-wallets (mode 2550, owner pepsi:pepsi-wallets — owner-execute and not world-execute, because the setgid bit is the gate on the helper, and pepsi is deliberately not a member of the group, so the exec right has to come from the owner bits); the helper is installed setuid-root, root:pepsi-wallets, mode 4750. make install (targets install-auto-pay-stage / install-auto-pay-helper) and the Debian package apply these bits. The pepsi-wallets account and group are created by the package; create them by hand otherwise.

The setgid bit being that gate, the program states the same rule itself: when the binary actually carries that bit, then unless the real user is root or the pepsi service account it exits with an error before any configuration is read, and — like the setuid crypto stages — it strips the environment variables that steer configuration loading (HOME, XDG_CONFIG_HOME, PG*, TALER_*, PEPSI_*) and pins PATH to a safe default. (A build installed without the setgid bit — a development tree, make check — gained nothing and skips both steps.) A file mode is a deployment fact and this is a program fact; neither is asked to stand on its own.

85.1.17.1.8. State

Inputs: the Taler: and Pepsi-Origin: headers of the message (the body is also scanned for an embedded Pepsi-Origin). The per-message spend ledger is pepsi.origin_nonce.amount and the per-wallet daily one pepsi.auto_pay_spend, not the message state.

Outputs: the stage does not modify the message; it advances or deletes it. The state layout is described in pepsi.state(7).

Transitions: finish (delete) when all demands are settled, or for a wallet self-service control message (after injecting the reply at RESPONSE_STAGE); otherwise advance to NEXT_STAGE (not a demand, unverifiable, unpriceable, over budget, or a settlement failure). The stage never pauses, bounces or rewrites a message.

85.1.17.1.9. Commands

worker

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

85.1.17.1.10. Global Options

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

85.1.17.1.11. Exit Status

0

The message was processed (paid and discarded, or forwarded).

1

An error occurred (message not found or not running, or a per-address setting that breaks the stage’s section). The reason is written to the log.

A fault of the host (the database, a template or helper that cannot be used) is not reported as a failure: the message is paused and retried, as Stage errors in pepsi-dispatch(1) describes. A section that does not parse makes the worker refuse to start (status 78) instead of failing each message in turn. A missing MAX_TOTAL is such a section.

85.1.17.1.12. Examples

A pipeline section that pays delivery demands for locally-originated mail, up to five euro per original message and fifty a day in all, from a single shared pepsi-wallets wallet:

[stage-auto-pay]
PROGRAM = pepsi-stage-auto-pay
MAX_TOTAL = EUR:5
MAX_TOTAL_PER_DAY = EUR:50
NEXT_STAGE = local-delivery
WALLET_MODE = shared
WALLET_SCOPE = unified

Or, paying from each original sender’s own local wallet:

[stage-auto-pay]
PROGRAM = pepsi-stage-auto-pay
MAX_TOTAL = EUR:5
NEXT_STAGE = local-delivery
WALLET_MODE = local-user

Letting one account spend more (mailed by that account to pepsi@, see pepsi-stage-edit-settings(1)):

[stage-auto-pay]
MAX_TOTAL = EUR:20

A wallet self-service section on the submission path (a message to pepsi-wallet@example.com with a command in the Subject operates the sender’s wallet):

[stage-wallet]
PROGRAM = pepsi-stage-auto-pay
MAX_TOTAL = EUR:0
NEXT_STAGE = dkim-sign
RESPONSE_STAGE = dkim-sign
CONTROL_LOCAL_PART = pepsi-wallet

85.1.17.1.13. See Also

pepsi-helper-auto-pay(1), pepsi-stage-anti-spam(1), pepsi-stage-relay-to-internet(1), pepsi-stage-edit-settings(1), pepsi-config(1), pepsi-dispatch(1), pepsi.conf(5), pepsi.state(7), pepsi-setup(1)

85.1.17.1.14. Bugs

Report bugs to the Pepsi issue tracker.