70.2.1. pepsi.conf

configuration file for the Pepsi pipeline

Manual section:

5

70.2.1.1.1. Name

pepsi.conf - configuration file shared by all Pepsi components.

70.2.1.1.2. Description

Every Pepsi component — pepsi-ingress(1), pepsi-dispatch(1), every pepsi-stage-* program, pepsi-httpd(1), pepsi-queue(1), pepsi-tlsrpt(1), pepsi-setup(1) and the rest — reads the same INI-style configuration file, conventionally pepsi.conf. The format is shared with the GNU Taler components Pepsi builds upon.

Because there is one file, each setting is documented once, under the section that owns it. A component reads only the sections relevant to it: the shared [pepsi] and [pepsi-postgres] sections are read by everything; the [stage-<name>] pipeline sections are read by the dispatcher and the stage programs; component-specific sections ([pepsi-ingress], [pepsi-httpd], [pepsi-dispatch], [pepsi-payments], [pepsi-tlsrpt] …) by the component named.

70.2.1.1.3. File Format

A configuration file is a sequence of [SECTION] headers, each followed by OPTION = VALUE assignments:

[pepsi-ingress]
HOSTNAME = mail.example.org
ACCEPTED_DOMAINS = example.org example.com

Section and option names are case-insensitive and are conventionally written in upper case. Blank lines are ignored. A # or % at the start of a line introduces a comment. A value may be wrapped in double quotes to preserve leading or trailing whitespace.

70.2.1.1.3.1. Directives

@inline@ FILE

Include another configuration file, resolved relative to the current file.

@inline-matching@ GLOB

Include every file matching the shell glob GLOB.

@inline-secret@ SECTION FILE

Merge section SECTION from FILE (typically a mode-restricted file holding credentials) into the configuration. A file that cannot be read is ignored with a warning. This keeps secrets out of the world-readable main file.

A directive is a top-level line: like @inline@, it ends the section it appears in. Write it after the last option of the section it feeds — an option placed below it belongs to no section, and the whole file then fails to load with Expected section header or directive. pepsi-setup(1) always emits its directives last within a section for this reason:

[pepsi-srs]
SRS_DOMAIN = srs.example.org
MAX_AGE_DAYS = 21
@inline-secret@ pepsi-srs secrets.d/pepsi-ingress.secret

Because an unreadable fragment is only a warning, an option it should have supplied simply looks unset — a permission problem is reported by whatever needs the value, typically as “… is not configured”. Pepsi detects that case and says so explicitly, naming the fragment, its owner and mode; run pepsi-setup run as root to give each fragment to its reader account.

70.2.1.1.3.2. Value Substitution

Options read as paths undergo $-expansion: $VAR and ${VAR} are replaced with the value of VAR from the [PATHS] section or, failing that, the environment. The form ${VAR:-default} supplies a fallback. The [PATHS] section is pre-populated with the usual installation directories (PREFIX, BINDIR, DATADIR and so on).

70.2.1.1.3.3. Value Types

boolean

YES or NO (case-insensitive).

number

A decimal integer.

duration

A span such as 5 s or 2 h. See the caveat below.

path

A filesystem path subject to $-expansion as described above.

unix mode

A file permission written in octal, e.g. 660.

Warning

duration values are parsed by jiff::SignedDuration, which accepts only hours/minutes/seconds and below. A calendar unit such as 5 d or 2 weeks does not parse. For day-or-longer windows write the equivalent in hours (120 h) or use an integer option where one is provided (e.g. [pepsi-srs] MAX_AGE_DAYS).

70.2.1.1.4. The Database Overlay

This file is the base layer. On top of it, every component reads a set of administrator-managed overrides from the pepsi.config_override table of the shared schema, so a deployment can be reconfigured without editing files and, for the stage sections, without a restart. With no rows in that table a deployment behaves exactly as this file says; deleting every override returns it to that state.

The scope chain, lowest precedence first:

  1. this file;

  2. database scope global;

  3. database scope domain:DOMAIN;

  4. database scope address:ADDRESS;

  5. the per-address pepsi.settings table (pepsi-settings(1)).

Higher layers override lower ones option by option, never section by section: an option that no layer mentions keeps the value written here. The address a domain:/address: scope is matched against is the envelope sender for a locally-originated message, otherwise each envelope recipient.

The last layer is a different table with a different write authority, and that is deliberate. pepsi.settings is written by account owners themselves, by e-mail, within the EDITABLE_STAGES allowlist; config_override defines the pipeline and is writable only through the pepsi-config PostgreSQL role. pepsi-setup(1) grants that role INSERT/UPDATE/DELETE on the table, revokes them from every service account, and verifies both against the live database after each install: a stage worker parses hostile mail for a living and must not be able to rewrite the configuration that defines the stage it runs in. The separation is a security boundary, not duplication.

70.2.1.1.4.1. Sections That Are Never Read From the Database

An override naming one of these is ignored at run time and refused at write time:

[pepsi], [pepsi-postgres], [PATHS]

Read before a database connection can exist – and [pepsi] additionally holds the administrator-only cryptographic policy.

[pepsi-httpd], [pepsi-httpd-listener-*], [pepsi-httpd-cert-*]

The server that serves the configuration interface must always be able to start.

[pepsi-ingress-listener-*]

Listening sockets are bound at start-up, often under socket activation and before privileges are dropped.

Consequently adding a submission port, changing a listener’s TLS material, the database connection or the crypto policy is a text-editor-and-restart operation, and no administrative interface can do it. [pepsi-ingress] DB_POOL_SIZE and [pepsi-dispatch] DB_POOL_SIZE are file-only for the same bootstrap reason: the pool must exist before the overlay can be read.

70.2.1.1.4.2. Hot Reload

Every write to the table fires a config_changed notification.

[stage-*] option changes are applied without a restart: pepsi-dispatch(1) retires its stage workers on the notification – no message is interrupted – and the replacements read the new values, so the next message runs with the new configuration.

Every other section is read once at start-up by the component that owns it and therefore needs that component restarted. So do the two pipeline properties the dispatcher itself reads once: which stages exist, and each stage’s PROGRAM/PARALLELISM. pepsi-config set prints which case applies.

A component that misses a notification re-reads the overlay when its database listener reconnects, so a lost notification costs latency, not correctness.

Secrets are never stored in the database – only the @inline-secret@ reference is. Each fragment stays owned by the single reader that needs it.

A broken value in the database cannot stop a program from starting: an overlay that cannot be read or does not produce a usable configuration is logged loudly and this file is used unchanged.

See pepsi-config(1) for set/unset/list, for dump --origin (which annotates every effective value with the layer it came from), and for the encrypted export/import of this file and its secrets.d fragments.

70.2.1.1.5. The [pepsi] Section

Cross-cutting identity and policy settings shared across the Pepsi tools. This section is never read from the database overlay.

KEY_DIR = PATH

Directory below which per-domain signing keys are stored. Each domain gets a sub-directory (mode 0700) containing dkim.rsa.key and dkim.ed25519.key (mode 0600). Optional; defaults to /var/pepsi/keys.

DKIM_SELECTOR = NAME

Base DKIM selector. The RSA key is published as a TXT record at <NAME>._domainkey.<domain> and the Ed25519 key at <NAME>-ed25519._domainkey.<domain>. Optional; defaults to pepsi.

ARC_DOMAIN = DOMAIN

Domain whose identity pepsi-stage-arc(1) uses to ARC-seal inbound mail (RFC 8617). The seal reuses this domain’s DKIM key, so pepsi-setup(1) adds it to the set of domains it generates keys and DKIM DNS records for; ARC needs no separate record. Optional; if unset, authentication results are recorded but not sealed.

ARC_ALGORITHM = rsa | ed25519

Algorithm that signs the single ARC seal (ARC permits only one per hop, unlike DKIM which is signed with both). Optional; defaults to rsa for the widest interoperability.

ORIGINATE_SUCCESS_DSN = yes | no

Whether Pepsi may originate a positive (Action: delivered) delivery-status notification when a delivery succeeds and the sender requested NOTIFY=SUCCESS. Optional; defaults to no — Pepsi normally emits only failure bounces. When yes, the delivery stages (pepsi-stage-relay-to-internet(1), pepsi-stage-relay-to-smarthost(1)) and pepsi-stage-discard(1) route a successful delivery to their BOUNCE_STAGE to emit the report. Pepsi never originates DELAY reports.

MAILBOX_QUOTA = SIZE

Default limit on how much mail a local account may hold, applied by pepsi-stage-relay-to-maildir(1). A plain byte count or a K/M/G/ T suffix in powers of 1024 (2G, 500M, 1048576), or none. Optional; defaults to none — no quota. A single account’s limit is set with pepsi-quota(1) set, which overrides this; the kernel’s own limit tightens whichever applies.

The quota covers the whole Maildir++ tree — the INBOX plus every folder (.Sent, .Trash, …) — because that is what the user calls their mailbox, and counting only the INBOX would let anyone park unlimited mail one drag-and-drop away from it.

MAILBOX_QUOTA_COUNT = N

Default limit on the number of messages an account may hold; 0 or absent for none. A very large number of very small messages is its own denial of service against a mailbox, which a byte limit alone does not prevent.

MAILBOX_OVER_QUOTA = defer | bounce

What happens to a recipient whose mailbox is full. Optional; defaults to defer.

defer keeps the message queued and retries it until the delivery stage’s MAX_LIFETIME, then bounces — what Pepsi has always done with a kernel EDQUOT, and it gives the account time to make room. bounce refuses at once with an RFC 3463 5.2.2, routing the recipient to the delivery stage’s QUOTA_LIMIT_STAGE, so the sender is not left waiting for days.

The same choice picks the reply pepsi-ingress(1) gives when it refuses an over-quota recipient at RCPT: 452 4.2.2 for defer, 552 5.2.2 for bounce.

MAILBOX_QUOTA_MAX_AGE = DURATION

How long a mailbox measurement may be trusted for a RCPT-time refusal. Optional; defaults to 15 m. Units are h/m/s.

pepsi-ingress(1) refuses an over-quota recipient only on a measurement younger than this, and never on the running usage estimate. That is not a performance trade-off but a correctness one: the estimate only ever grows (nothing tells Pepsi when a user deletes mail over IMAP), so refusing on it would shut a mailbox whose owner has since emptied it — permanently, because with every message refused no delivery would run to re-measure. This bound, and pepsi-quota(1) reconcile, are what keep that from happening.

MAILBOX_QUOTA_RCPT_CHECK = yes | no

Whether pepsi-ingress(1) consults the quota table at RCPT time. Optional; defaults to yes. Refusing there costs one indexed lookup and saves a bounce: the sending MTA is still on the line, so it tells its user, and Pepsi generates no backscatter to an envelope sender that may well be forged. Turning it off moves all enforcement to delivery time.

MAILBOX_FS_QUOTA = yes | no

Whether the filesystem holding home directories enforces kernel disk quotas, in which case Pepsi consults quotactl(2) as well and lets the kernel’s limit tighten the effective one. Optional; defaults to no.

Normally set for you. This is a fact about the host’s mounts, not a preference, so pepsi-setup(1) probes for it and records the answer without asking. An explicit value is never overwritten. It is worth having on where it applies: the kernel’s accounting is the only layer of the three that the account owner cannot tamper with (the Maildir++ maildirsize file lives in their own directory), and it counts everything they own on that filesystem rather than only their mail.

RECIPIENT_DELIMITER = CHARACTER | none

The site-wide sub-address separator (alice+lists is account alice). Optional; defaults to +. A [stage-<name>] section may override it for its own delivery decisions, but prefer setting it here: pepsi-ingress(1) uses this value to work out whose mailbox quota a recipient belongs to and has no stage section to read, so a stage that disagrees makes sub-addressed mail escape the RCPT-time refusal. pepsi-setup(1) warns when one does.

MAIL_LOG = off | summary | full

Whether to keep a per-message record. Optional; defaults to off, and the default is the point.

Pepsi deletes a message’s row when the pipeline is finished with it, so there is normally no per-message success log — a property of the design rather than an omission: an ordinary deployment does not accumulate a record of its users’ correspondence, and cannot be compelled to produce one it does not have.

Setting this option changes that. summary writes one pepsi.mail_log row as a message leaves the pipeline, carrying the envelope sender and recipients, the direction (outbound for locally-submitted mail, inbound otherwise), the stage it ended at, the outcome (completed/failed) and what the pipeline decided about it (the authentication verdict, the spam/paid decision, any next-hop failure detail). full additionally records the Subject: line. Message content is never recorded at any setting.

Warning

Turning this on means the deployment keeps a record of who corresponds with whom. Some deployments genuinely need that evidence; make sure yours is one of them, and that keeping it is lawful where you operate.

Rows are readable through GET /api/v1/mail-log by a principal holding logs:read, are append-only for every component that processes mail, and are pruned after [pepsi-admin] MAIL_LOG_RETENTION_DAYS days. The write rides on the same statement as the terminal that removes or fails the message, so enabling the log costs no extra database round-trip. See the manual’s “The administrative API” chapter.

ALLOW_FUSION = yes | no

Whether stage fusion is permitted. When a stage advances to a successor that is marked FUSION = yes (see the per-stage option below), whose PROGRAM is folded into the same unified pepsi binary, and that needs no message column the predecessor did not already load, the predecessor runs the successor’s body in its own worker process — skipping both the advancing UPDATE and the successor’s loading SELECT, and reporting a single completion to the dispatcher for the whole fused chain. Optional; defaults to yes. Set it to no to force every stage transition back through the database and the dispatcher (the per-stage cost benchmark does this so each stage is measured as its own dispatched worker). Fusion is in any case inert in a per-program (multibin) build, where a successor is always a separate process. See pepsi-dispatch(1) for the full model.

MTA_STS_MODE = enforce | testing | none

Mode of the MTA-STS policy published for our domains. enforce and testing cause pepsi-setup(1) to emit a _mta-sts.<domain> TXT record (and pepsi-httpd(1) to serve the corresponding policy file); none disables MTA-STS publishing. Optional; defaults to enforce. The policy’s mx host is the [pepsi-ingress] HOSTNAME.

MTA_STS_MAX_AGE = SECONDS

The max_age published in the MTA-STS policy (how long senders may cache it). Optional; defaults to 604800 (one week).

TEMPLATE_DIR = PATH

Directory holding message templates (<name>.<lang>.body) expanded at runtime — for example the pepsi-stage-anti-spam(1) payment-request reply and the pepsi-stage-bounce(1) BOUNCE_MESSAGE body. Optional; defaults to ${DATADIR}/templates, where make install places the bundled templates. ${DATADIR} is resolved from the install prefix of the running binary, so the default is /usr/share/pepsi/templates for a --prefix=/usr installation and follows the prefix for any other; set this option only if the templates live somewhere the prefix does not imply.

LOG_JSON = yes | no

Emit logs as structured JSON lines (one JSON object per record) on standard error instead of the default human-readable text format, so a log aggregator such as Loki can ingest them without extra parsing. Applies to every Pepsi binary. Optional; defaults to no (text). The -L/--log log-level option and -v/--verbose apply in both formats.

LOG = error | warn | info | debug | trace

Default maximum log level for every Pepsi binary. Because all components — including the stage workers the dispatcher spawns (which receive no command-line log flag of their own) — load this same configuration, this is the single knob that lowers or raises logging across the whole pipeline at once; it is, for instance, what the benchmark scripts set to warn so per-message logging does not skew their measurements. Optional; defaults to info. The -L/--log flag, when given, overrides it for that one process.

SHARE_TELEMETRY = yes | no

Whether this deployment shares anonymous feature-usage telemetry with the Pepsi project. Opt-in: optional, and defaults to no, so an installation that never sets it shares nothing. When set to yes, pepsi-setup(1) generates a random SYSTEM_ID (below) and the pepsi-telemetry-client(1) daemon aggregates feature-usage events from the stage workers and pepsi-ingress(1) and submits per-feature counts to TELEMETRY_SERVER under that anonymous id (no addresses, message data, host names or IP addresses). Left at no, the daemon exits immediately, no SYSTEM_ID is generated, and the in-process telemetry calls are no-ops that open no socket. An unparseable value is read as no.

SYSTEM_ID = 64-HEX

Anonymous 256-bit identifier (64 hexadecimal characters) submitted with every telemetry report. Generated and written here by pepsi-setup(1) only once SHARE_TELEMETRY has been turned on — a deployment that has not opted in never has one minted for it; required by pepsi-telemetry-client(1). Do not reuse it across deployments.

TELEMETRY_SERVER = HOST | URL

The central telemetry collector. Optional; defaults to telemetry.pepsi.taler.net. pepsi-telemetry-client(1) submits to https://<server>/telemetry/{usage,features}; a value containing an explicit scheme:// is honoured verbatim (e.g. http://localhost:18080 for local testing).

The following CRYPTO_* options govern end-to-end message cryptography (OpenPGP and S/MIME) — not the DKIM/ARC keys above. They are deliberately system-administrator settings and live in [pepsi] rather than in a [stage-*] section, which puts them structurally out of reach of the per-address override layer (pepsi.settings overrides only stage sections) and therefore of pepsi-stage-edit-settings(1): algorithm selection, downgrade permission, weak-digest acceptance and minimum key sizes are security decisions with a secure default, not per-user preferences. Every one is optional; a deployment that does no end-to-end cryptography can ignore them all. The key store those keys live in is configured in [pepsi-crypto] (below) and managed with pepsi-keys(1).

CRYPTO_ALLOW_DOWNGRADE = no | negotiate | yes

Whether Pepsi may emit a downgraded container (S/MIME AES-256-CBC EnvelopedData, OpenPGP SEIPDv1-with-MDC) and accept 3DES on decryption. Optional; the value compiled in is negotiate, which downgrades only when the recipient’s own key proves it can do no better — which matters, because much of the installed base cannot read an RFC 9580 AEAD message at all. Reading SEIPDv1 and AES-CBC is always permitted and is not gated by this option.

An installed Pepsi ships yes, from ${DATADIR}/config.d/thunderbird.conf, so the effective default on a packaged system is S/MIME AES-256-CBC EnvelopedData. Thunderbird 140.12.0esr cannot read our AES-256-GCM AuthEnvelopedData and fails silently — no error, an empty plaintext — so a deployment left on the compiled-in default would send blank mail to the largest S/MIME client population there is. Deleting that file restores AEAD in one step, and CRYPTO_ALLOW_DOWNGRADE = negotiate in pepsi.conf does the same without deleting it (pepsi.conf is parsed after config.d). The measurement is pepsi-crypto/tests/thunderbird.rs; the client matrix is in the manual’s interoperability chapter.

yes does not coarsen OpenPGP: the encrypt stage requests the container auto, which both negotiate and yes resolve per recipient — AEAD for a certificate that advertises SEIPDv2, SEIPDv1+MDC only for one that does not. The difference between the two values is the S/MIME container and the acceptance of an inbound 3DES S/MIME message, which is never emitted under any setting.

pepsi-setup does not write this option. The wizard deliberately leaves it out of the generated pepsi.conf — anything it wrote there would override the packaged file — so a deliberate override is made by editing pepsi.conf by hand or with pepsi-setup --wizard --expert=CRYPTO_ALLOW_DOWNGRADE.

CRYPTO_ALLOW_WEAK_DIGESTS = yes | no

Accept SHA-1 and MD5 signatures on verification. Optional; defaults to no. A signature under a weak digest is reported as such, never silently counted as valid.

CRYPTO_MIN_RSA_BITS = BITS

Smallest RSA modulus accepted on inbound verification and outbound encryption. Optional; defaults to 2048. Values below 1024 are refused outright.

CRYPTO_GENERATE_RSA_BITS = BITS

Modulus size used when this deployment generates an RSA key. Optional; defaults to 2048. It may not be below CRYPTO_MIN_RSA_BITS — a deployment must not generate keys it then refuses to accept — and OpenPGP key generation accepts only 2048, 3072 or 4096.

CRYPTO_INLINE_PGP = accept | reject

How to treat inline (non-MIME) PGP. Optional; defaults to accept, because real senders still emit it. Pepsi never generates it.

CRYPTO_OPENPGP_ALGORITHM = ed25519 | rsa

Algorithm used when generating a new OpenPGP identity. Optional; defaults to ed25519 — generation is instantaneous, which matters because an identity may have to be created while a message waits. rsa uses CRYPTO_GENERATE_RSA_BITS.

CRYPTO_SMIME_ALGORITHM = rsa | p256 | p384

Algorithm used when generating a new S/MIME identity. Optional; defaults to rsa (at CRYPTO_GENERATE_RSA_BITS); p256 and p384 are ECDSA/ECDH over the corresponding NIST curve.

CRYPTO_SMIME_SHARED_KEY = yes | no

Issue one S/MIME certificate carrying both digitalSignature and keyEncipherment instead of a separate signing and encryption certificate. Optional; defaults to no.

The default splits them because a separate signing certificate can be destroyed at expiry or revocation — nothing legitimate ever re-signs old mail, so a retired signing key is pure forgery liability — while the decryption half is retained so archived and in-flight ciphertext stays readable. Turning this on halves the certificates an operator buys per identity and gives that up: a shared key follows the decryption rule, so revoking a compromised signing key also ends the ability to receive new encrypted mail at that certificate.

The option decides only what newly created identities look like. Both shapes are supported permanently and may coexist for one address, so every lookup resolves by capability rather than by shape. Combined with an elliptic-curve CRYPTO_SMIME_ALGORITHM it is refused by pepsi-setup(1): one EC key doing both ECDSA and ECDH is cross-algorithm key reuse that several S/MIME clients reject.

70.2.1.1.6. The [pepsi-postgres] Section

Shared database connection settings. Every Pepsi component connects to the same database and the shared pepsi schema, so these settings live in one section rather than being repeated per component.

Note

Connection budget. Each Pepsi process opens its own connection pool. The stage workers and the operator CLIs do their database work strictly serially, so they use a single connection each — and because the dispatcher runs up to one worker process per stage slot (the sum of the stages’ PARALLELISM), this single-connection-per-worker rule is what keeps the bulk of the connection count predictable and minimal. The concurrent components keep a small bounded pool sized by their own DB_POOL_SIZE option: the pepsi-dispatch(1) coordinator (default 8, for the many workers’ concurrent result-processing) and the pepsi-ingress(1) and pepsi-httpd(1) servers (default 1 each). When tuning, ensure Σ PARALLELISM (one connection per live worker) + the dispatcher pool + the two servers’ pools stays below the server’s max_connections. A stage’s QUEUE_LIMIT does not enter this budget: pipelining more messages onto a worker raises its in-flight count, not the number of worker processes (or connections), so it is the safe knob for throughput when max_connections is the binding constraint.

CONFIG

(string, required) PostgreSQL connection string or URI, e.g. postgres:///pepsi. The server sets the schema search path to pepsi and uses READ COMMITTED transactions (at most one writer ever touches a given queue row, so serializable isolation is unnecessary).

SQL_DIR

(path, optional) Directory containing the SQL migration files. Only pepsi-setup(1) reads this, when installing the schema; the serving components ignore it. Defaults to ${DATADIR}/sql (i.e. <install-prefix>/share/pepsi/sql, where make install places the files). Override it when running from a source checkout.

WORKER_SYNCHRONOUS_COMMIT

(boolean, optional) Whether a stage worker’s database connection keeps PostgreSQL’s synchronous_commit on. Defaults to no.

With it off, a worker’s COMMIT returns before the write-ahead log record has reached stable storage, which lets the pipeline’s writes group-commit instead of each paying its own flush. Nothing else changes: isolation, atomicity and visibility are unaffected, and a crash can never expose a partial transaction. What can be lost is the last few hundred milliseconds of durability — and only for stage transitions, never for message acceptance, because pepsi-ingress(1) always commits synchronously. A 250 still means the message is on disk.

Losing a stage advance to a machine crash means the message is found still at the previous stage and runs through it a second time — exactly what already happens when a worker is killed after doing its work but before committing. Set this to yes if a stage in your pipeline has side effects that must not be repeated even across a power loss, and accept the throughput cost.

70.2.1.1.7. The [pepsi-srs] Section

The Sender Rewriting Scheme parameters, shared by pepsi-stage-srs(1) (the forward rewrite) and pepsi-ingress(1) (the reverse decode at RCPT time), so the secret and domain match in both directions. Omitting the section disables SRS: the stage refuses to run and ingress treats SRS-looking recipients as ordinary addresses.

SRS_DOMAIN

(string, required to enable SRS) Domain that rewritten envelope senders live under. It must be a domain you control: its MX must point at the Pepsi ingress (so bounces return) and its SPF must authorise Pepsi’s sending IPs. pepsi-setup(1) provisions keys and DNS for it.

SECRET_FILE / SECRET

(required when SRS_DOMAIN is set) The secret keying the HMAC that signs SRS addresses, given as a file path (preferred; store it mode 0600) or inline. It must stay stable over time — a bounce returned days later must still verify — and be identical across all instances.

MAX_AGE_DAYS

(integer, optional) How many days a rewritten address stays valid for returning bounces (SRS timestamps are day-granular). Default 21.

70.2.1.1.8. The [pepsi-origin] Section

Proof-of-origin keying, shared by both relay stages (pepsi-stage-relay-to-internet(1) and pepsi-stage-relay-to-smarthost(1), which stamp the Pepsi-Origin header on outbound mail and record its nonce) and pepsi-stage-anti-spam(1) (which verifies the header on a returning bounce). The feature is on by default: when no secret is configured, pepsi-setup(1) generates a random one on first run (provided [pepsi-ingress] HOSTNAME is set). The secret is not stored inline in this world-readable file; it is written to a secrets.d/pepsi-origin.secret fragment beside the configuration (owned by the pepsi dispatcher account, mode 0640) and pulled into this section with an @inline-secret@ directive. Re-running setup reuses the existing fragment rather than rotating the key. Removing the section disables the feature — outbound mail is unstamped and every bounce is then unverifiable (routed per the stage’s BOUNCE_TARGET_STAGE, else forwarded).

The header carries the version, our HOSTNAME (the [pepsi-ingress] option), the originating sender account, a 128-bit nonce and a timestamp, plus an HMAC-SHA256 over all of them. Each stamped nonce is recorded in pepsi.origin_nonce for two weeks; a bounce is trusted only if its embedded header’s MAC verifies and its nonce is still tracked. See pepsi-stage-anti-spam(1).

SECRET_FILE / SECRET

The secret keying the HMAC, given as a file path (preferred; store it mode 0600) or inline. Like the SRS secret it must stay stable over time — a bounce returned days later must still verify — and be identical across all instances. When neither is set, pepsi-setup(1) provisions a random SECRET in a secrets.d/pepsi-origin.secret fragment referenced by @inline-secret@ (see above), keeping it out of the world-readable main configuration. The feature requires [pepsi-ingress] HOSTNAME (bound into the MAC).

70.2.1.1.9. The [pepsi-crypto] Section

Custody and lifecycle of the end-to-end key store — the pepsi.crypto_identity, pepsi.peer_key and pepsi.ca_trust tables managed by pepsi-keys(1). Like the [pepsi] CRYPTO_* options above this is administrator-only, but it is about storage rather than about cryptographic strength. Omit the whole section if the deployment does no end-to-end cryptography; as soon as it says anything, pepsi-setup(1) insists on a usable KEY_WRAP_SECRET, because a key store that cannot store keys is a configuration mistake rather than a choice.

Private key material is kept in the database, sealed with AES-256-GCM under a key-encryption key derived from KEY_WRAP_SECRET. The database alone therefore never yields a private key: a dump, a replica or a backup tape produces ciphertext, and the key that opens it is a separate artefact guarded by file permissions. The other half of the boundary is a database grant — crypto_identity.private_wrapped is granted to the pepsi-crypto role alone; see pepsi-setup(1).

KEY_WRAP_SECRET

(string, required once the section exists) The key-encryption key that wraps private key material at rest. Keep it out of this world-readable file: put it in a secrets.d/pepsi-crypto.secret fragment and reference it with an @inline-secret@ directive, exactly as the SRS and proof-of-origin secrets are handled. Every pepsi-setup(1) run re-asserts mode 0640 on that fragment and hands it to the pepsi-crypto account. Fewer than 16 characters is refused.

Back it up separately from the database. If it is lost, every stored private key is gone: mailboxes hold plaintext, so nobody’s mail becomes unreadable, but in-flight and archived ciphertext is lost, every published identity is invalidated and the organisation must re-key.

KEY_WRAP_KEY_ID

(string, optional) Identifier of the key-encryption key new material is wrapped under. Default k1. Must be a non-empty run of ASCII letters, digits and underscores, because it is spliced into the KEY_WRAP_SECRET_<ID> option name a retired key is read from. Each row records the id that sealed it, which is what makes rotation incremental rather than a flag day.

KEY_WRAP_SECRET_<ID>

(string, optional) A retired key-encryption key, kept until every row wrapped under it has been rotated. <ID> is the wrap_key_id it names, upper-cased (so KEY_WRAP_SECRET_K0 for key k0). Between introducing a new key and running pepsi-keys wrap rotate the deployment keeps working: new material is sealed under the new key while old material still opens under this one. Remove it once the rotation reports everything re-wrapped.

AUTO_CREATE_IDENTITY

(boolean, optional) Whether an identity may be generated automatically for a served address. Default YES, but the trigger is lazy: creation happens the first time a local sender explicitly asks for protection, never merely because an address appeared in an envelope, so a user who never uses the feature never has key material. (The option is read and validated today; the stage that acts on it ships with the rest of the end-to-end cryptography work, so until then every identity is created by hand with pepsi-keys(1).)

The consequence is worth knowing, because it is the one surprising part of the design: the trigger is on the outbound path, so an address that only ever receives stays keyless and is never published — outsiders therefore have nothing to encrypt to it with. Pre-provision such addresses with pepsi-keys identity generate.

IDENTITY_VALIDITY_DAYS

(number, optional) Lifetime of a generated identity, in days. Default 730 (two years); must be positive. An integer count of days rather than a duration because the parser rejects calendar units (see the warning at the top of this page). pepsi-keys identity generate --days overrides it for one identity.

LOCAL_DOMAINS, TARGETS, RECIPIENT_DELIMITER

(string, optional) Which domains and accounts an address is attributed to when deciding who owns an identity — the same options, with the same meanings and defaults, that the local-delivery stages use (pepsi-stage-relay-to-maildir(1)). LOCAL_DOMAINS defaults to [pepsi-ingress] ACCEPTED_DOMAINS, TARGETS to the /etc/login.defs UID_MIN..``UID_MAX`` range and RECIPIENT_DELIMITER to +. An address that resolves to a local account has that login recorded with its identity and may be managed by that user; one that does not (a role address, a hosted domain) is operator-only.

70.2.1.1.10. The [pepsi-keydiscovery] Section

How Pepsi finds a correspondent’s public key or certificate — the input to pepsi.peer_key, as distinct from [pepsi-crypto] above, which is about custody of our own key material. Read by the pepsi-keydisc(1) discovery services, by pepsi-keys(1) (whose peer refresh --address runs the same fan-out in one process), and by any stage that must park a message on a key it does not yet have.

The whole section is optional: with none of it, discovery runs with the default source set, which is what an operator who never thought about it should get.

Discovery is asynchronous. A stage that needs a key it does not have in cache performs no network I/O of its own — it commits its work, pauses the message and enqueues a request in pepsi.key_request, and a discovery service answers the request and releases every message parked on that address. See pepsi-keydisc(1) for the service side and pepsi.state(7) for the keydisc key a parked message carries.

SOURCES

(string, optional) Which discovery methods this deployment runs, as a comma- or whitespace-separated list of dane, wkd-advanced, wkd-direct, ldap and vks (wkd is accepted for wkd-advanced, hkp for vks; duplicates are ignored). Default dane wkd-advanced wkd-direct vksldap is deliberately absent, being feature-gated and useless until configured.

This list is also the roster the stop rule waits for, which has two consequences. A pepsi-keydisc@<method> instance whose method is not listed refuses to start; and a listed method with no instance running merely costs every request the full TIMEOUT before it settles. Keep the list and the enabled units in step. inbound is rejected: harvesting keys out of a message is not a service, so nothing waits for it.

MIN_TRUST

(string, optional) The trust floor — a key from a worse-ranked source is not used. Named by its source: manual/api, dane, wkd-advanced, wkd-direct, ldap, vks, harvested (also spelled inbound or autocrypt), or gossip (also autocrypt-gossip, any, none). Default anyaccept anything.

That default is a decision, not an oversight: the alternative to encrypting to a trust-on-first-use key is not encrypting to a better key, it is sending cleartext. Raising it is how a deployment that would rather send nothing than send to an unauthenticated key says so. The ranking still does work at the default: it decides conflicts, and it is recorded per key.

any/none name the bottom rung, whatever it currently is, while every other spelling names one rank exactly. The distinction matters for the two that look alike: harvested is rank 7 — what a correspondent said about their own address, in an Autocrypt: header or an attached key — and gossip is rank 8, one correspondent’s introduction of another (Autocrypt Level 1 §5.3). So MIN_TRUST = harvested is the useful middle setting: keep opportunistic encryption, refuse third-party introductions. Gossiped keys are still stored either way; the floor decides only whether they may be used.

The floor does not apply to an address this deployment holds an identity for. Our own key is not a discovered key: it pre-empts the lookup, so no peer_key row for that address is consulted whatever its rank, and no floor can exclude it. own (also local) is accepted as a spelling for completeness — every rank has a name and state.crypto renders this one — but as a floor it means “encrypt only to addresses we hold our own key for”, i.e. no external correspondent ever receives ciphertext. See pepsi-stage-encrypt(1) and pepsi-stage-decrypt(1).

TIMEOUT

(duration, optional) Whole-request deadline: how long a request may stay outstanding before it settles with whatever has been found. Default 60 s. Uses h/m/s units only (see the duration note above).

INBOUND_TIMEOUT

(duration, optional) The same deadline for a message parked on the inbound path — waiting for the key that verifies a signature — defaulting to TIMEOUT. It is separate precisely so it can be lowered on its own: an inbound park delays exactly the mail somebody is waiting for, so an operator who feels it can drop this to seconds without shortening the outbound deadline. pepsi-setup(1) warns above five minutes.

RANK_GRACE

(duration, optional) The speed-versus-thoroughness dial. When a lookup succeeds, every method still running that ranks better is granted RANK_GRACE × (how many ranks better it is) of extra time; when that expires, the best answer in hand wins. Default 5 s, so one rank up gets 5 s and three ranks up gets 15 s.

One knob spanning the whole range: 0 means the first usable answer wins outright; the default sits where a slow WKD server cannot hold a message behind a key-server hit that has already arrived, but a nearly-as-fast better source still gets to win; a value at or above TIMEOUT means every method is waited for and the ranking applies in full (pepsi-setup(1) notes that case rather than treating it as an error — it is a legitimate choice).

METHOD_TIMEOUT

(duration, optional) One method’s own network deadline, covering connect, handshake and every read. Default 15 s. Setting it above TIMEOUT makes a slow method unable to finish inside the whole-request deadline, which pepsi-setup(1) warns about.

CACHE_TTL

(duration, optional) How long a stored key is used before pepsi-keys peer refresh re-checks it (it becomes the row’s refresh_after). Default 24 h.

NEGATIVE_TTL

(duration, optional) How long an authoritative “this address has no key” is remembered. Default 24 h. While the entry is fresh a message for that address takes the no-key path immediately and never parks: pausing 60 s to re-learn a known answer only delays every message to that correspondent. The accepted cost is that a key published five minutes ago stays invisible until the entry ages out — lower this if that matters more than the delay.

ERROR_TTL

(duration, optional) The same, after a method failed rather than answering “nothing here”. Default 1 h: “the server was down” is worth re-trying sooner than “there is no key”.

MAX_RESPONSE_BYTES

(number, optional) Cap on a fetched WKD or VKS response body, enforced while streaming rather than afterwards, so a 10 MB body costs the cap and not 10 MB. Default 262144 (256 KiB). Must be greater than zero.

VKS_SERVERS

(string, optional) Verifying key servers to query, in order, as a comma- or whitespace-separated list. Default https://keys.openpgp.org. Every entry must be an https:// URL — a key fetched over an unauthenticated channel is not a key there is any reason to believe — and a trailing / is stripped.

ALLOW_DOMAINS, DENY_DOMAINS

(string, optional) Recipient-domain lookup policy, comma- or whitespace-separated and matched case-insensitively on the domain part. A non-empty ALLOW_DOMAINS is exclusive (only those domains are ever looked up); DENY_DOMAINS always wins over it. Both empty by default, so every domain may be looked up. An address with no @ is never looked up.

This is the recipient-side privacy control, and it is the operator’s. The per-user switch below is the sender side; the two are not interchangeable.

LDAP_URL, LDAP_BASE, LDAP_FILTER, LDAP_ATTRIBUTE, LDAP_BIND_DN, LDAP_BIND_PASSWORD

(string, optional) The directory pepsi-keydisc@ldap queries. LDAP_URL must be an ldap:// or ldaps:// URL and, with LDAP_BASE, is required once ldap is in SOURCES. LDAP_FILTER defaults to (mail={address}) and must contain the {address} placeholder (the address is RFC 4515-escaped before substitution, so a local part containing * or ( cannot become filter syntax); LDAP_ATTRIBUTE defaults to userCertificate;binary. An absent LDAP_BIND_DN means an anonymous bind.

LDAP support is a compile-time cargo feature. On a build without it, ldap in SOURCES is rejected by pepsi-setup(1) and the pepsi-keydisc@ldap instance refuses to start.

70.2.1.1.10.1. The per-sender switch: [stage-encrypt] DISCOVERY

A stage that consumes the key store reads one option of its own, in its [stage-<name>] section, so it can be overridden per address through the pepsi.settings layer (pepsi-settings(1)):

DISCOVERY

(boolean, optional) Whether a cache miss may trigger a network lookup. Default YES. With NO the cached keys are still used, but a miss goes straight to the stage’s no-key path instead of parking the message — there would be nothing to wait for.

Important

What the per-address override actually means. The settings layer resolves an outbound message on its envelope sender, so an override of DISCOVERY for an address means “this user’s mail never triggers a lookup”not “never look this correspondent up”. Recipient-side suppression is the operator’s ALLOW_DOMAINS/DENY_DOMAINS above; there is deliberately no per-address recipient denylist, which would need its own column and a second list to stay consistent with this one.

DISCOVERY is read by pepsi-stage-encrypt(1) and pepsi-stage-decrypt(1) from that stage’s effective section, so a pepsi.settings override applies. Their other options are documented under pepsi-stage-encrypt Options and pepsi-stage-decrypt Options.

70.2.1.1.11. The [pepsi-keys] Section

The mirror image of the section above: how other people find our users’ keys. Read by pepsi-keys(1) and by pepsi-stage-vks-confirm(1). The whole section is optional, and a configuration that omits it publishes nothing to any key server.

Web Key Directory publication needs no options at all. It follows each identity’s published flag (on by default, cleared with pepsi-keys identity unpublish) and is served by pepsi-httpd(1) from our own domain, so it takes effect on the next request and is undone by clearing the flag. What this section configures is the other channel, which behaves nothing like it: a key server accepts a key permanently — it can later be told the key is revoked, never told to forget it.

VKS_PUBLISH

(boolean, optional) Whether every published OpenPGP identity is uploaded to the key server automatically and retried until verified. Default NO.

Deliberately an operator-level switch and not a per-identity one. An upload cannot be withdrawn, so the decision belongs to whoever understands that rather than to whoever happened to generate a key; and a per-user opt-in is a step essentially nobody takes, so making it per-user would mean key-server discoverability never materialised at all. What it must not be is a consequence of a default: uploading a user’s address to a third party should be something an operator decided. With the switch off, pepsi-keys identity publish <ID> --vks remains available as a confirmed one-off.

VKS_SERVER

(string, optional) The key server uploads go to; https:// only, and a plaintext URL is refused rather than downgraded. Defaults to the first [pepsi-keydiscovery] VKS_SERVERS entry, so a deployment that already chose a key server to read from does not name it twice.

One server, not a list: publishing the same key to several multiplies an irreversible act, and every one of them then has to be kept up to date with revocations.

Its host is also the default VKS_HOST of pepsi-stage-vks-confirm(1) — the one host that stage will accept a verification mail from and follow a link to.

VKS_MAX_ATTEMPTS

(integer, optional) How many attempts one identity gets before pepsi-keys identity publish --retry leaves it alone. Default 5. The row keeps its last error, so an abandoned identity says why.

VKS_RETRY_INTERVAL

(duration, optional) Minimum spacing between attempts for one identity. Default 6 h. Calendar units are rejected: 6 h parses, 1 d does not.

VKS_BATCH

(integer, optional) How many identities one --retry run works on, so a first run over a large deployment is not one enormous burst at a third party. Default 50.

70.2.1.1.12. The [pepsi-ingress] Section

Global options governing message acceptance, read by pepsi-ingress(1).

HOSTNAME

(string, required) Host name announced in the SMTP greeting and the EHLO response, e.g. mail.example.org.

ACCEPTED_DOMAINS

(string, required) Whitespace- or comma-separated list of domains for which mail is accepted. Recipients in any other domain are rejected as relaying with a 550 reply. Matching is case-insensitive. At least one domain must be given.

USERNAME_MAP

(path, optional) File mapping each SASL authentication id to the e-mail addresses it may use as the envelope sender and From: identity (RFC 6409 §6/§8.1). Each non-blank, non-comment line is username: addr… — a # begins a comment, the key is the SASL username, and the value is the whitespace/comma separated list of allowed addresses (which may be empty). A username listed with addresses is allowed exactly those (the first concrete one being the canonical identity used for the Sender: fixup); an address may be a * wildcard such as *@example.org (any address at the domain) or a bare * (any address). A username listed with no addresses is denied any address (its AUTH is refused even though the credentials are valid); a username not listed falls back to the default username@HOSTNAME. The file is re-read whenever its modification time changes (checked on each authentication); a missing/unreadable file is treated as empty. When unset, every authenticated user is mapped to its default username@HOSTNAME. See pepsi-ingress(1).

POSTMASTER

(string, optional) Routable address the domainless reserved <Postmaster> mailbox is rewritten to. RFC 5321 §4.5.1 requires <Postmaster> (with no domain) to be accepted regardless of ACCEPTED_DOMAINS; on acceptance Pepsi rewrites it to this address so the normal pipeline (aliases, local delivery, relay) can route it. When unset it is rewritten to postmaster@HOSTNAME — add an alias for that address in a pepsi-stage-aliases ALIASES map to deliver postmaster mail to a real person, or set POSTMASTER to that person directly. See pepsi-ingress(1).

MAX_MESSAGE_SIZE

(number, optional) Maximum accepted message body size in bytes — the size of the DATA payload. The value is advertised through the ESMTP SIZE extension; messages exceeding it are rejected with 552. Defaults to 26214400 (25 MiB).

MAX_CONNECTIONS

(number, optional) Maximum number of SMTP connections served concurrently across all listeners. The effective admission ceiling is the smaller of this and MAX_OPEN_SOCKETS. Defaults to 1024.

DB_POOL_SIZE

(number, optional) Maximum number of PostgreSQL connections in the ingress database pool. Ingress serves many SMTP sessions concurrently, each briefly calling ingress_add, so unlike the single-connection stage workers it can use more than one. Defaults to 1; raise it only if inbound acceptance is bottlenecked on the single connection, and keep the sum of every component’s pool below PostgreSQL’s max_connections.

MAX_OPEN_SOCKETS

(number, optional) Hard ceiling on concurrently open client connections, guarding against file-descriptor exhaustion. When unset, it is derived from the process’s soft RLIMIT_NOFILE minus RESERVED_SOCKETS. When the ceiling is reached and a new connection arrives, the server makes room by closing the longest-idle connection in the source range (one IPv4 address, or an IPv6 /32) whose total idle time is largest (a 421, then close).

RESERVED_SOCKETS

(number, optional) File descriptors held back from the RLIMIT_NOFILE-derived budget (for the database pool, listeners, stdio and DNS sockets). Defaults to 64. Ignored when MAX_OPEN_SOCKETS is set.

CONN_RATE_PER_SECOND

(number, optional) Token-bucket refill rate limiting accepted new connections per source range, in connections per second. A connection over the rate is refused with 421 and closed without taking a slot. Defaults to 1. Local (UNIX-socket) connections are never rate-limited.

CONN_RATE_BURST

(number, optional) Token-bucket burst allowance per source range. Defaults to 1.

COMMAND_TIMEOUT

(duration, optional) Longest a session waits for the next command (or AUTH continuation, the start of a DATA/BDAT body, or completion of a STARTTLS/implicit-TLS handshake) before the connection is closed with 421 (RFC 5321 §4.5.3.2). Defaults to 300 s.

DATA_TIMEOUT

(duration, optional) Longest a session waits for the next block of payload octets while receiving a DATA/BDAT body before closing with 421. Defaults to 180 s.

DMARC_ENFORCE

(boolean, optional) When YES, reject at SMTP time (550) a message whose DMARC evaluation fails and whose published policy is quarantine or reject. Defaults to NO: as a forwarder, pepsi-ingress(1) normally accepts such mail (and pepsi-stage-arc(1) seals the failing result) so the downstream receiver can decide. Other authentication failures never reject a message.

DNS_SERVERS

(string, optional) Whitespace- or comma-separated list of resolver IP addresses (IPv4 and/or IPv6) used for the SPF/DKIM/DMARC lookups, queried on port 53. If unset, the system resolver configuration (/etc/resolv.conf) is used.

DNS_TIMEOUT

(duration, optional) Per-query timeout for those lookups. Defaults to 5 s. Because verification runs synchronously while receiving DATA, a slow resolver delays the 250 acceptance.

DISPATCH_WAKE_INTERVAL

(duration, optional) Minimum spacing between two NOTIFYs telling pepsi-dispatch(1) that new mail has arrived. Defaults to 5 ms; 0 disables coalescing and notifies once per accepted message.

Ingress does not notify from inside the transaction that stores the message. PostgreSQL holds a database-wide lock from the moment a transaction queues a notification until it commits, so notifying transactions cannot group-commit — under concurrent load they serialise, each paying its own disk flush. Admissions are therefore notified out of band, once per interval, after the rows have committed; because the dispatcher answers any wake-up with a full scan of the queue, one notification serves an arbitrary number of newly accepted messages.

The limit is trailing: the first wake-up after an idle period is sent immediately, so a lone message is not delayed at all, and only notifications behind it are merged. Raising this trades a little worst-case queue latency for fewer notifications under sustained load; there is rarely a reason to.

The ARC sealing identity and algorithm are configured in the shared [pepsi] section (ARC_DOMAIN and ARC_ALGORITHM) above and used by pepsi-stage-arc(1), not by ingress.

70.2.1.1.13. The [pepsi-ingress-listener-*] Sections

Each section whose name begins with pepsi-ingress-listener- defines one listening socket. The suffix is a free-form name used in log messages, e.g. [pepsi-ingress-listener-mx]. Declare as many listeners as required.

SERVE

(required) The kind of socket to bind. One of:

tcp

Bind a TCP socket. Requires BIND_TO and PORT.

unix

Bind a UNIX-domain socket. Requires UNIXPATH; honours UNIXPATH_MODE and UNIXPATH_GROUP.

systemd

Adopt a socket passed by a systemd parent through socket activation; see FD_INDEX.

BIND_TO

(string) IP address to listen on when SERVE = tcp, e.g. 0.0.0.0 or ::. Required for tcp.

PORT

(number) TCP port to listen on when SERVE = tcp. Required for tcp.

UNIXPATH

(path) Filesystem path of the socket when SERVE = unix. Required for unix.

UNIXPATH_MODE

(unix mode, optional) Permission bits of the UNIX-domain socket, in octal. Defaults to 660.

UNIXPATH_GROUP

(string, optional) Group name the UNIX-domain socket is chgrped to after binding (the owner is unchanged). Combined with the default 0660 mode this lets a specific peer reach the socket while it stays non-world-accessible — used when pepsi-httpd runs behind a front web server (e.g. www-data) that reverse-proxies to it. The group must exist; an unknown group is a startup error.

FD_INDEX

(number, optional) Zero-based index of the systemd-passed file descriptor to adopt when SERVE = systemd. Index 0 is the first descriptor (SD_LISTEN_FDS_START). Defaults to 0.

It is a position, not a name: it counts ListenStream= entries in the owning .socket unit, so inserting one renumbers every listener after it while appending one renumbers nothing. The unit and these sections are two halves of one statement and nothing checks that they agree at configuration time, so a mismatch is reported where the descriptors actually are: the server logs a warning at start-up naming every index no listener claimed. Take it seriously — a socket the server never accepts on is worse than an absent one, because systemd still queues the connection and the client blocks until its own read timeout expires rather than being refused at once.

A descriptor may be a UNIX-domain socket as readily as a TCP one; which it is lives in the unit, not here. Options that depend on the socket’s family are therefore resolved once the descriptor is in hand rather than while parsing: AUTH_PEERCRED must be stated explicitly (it cannot be inferred as it is for SERVE = unix), and a plaintext SUBMISSION listener that turns out to have inherited a network socket makes pepsi-ingress(1) refuse to start rather than carry credentials in the clear.

MODE

(optional) Transport security of the listener. One of:

plain

Cleartext only; STARTTLS is not advertised. This is the default.

tls

Implicit TLS: the connection is wrapped in TLS from the first byte (e.g. the submissions port 465). Requires TLS_CERT and TLS_KEY.

starttls

Cleartext that may be upgraded with the STARTTLS command. Requires TLS_CERT and TLS_KEY.

TLS_CERT

(path) PEM file containing the server certificate chain. Required when MODE is tls or starttls — but may be omitted, in which case pepsi-setup(1) auto-fills the certbot path /etc/letsencrypt/live/<HOSTNAME>/fullchain.pem and can obtain the certificate (see pepsi-setup(1) and its –no-certbot option).

TLS_KEY

(path) PEM file containing the private key for TLS_CERT. Required when MODE is tls or starttls; like TLS_CERT it may be omitted for pepsi-setup(1) to auto-fill (…/privkey.pem).

MYNETWORKS

(optional) Whitespace/comma separated list of trusted IPv4/IPv6 CIDR networks (a bare address is a /32 or /128 host). A connection from any of these is authenticated without further proof. Empty by default.

AUTH_PEERCRED

(optional, default ``yes`` on a ``SERVE = unix`` listener, ``no`` elsewhere) Authenticate the client by its kernel-reported UNIX-socket peer credentials (SO_PEERCRED): the connecting process’s user id is resolved to a login through the passwd database, and that login is looked up in USERNAME_MAP exactly as a SASL username would be. The session is then authenticated and carries a submission identity, so the RFC 6409 §6/§8.1 rules apply — a local program can only use an envelope sender and From: its own account is mapped to, and an account mapped to no address is refused.

This is the mechanism behind pepsi-sendmail(1) and the reason the socket may safely be world-writable: being able to open it decides nothing about who you can send as. Unlike MYNETWORKS, which trusts a network position and therefore lets any local process send as anyone, the identity here is supplied by the kernel and cannot be forged by the client.

Rejected on a SERVE = tcp listener, where there are no peer credentials to read. Set it to no for an unauthenticated local socket (for instance behind a proxy) — but such a listener cannot then also be a SUBMISSION listener, since nothing would authenticate.

With SERVE = systemd it is accepted but not inferred, and so must be written out: whether the inherited descriptor is a UNIX socket or a TCP one is a property of the .socket unit rather than of this file, and the shipped pepsi-ingress.socket passes both kinds. If the descriptor turns out to be a TCP socket the option authenticates nobody — SO_PEERCRED has no meaning there, so every session stays anonymous — which is the safe direction but a silent one, so pepsi-ingress(1) warns at start-up naming the listener.

SASL_TYPE

(optional) SASL backend for the AUTH command: none (default) or dovecot. AUTH (mechanisms PLAIN and LOGIN) is advertised and accepted only on a TLS-protected session; a cleartext attempt is refused with 538. Any value other than none requires MODE tls or starttls.

SASL_PATH

(path) Path of the Dovecot auth-client socket. Required when SASL_TYPE is dovecot. The default (/run/dovecot/auth-client-pepsi) is a Pepsi-private listener rather than Dovecot’s shared auth-client socket: the latter is 0600 dovecot and unreachable by the unprivileged pepsi-ingress user. pepsi-setup run probes this socket as the service user and, if it is missing or unreadable, offers to install a /etc/dovecot/conf.d/10-pepsi.conf that adds a dedicated listener owned by pepsi-ingress (or prints it for you to deploy).

TLS_AUTH_CLIENT

(optional) Whitespace/comma separated list of SHA-256 hashes (64 hex characters) of the DER SubjectPublicKeyInfo of authorised client or CA public keys. When set, the listener requests a client certificate: a session is authenticated if the presented leaf’s key hash is listed, or if its chain signature-validates up to a listed CA key. An unrecognised certificate does not abort the handshake. Requires MODE tls or starttls. Compute a hash with openssl x509 -in cert.pem -noout -pubkey | openssl pkey -pubin -outform DER | openssl dgst -sha256.

SUBMISSION

(optional, default ``no``) When yes, the listener is an RFC 6409 message submission agent: authentication becomes mandatory (an unauthenticated MAIL FROM is refused with 530 5.7.0), and every accepted message gets the submission fixups — a missing Date: and Message-ID: are added. Requires at least one authentication mechanism (SASL_TYPE, TLS_AUTH_CLIENT, MYNETWORKS or AUTH_PEERCRED); a submission listener with none is rejected. Leave it off for an MX listener (port 25).

A TLS MODE is required too, except on a SERVE = unix listener: a filesystem socket has no transport an attacker could observe or interpose on, and its authentication comes from the kernel rather than from the wire, so requiring a certificate there would protect nothing.

A session accepted by any of MYNETWORKS, AUTH_PEERCRED, SASL AUTH or TLS_AUTH_CLIENT is recorded with state.local_origin = true (otherwise false) and may relay to any domain, not only the served domains.

70.2.1.1.14. The Stage Pipeline and [stage-*] Sections

After ingress, a message is advanced by a chain of stage programs. pepsi-dispatch(1) claims a pending row, sets it running and runs the program named by its stage’s section with the message id as the only argument; the program does its work and advances the row (or finishes it). New messages enter at the initial stage, [stage-init], which must exist.

Each section whose name begins with stage- describes one stage. The suffix is the stage name, e.g. [stage-init]. The stage name is an operator-chosen label, independent of the binary it runs.

PROGRAM

(string, required) The stage binary to run, e.g. pepsi-stage-relay-to-smarthost. It is located on the PATH unless given as an absolute path. The same binary may serve several stages (it reads its options from whichever section the message is currently at).

NEXT_STAGE

(string, optional) The stage a message advances to on success. With none, a successful terminal stage removes the message.

BOUNCE_STAGE

(string, optional) The stage a message is routed to so a delivery-status notification is generated. For delivery stages (pepsi-stage-relay-to-smarthost(1), pepsi-stage-relay-to-internet(1)) and pepsi-stage-discard(1) it receives a permanently-failed (non-bounce) message for a bounce, and — when [pepsi] ORIGINATE_SUCCESS_DSN is enabled — a successfully-delivered message for a positive report. With none, a permanently-failed message is marked failed (delivery stages) or simply dropped (pepsi-stage-discard(1)).

PARALLELISM

(number, optional) Upper bound on the number of concurrent worker processes pepsi-dispatch(1) runs for this stage. Workers are started on demand and stopped when idle, so this is a ceiling, not a fixed pool size. Each worker holds one database connection, so this also bounds the stage’s share of the connection budget (see the [pepsi-postgres] note above). Default 4. The dispatcher temporarily lowers a stage below this value when the database reports its connection limit is exhausted (see pepsi-dispatch(1)).

MAX_MESSAGES

(number, optional) Number of messages a worker of this stage handles before the dispatcher retires it and starts a fresh process (bounding memory growth). Default 1000.

QUEUE_LIMIT

(number, optional) Upper bound on the number of messages pepsi-dispatch(1) pipelines to a single worker process of this stage at once. The worker still processes them strictly one at a time, but having the next ids already queued on its standard input removes a coordinator round-trip per message, so the stage’s total in-flight capacity is QUEUE_LIMIT × PARALLELISM. Because the worker count (and thus the connection budget) is unchanged, raising this trades a little head-of-line latency (a message can wait behind a slower pipelined sibling on the same worker) for throughput, without spending more database connections; lower it (e.g. to 1) for a stage whose per-message work is long and uneven, such as a network relay. Default 4.

FUSION = yes | no

(optional) Whether a predecessor stage may run this stage in its own worker process instead of writing the row pending and waiting for the dispatcher to claim it (stage fusion; see [pepsi] ALLOW_FUSION above and pepsi-dispatch(1)). Fusion happens only when it is also globally enabled, this stage’s PROGRAM is folded into the unified pepsi binary, and this stage needs no message column the predecessor did not already load — so it is refused (and the advance falls back to a normal dispatched hop) whenever any of those does not hold, which is always safe. Defaults to yes for the fast, body-free routing/classification stages — pepsi-stage-if(1), pepsi-stage-discard(1), pepsi-stage-srs(1), pepsi-stage-check-whitelist(1), pepsi-stage-auto-whitelist(1) and pepsi-stage-block-language(1) — and no for every other program. Of those, pepsi-stage-block-language(1) is the one that needs the header block (its ENFORCEMENT = soft path rewrites the Subject:), so it fuses only into a predecessor that had already loaded it.

The remaining options in a [stage-<name>] section depend on its PROGRAM. The subsections below document the options each stage program reads from its own section; a section reads only the keys listed for its PROGRAM plus the general keys above. The shared sections these programs also draw on are documented elsewhere on this page: [pepsi] (signing identity), [pepsi-srs] (the SRS engine), [pepsi-payments] (the merchant backend) and the smarthost [pepsi-stage-relay-to-smarthost-mta-*] sections.

70.2.1.1.14.1. pepsi-stage-arc Options

A stage with PROGRAM = pepsi-stage-arc reads, besides a required NEXT_STAGE, its DNS settings and the signature parameters of the ARC set it adds (these apply to the ARC-Message-Signature; the ARC-Seal is always relaxed-canonicalized over a fixed header set, per RFC 8617):

DNS_SERVERS

(string, optional) Whitespace/comma-separated explicit resolvers (port 53) for the SPF/DKIM/DMARC/ARC lookups. When unset, the system resolv.conf is used.

DNS_TIMEOUT

(duration, optional) Per-query DNS timeout. Default 5 s.

HEADER_CANONICALIZATION

(``relaxed`` | ``simple``, optional) Header canonicalization of the AMS (the left half of c=). Default relaxed.

BODY_CANONICALIZATION

(``relaxed`` | ``simple``, optional) Body canonicalization of the AMS (the right half of c=). Default relaxed. ARC supports any combination of the two.

COVER_BODY

(boolean, optional) When yes (default) the AMS strictly covers the whole body. When no it carries a body-length (l=) tag fixed to the body as signed, so a later hop may append a footer without invalidating the AMS.

SIGNATURE_EXPIRATION_DAYS

(integer, optional) When set to a positive number of days, the AMS and ARC-Seal carry an expiration (x=) that many days after signing. Unset (the default) emits no expiration.

SIGNED_HEADERS

(string, optional) Whitespace/comma-separated list of headers the AMS covers (h=). Must include From. Defaults to the built-in originator/MIME header set.

The ARC signing identity, key directory, selector and algorithm come from the shared [pepsi] section (ARC_DOMAIN, ARC_ALGORITHM, KEY_DIR, DKIM_SELECTOR). The hash is always SHA-256; the signature algorithm (a=) is the single [pepsi] ARC_ALGORITHM choice, since ARC permits one signature per hop.

70.2.1.1.14.2. pepsi-stage-srs Options

A stage with PROGRAM = pepsi-stage-srs needs only a NEXT_STAGE in its own [stage-<name>] section; all its parameters (SRS_DOMAIN, SECRET/SECRET_FILE, MAX_AGE_DAYS) live in the shared [pepsi-srs] section documented above, so they are identical to ingress’s reverse-decode.

70.2.1.1.14.3. pepsi-stage-encrypt Options

A stage with PROGRAM = pepsi-stage-encrypt signs locally submitted mail as its From: author and encrypts it to each recipient. It reads, besides a required NEXT_STAGE and an optional BOUNCE_STAGE:

Warning

Place this stage before the DKIM-signing stage, not after: encrypt srs dkim-sign relay. DKIM must sign the bytes that are actually transmitted, and this stage rewrites the body. Signing first yields mail that looks valid and whose signature does not verify — worse than no signature, because a broken one is a stronger negative signal to a receiver than an absent one. pepsi-setup(1) warns when it finds a DKIM-signing stage that leads to this one.

The pay-to-send gate and language detection both read the message body and both want cleartext; on the outbound path they run before this stage, which is why encryption is last.

Every option below is behavioural and is per-address overridable through the pepsi.settings table (pepsi-settings(1)), keyed for outbound mail on the envelope sender. The cryptographic policy — algorithms, downgrade permission, minimum key sizes — is in [pepsi]’s CRYPTO_* options and [pepsi-crypto], is system-administrator-only, and is deliberately not reachable from here.

SIGN

(``no`` | ``opportunistic`` | ``always``, optional) Whether to sign with the From: author’s key. Default opportunistic: sign when the author has usable material, otherwise send unsigned. always additionally asks for an identity to be created when [pepsi-crypto] AUTO_CREATE_IDENTITY permits it. Neither is a guarantee — an author this deployment does not serve is sent unsigned rather than bounced.

ENCRYPT

(``no`` | ``opportunistic`` | ``required``, optional) Default opportunistic: encrypt to a recipient we have a usable key for, send ordinary mail to one we do not. required means a recipient with no usable key gets the secure link or a bounce, never cleartext. optional is accepted as a synonym for opportunistic.

PREFER

(``openpgp`` | ``smime``, optional) Which protocol to reach for when a correspondent has usable material for both. Default openpgp; pgp is accepted as a synonym for it.

ON_NO_KEY

(``cleartext`` | ``secure-link`` | ``bounce``, optional) What happens to a recipient with no usable key. Defaults from ENCRYPT: cleartext for no/opportunistic, and for required either secure-link (when SECURE_LINK_STAGE is set) or bounce. Setting it explicitly overrides that — except that a message whose own request raised the policy to required never falls back to cleartext.

Caution

ON_NO_KEY = cleartext together with [pepsi] CRYPTO_ALLOW_DOWNGRADE = no sends a correspondent whose key cannot read AES-GCM plaintext — strictly worse than the SEIPDv1+MDC the option was set to avoid, and most of the OpenPGP installed base (GnuPG 2.4 and older) is in that position. pepsi-setup(1) warns about the pair. Leave the option alone: both negotiate (compiled in) and yes (what config.d ships) downgrade rather than refuse, and under negotiate only when the recipient’s own key proves they must.

ON_OVERSIZE

(``cleartext`` | ``secure-link`` | ``bounce``, optional) What happens to a message larger than MAX_SIZE. Defaults exactly like ON_NO_KEY, so required does not become cleartext because an attachment was large.

MAX_SIZE

(number, optional) Largest message, in bytes, this stage will encrypt. Default 25000000 (25 MB). Neither the CMS builder nor the OpenPGP paths stream, so the whole message is resident while it is encrypted, once per recipient; a visible cap is what keeps the per-worker memory ceiling predictable instead of letting PARALLELISM workers each hold a multi-hundred-megabyte message.

MIN_TRUST

(``manual`` | ``dane`` | ``wkd-advanced`` | ``wkd-direct`` | ``ldap`` | ``vks`` | ``harvested`` | ``gossip``, optional) The lowest-ranked key source this stage will encrypt to. Defaults to [pepsi-keydiscovery] MIN_TRUST, so a deployment has one floor unless it deliberately wants two. See The [pepsi-keydiscovery] Section for the ranking, for why the default floor accepts anything, and for the difference between harvested and gossip.

SUBJECT_KEYWORDS_SIGN, SUBJECT_KEYWORDS_ENCRYPT, SUBJECT_KEYWORDS_BOTH

(comma-separated list, optional) Keywords a sender may put anywhere in the Subject to ask for protection. Defaults [sign], [encrypt] and [secure]. Matched case-insensitively; the matched keyword is removed from the outgoing Subject. An encryption keyword raises the policy to required for that message — reading a deliberate human request as “try, and quietly give up” would make the feature a lie. Commas, not spaces, separate the list, because a keyword may legitimately contain a space.

STRIP_REQUEST_HEADERS

(boolean, optional) Remove every X-Pepsi-* field from the outgoing message. Default YES. The two headers this stage acts on (X-Pepsi-Sign, X-Pepsi-Encrypt) are removed unconditionally, whatever this says: a control channel that survives onto the wire is one the next hop can be told to obey. This option decides only the fate of the other X-Pepsi-* fields an add-in may have added. Pepsi-Origin is not in that namespace and is never touched.

PROTECT_HEADERS

(boolean, optional) Copy the RFC 5322 header fields into the protected part and replace the outer Subject with ... — what Thunderbird implements for PGP/MIME. Default NO: client support is uneven, and the failure mode is a message whose subject reads ... in a client that cannot look inside.

AUTOCRYPT

(boolean, optional) Advertise the From: author’s own OpenPGP key in an Autocrypt: header. Default YES, and on every locally submitted message — signed, encrypted or plain cleartext — because a correspondent has to learn the key from ordinary mail before there is anything to encrypt with.

The key is reduced to the minimal five-packet form of Autocrypt Level 1 §2.1.1. Only OpenPGP is advertised (Autocrypt has no S/MIME form), only for an identity that still holds its private material, and never on a message that already carries an Autocrypt: field — a real client’s own header names the key it can decrypt with, and a second field makes Level 1 parsers discard both. prefer-encrypt=mutual is claimed only when the effective ENCRYPT is required; see pepsi-stage-encrypt(1).

Turn it off to keep key publication to the Web Key Directory and DNS, which is a defensible choice for a deployment that does not want its users’ keys travelling on every message they send.

AUTOCRYPT_GOSSIP

(boolean, optional) Advertise the other recipients’ OpenPGP keys in Autocrypt-Gossip: fields (Autocrypt Level 1 §5.3), so that two correspondents who have never written to each other can nevertheless answer each other encrypted after one message. Default NO.

Off by default because it is not the same kind of act as AUTOCRYPT: that one publishes a key its owner asked this deployment to hold, while this one redistributes third parties’ keys — material merely cached here — to correspondents who did not ask for them.

It applies only to a message this stage actually encrypts, and the fields go inside the ciphertext, never on the outer header block: their whole purpose is to introduce the recipients to each other without disclosing the recipient set to the network. There is therefore no gossip on the cleartext pass-through, on a signed-but-unencrypted message, or on an S/MIME container (Autocrypt is defined over OpenPGP and has no S/MIME form). One field is emitted per introduced recipient — unlike Autocrypt:, where a second field makes a Level 1 parser discard the message — and prefer-encrypt never appears, since it states the sender’s own policy and this field speaks for somebody else.

Warning

Blind carbon copies are never gossiped. The addresses advertised are read from the message’s parsed To: and Cc: fields and from nowhere else; the envelope recipient list is consulted only to remove an address from the set. A Bcc recipient is on the envelope and in neither field, so no copy of the message can carry a header naming them — which is the property to rely on, because a gossip header for a blind recipient would tell every other recipient that a hidden one exists and who it is. This holds even if a Bcc: field was left on the message by a broken submitting client, because the field is not read.

The converse is deliberate: a blind recipient’s own copy does carry gossip about the To:/Cc: recipients. They received those header fields like everyone else, so it discloses nothing they cannot already read, and it is what lets them reply encrypted.

A recipient this deployment holds no cached key for is simply left out — the stage performs no key discovery on their behalf and never delays a message to fetch a third party’s key. A message with more than 50 visible recipients gets no gossip at all: at that size it is a broadcast rather than a conversation, each introduced key is repeated in every recipient’s own ciphertext, and introducing an arbitrary subset would be worse than introducing none.

SECURE_LINK_STAGE

(stage name, optional) Where a recipient taking the secure-link route is sent. Required if ON_NO_KEY/ON_OVERSIZE resolves to secure-link.

DISCOVERY

(boolean, optional) Whether a cache miss may trigger a network lookup; documented under The per-sender switch: [stage-encrypt] DISCOVERY.

DISCOVERY_TIMEOUT

(temporal, optional) Override [pepsi-keydiscovery] TIMEOUT for the parks this stage creates. Note that Section::duration() rejects calendar units: 90 s and 2 h parse, 1 d does not.

LOCAL_DOMAINS, TARGETS, RECIPIENT_DELIMITER

(optional) The same locality options, with the same defaults, as [pepsi-crypto] and pepsi-stage-decrypt Options — read here only to attribute a lazily created author identity to a passwd login, so that the ownership rule of The [pepsi-crypto] Section has something to key on. An address that resolves to no local account is a legitimate state (a role address or a hosted domain), not a failure. Whether an identity may be created at all is [pepsi-crypto] AUTO_CREATE_IDENTITY, and it is offered only for an author in a [pepsi-ingress] ACCEPTED_DOMAINS domain.

The binary is installed setuid pepsi-crypto, mode 4750 pepsi-crypto:pepsi, because it must be the one database role granted crypto_identity.private_wrapped — peer authentication keys off the effective uid — and must read the 0640 key-encryption-key fragment owned by the same account. See pepsi-stage-encrypt(1).

70.2.1.1.14.4. pepsi-stage-decrypt Options

A stage with PROGRAM = pepsi-stage-decrypt decrypts mail addressed to recipients this host serves and verifies whatever signature it carries. It reads, besides a required NEXT_STAGE and an optional BOUNCE_STAGE:

ENABLED

(boolean, optional) Whether to do any cryptography at all. Default YES. NO still removes the X-Pepsi-* header namespace from inbound mail: a disabled stage must not become a channel for a forged indicator.

LOCAL_DOMAINS, TARGETS, RECIPIENT_DELIMITER

(optional) Which recipients this host serves — the same options, with the same defaults, as pepsi-stage-relay-to-maildir(1) and pepsi-stage-dot-forward(1). LOCAL_DOMAINS defaults to [pepsi-ingress] ACCEPTED_DOMAINS.

REQUIRE_ACCOUNT

(boolean, optional) Whether a local recipient must additionally resolve to a passwd account permitted by TARGETS. Default NO, which is the difference from the delivery stages: they must know whose mailbox to write, while this stage only has to know whether the message is ours. A hosted domain whose users hold crypto identities but no shell accounts is an ordinary deployment.

ON_DECRYPT_FAILURE

(``passthrough`` | ``quarantine`` | ``bounce``, optional) What to do with a message this host could not open. Default passthrough: the user may hold the key in their own client, and bouncing mail we merely could not open helps nobody. quarantine needs QUARANTINE_STAGE; bounce needs BOUNCE_STAGE.

ON_BAD_SIGNATURE

(``record`` | ``quarantine`` | ``bounce``, optional) What to do with a message whose signature did not verify. Default record: mailing lists that rewrite bodies, forwarders and clients with broken canonicalisation all produce bad signatures on entirely legitimate mail. Only the invalid verdict counts as bad here — valid-untrusted and unverifiable are the normal state of mail from an unknown correspondent and are never routed.

QUARANTINE_STAGE

(stage name, optional) Where a quarantine route leads. Required if either policy above is set to quarantine.

SUBJECT_TAGS

(boolean, optional) Prepend the earned tags to the Subject in nesting order — encrypted(signed(body)) gives [decrypted][verified] and signed(encrypted(body)) would give [verified][decrypted] . Default YES: for most users this is the only signal they will ever see. The cost is a mutated user-visible field, which affects search, threading and quoted subjects in replies. (Both shapes open. The second earns [verified] only from a signature inside the ciphertext: an outer signature covers ciphertext and never reaches the valid verdict the tag is written for — see pepsi-stage-decrypt(1).)

TAG_DECRYPTED, TAG_VERIFIED

(string, optional) The tag wording. Defaults [decrypted] and [verified]. TAG_VERIFIED is written for the valid verdict only.

TAG_BAD_SIGNATURE

(string, optional) The tag for the invalid verdict. Empty by default: a bad signature is a routine property of legitimate mail, so a default-on tag would decorate a large part of a user’s inbox with an accusation.

STRIP_SUBJECT_TAGS

(boolean, optional) Remove this stage’s own tag vocabulary from the front of an inbound Subject before prepending ours. Default YES, and effectively mandatory: without it an external sender simply writes [verified] themselves. The vocabulary is derived from the three TAG_* options plus the built-in [decrypted] and [verified], so renaming a tag cannot reopen the hole.

STRIP_SUBJECT_KEYWORDS

(comma-separated list, optional) Extra inbound subject tags to remove. Commas rather than whitespace, because a keyword may contain a space.

ADD_RESULT_HEADER

(boolean, optional) Add the X-Pepsi-Crypto result header to a message that carried protection. Default YES. Its grammar is documented in pepsi-stage-decrypt(1). Every X-Pepsi-* field is removed from inbound mail first, unconditionally and whatever this option says — that removal is what makes the header believable.

Note the asymmetry with the sending half ([stage-encrypt] AUTOCRYPT_GOSSIP, default NO): emitting gossip redistributes third parties’ keys to correspondents who did not ask for them, while reading it only fills this host’s own cache. An operator who wants the introductions stored but never used sets [pepsi-keydiscovery] MIN_TRUST = harvested instead of turning this off.

MAX_LAYERS

(integer, optional) How many nested encrypted layers are unwrapped. Default 4. A message with more is delivered with the remainder unopened.

TRUST_ANCHOR_TTL

(temporal, optional) How long a worker process reuses the ca_trust anchors it last read. Default 5 m. An anchor added or disabled becomes effective within this window; restart the workers to apply it at once. Calendar units are rejected (5 m and 2 h parse, 1 d does not).

DISCOVERY

(boolean, optional) Whether a cache miss may trigger a network lookup; documented under The per-sender switch: [stage-encrypt] DISCOVERY. Read from this section by the same reader, so the two crypto stages cannot disagree about its name or its default.

DISCOVERY_TIMEOUT

(temporal, optional) Override [pepsi-keydiscovery] INBOUND_TIMEOUT for the parks this stage creates.

[pepsi] CRYPTO_ALLOW_DOWNGRADE does not gate this direction. OpenPGP SEIPDv1+MDC and CMS AES-CBC EnvelopedData are always readable, whatever the emission policy says: refusing them inbound would make this host unable to receive mail from most of the world while protecting nobody. Only 3DES and the weak signature digests are gated inbound, and each refusal produces its own verdict.

The binary is installed setuid pepsi-crypto, mode 4750 pepsi-crypto:pepsi, for exactly the reasons given for pepsi-stage-encrypt(1) above. See pepsi-stage-decrypt(1). The [pepsi-secure-link] Section ——————————-

The secure-link fallback portal: where a message that could not be encrypted is held, and how the recipient is let in. Read by three programs — the stage that stores a message (pepsi-stage-secure-link), the server that serves it (pepsi-httpd(1)) and the operator CLI (pepsi-secure-link(1)) — which is why it is one global section rather than options copied into a [stage-*] one.

That placement is also a decision about who may change these values. This is not a [stage-<name>] section, so it is out of reach of pepsi.settings and pepsi-stage-edit-settings(1): a correspondent cannot mail themselves a longer expiry, a shorter PIN or a disabled lockout. The genuinely behavioural knob — whether an unencryptable message goes to the portal at all — is [stage-encrypt] ON_NO_KEY, which is per-address overridable.

BASE_URL

(string) Public origin the portal is reachable at, e.g. https://secure.example.org. Setting it (together with PEPPER) is what enables the feature; the link mailed to a recipient is <BASE_URL>/secure/<token>. Nothing here says where the portal is mounted — its own listener and hostname, or one shared with the API and the Web Key Directory, are both supported.

PEPPER

(string) The server-side secret mixed into every content-key derivation. Written by pepsi-setup(1) to secrets.d/pepsi-secure-link.secret and referenced with @inline-secret@; it is the one managed fragment with two readers, so it is installed mode 0640 owned pepsi-httpd:pepsi. Without it the portal is off — there is no mode in which a message is stored in the clear. Losing or changing it makes every stored message unreadable, which is the same property that makes a stolen database worthless.

EXPIRY_DAYS

(integer, optional) How long a stored message lives. Default 7. An integer count of days rather than a duration, because duration values reject calendar units.

PIN_LENGTH

(integer, optional) Characters in a generated PIN, from a 31-symbol unambiguous alphabet (no 0/O, no 1/I/L). Default 10, minimum 8. Six digits is refused: the PIN is the only secret an attacker holding both the database and the pepper still has to find.

MAX_ATTEMPTS, LOCKOUT

(integer / temporal, optional) Wrong PINs before the token locks (default 10) and the first lockout window (default 15 m). The window doubles with each further lockout, capped at 64 times the base.

SESSION_LIFETIME

(temporal, optional) How long the portal session lasts after a correct PIN. Default 30 m.

RATE_LIMIT

(integer, optional) Requests per minute per peer address across every portal route. Default 30; 0 disables the limiter.

NOTIFY_STAGE

(string) Required. Where the link mail, the PIN mail and read receipts are injected — a stage on the outbound path, since these are messages the deployment originates.

REPLY_STAGE

(string, optional) Where a reply composed in the portal is injected. Unset disables replying. Point it at the inbound path: a portal reply is written by an external party, so it is injected without state.local_origin and must not be DKIM-signed as one of your domains (pepsi-setup(1) warns if it resolves to a signing stage).

SEND_RECEIPT

(boolean, optional) Tell the sender, once, that the message was read. Default yes. The receipt reports who and when, never what — there is nothing for it to quote.

PIN_DELIVERY

(``sender`` | ``command`` | ``none``, optional) How the second factor reaches the recipient. Default sender: the PIN is mailed to the sender, who relays it by telephone or text message. command runs PIN_COMMAND. none puts the PIN in the link itself and is strictly weaker — the mailbox becomes the only factor.

PIN_COMMAND

(path, optional) Required by PIN_DELIVERY = command. Run with the recipient address as its single argument and the PIN on standard input (a command line is world-readable through /proc). A non-zero exit fails the message rather than leaving a link nobody can open.

MAX_REPLY_SIZE, MAX_REPLY_FILES, MAX_REPLIES

(integer, optional) Caps on a portal reply: total octets (default 25000000, matching [stage-encrypt] MAX_SIZE), files per reply (default 10) and replies per stored message (default 20). The size cap is enforced while the body streams, so a client that misstates Content-Length gains nothing.

ACCESS_LOG_ROWS

(integer, optional) Access-log rows kept per message. Default 100. The log is deleted with the message.

ORGANIZATION, BRAND_COLOR, LOGO_FILE

(string / string / path, optional) Appearance. BRAND_COLOR must be #rgb or #rrggbb — it is interpolated into the page’s stylesheet, so arbitrary text is refused rather than escaped. LOGO_FILE is a local PNG, JPEG, GIF or WebP inlined as a data: URI (SVG is refused: it can carry script). A logo URL is deliberately not an option — the portal’s Content-Security-Policy forbids remote content, which is also what stops a CDN learning who read what.

70.2.1.1.14.6. pepsi-stage-dkim-sign Options

A stage with PROGRAM = pepsi-stage-dkim-sign reads, besides a required NEXT_STAGE:

COVER_BODY

(boolean, optional) Whether the signature covers the whole body strictly. yes (default) emits no l= tag; no adds the body-length tag so trailing additions are tolerated.

HEADER_CANONICALIZATION, BODY_CANONICALIZATION

(``relaxed`` | ``simple``, optional) The two halves of the c= canonicalization. Both default to relaxed and may be set independently; all four combinations are valid for both DKIM signing and ARC sealing.

SIGNATURE_EXPIRATION_DAYS

(integer, optional) When set to a positive number of days, the signatures carry an expiration (x=) that many days after signing. Unset (the default) emits no expiration.

SIGNED_HEADERS

(string, optional) Whitespace/comma-separated list of headers the signatures cover (h=). Must include From. Defaults to the built-in originator/MIME header set.

SIGNING_DOMAIN

(string, optional) Force the signing domain (d=). When unset, the domain is taken from each message’s From: header. A fixed SIGNING_DOMAIN is a sending identity, so pepsi-setup(1) provisions its keys and DNS.

The DKIM key material and selectors come from the shared [pepsi] section (KEY_DIR, DKIM_SELECTOR). The hash is always SHA-256; both an RSA (rsa-sha256) and an Ed25519 (ed25519-sha256) signature are emitted.

70.2.1.1.14.7. pepsi-stage-relay-to-internet Options

A stage with PROGRAM = pepsi-stage-relay-to-internet reads (a NEXT_STAGE and BOUNCE_STAGE are the general keys above):

SERVER_NAME

(string, required) Our own host name, announced in EHLO and written into the Received: trace header, e.g. mail.example.org.

POSTMASTER

(string, optional) Address that receives a double bounce — a bounce (null sender) that is itself undeliverable. With none, such double bounces are discarded.

CONNECT_TIMEOUT / COMMAND_TIMEOUT / DATA_TIMEOUT

(duration, optional) Per-connection timeouts for, respectively, establishing the TCP connection, an SMTP command/response, and the DATA transfer. Defaults 300 s / 300 s / 600 s.

RETRY_INITIAL / RETRY_MAX_INTERVAL / RETRY_FACTOR

(duration / duration / number, optional) Exponential-backoff schedule between delivery attempts of a transiently-failing message: the first wait, the cap on the wait, and the multiplier applied each attempt. Defaults 300 s / 2 h / 2.

MAX_LIFETIME

(duration, optional) How long a message may stay in the pipeline before a transient failure becomes permanent (it is then bounced or failed). Default 5 d of wall-clock, but the value must be written with h/m/s units — the duration parser rejects the d calendar unit (so write 120 h, not 5 d).

DELAY_DSN_AFTER

(duration, optional) When set, a one-shot Action: delayed DSN is sent to the sender once a still-undelivered message has been queued at least this long and the sender requested NOTIFY=DELAY. Must use h/m/s units. Unset (the default) disables delay warnings.

MAX_HOP_COUNT

(number, optional) Maximum number of Received: headers tolerated before the message is treated as a mail loop and permanently failed. Default 30.

DNS_SERVERS

(string, optional) Whitespace/comma-separated explicit resolvers (port 53) for the MX and address lookups. When unset, the system resolv.conf is used.

DNS_TIMEOUT

(duration, optional) Per-query DNS timeout. Default 5 s.

MTA_STS

(boolean, optional) Whether to discover and enforce recipient-domain MTA-STS policies (RFC 8461). Default yes.

MTA_STS_TIMEOUT

(duration, optional) Timeout for fetching an MTA-STS policy file over HTTPS. Default 10 s.

ADDRESS_FAMILY

(optional) Which IP address families to use when connecting to mail exchangers: any (default), ipv4 (also spelled v4/v4only) or ipv6 (v6/v6only). The configured value is intersected with what the local host can actually route, so pinning a family the host has no route for leaves nothing to connect to. pepsi-stage-relay-to-smarthost(1) has the same option, additionally settable per MTA entry.

DANE

(optional) DANE/TLSA (RFC 7672) enforcement for the destination MX: off (no TLSA lookups), warn (the default; validate but, on a mismatch, log and deliver anyway) or strict (a usable-but-mismatching record, or a failed TLSA lookup, defers delivery). Requires a DNSSEC-validating resolver (Pepsi trusts the response AD bit; see DNS_SERVERS); usable TLSA records take precedence over MTA-STS.

70.2.1.1.14.8. pepsi-stage-relay-to-smarthost Options

A stage with PROGRAM = pepsi-stage-relay-to-smarthost reads the operational options below; the upstream smarthosts themselves are defined once in the shared [pepsi-stage-relay-to-smarthost-mta-*] sections (see The Smarthost (MTA) Sections below). A NEXT_STAGE and BOUNCE_STAGE are the general keys above.

SERVER_NAME

(string, required) Our own host name, announced in EHLO (unless an MTA overrides it with HELO_NAME) and written into the Received: header.

CONNECT_TIMEOUT / COMMAND_TIMEOUT / DATA_TIMEOUT

(duration, optional) Per-connection timeouts for the TCP connect, an SMTP command/response and the DATA transfer. Defaults 300 s / 300 s / 600 s.

RETRY_INITIAL / RETRY_MAX_INTERVAL / RETRY_FACTOR

(duration / duration / number, optional) Exponential-backoff schedule between relay attempts. Defaults 300 s / 2 h / 2.

MAX_LIFETIME

(duration, optional) How long a message may stay in the pipeline before a transient failure becomes permanent. Default 5 d of wall-clock, but write it with h/m/s units — the parser rejects the d calendar unit.

DELAY_DSN_AFTER

(duration, optional) When set, a one-shot Action: delayed DSN is sent once a still-undelivered message has been queued at least this long and the sender requested NOTIFY=DELAY. Must use h/m/s units. Unset disables it.

MAX_HOP_COUNT

(number, optional) Maximum number of Received: headers before the message is treated as a loop and permanently failed. Default 30.

DNS_SERVERS

(string, optional) Whitespace/comma-separated explicit resolvers (port 53) for the DANE TLSA lookups. When unset, the system resolv.conf is used. Only consulted when a smarthost enables DANE; the resolver must validate DNSSEC.

DNS_TIMEOUT

(duration, optional) Per-query timeout for the DANE TLSA lookups. Default 5 s.

70.2.1.1.14.9. Bounce Stage Options

A [stage-<name>] section with PROGRAM = pepsi-stage-bounce additionally reads, from its own section:

SERVER_NAME

(string, required) Host name used in the generated bounce’s From: and Reporting-MTA fields and in its Message-ID domain, e.g. mail.example.org.

POSTMASTER

(string, optional) From: address of generated bounces (Mail Delivery Subsystem <POSTMASTER>) and the DKIM signing identity — the bounce is signed as this address’s domain, whose keys pepsi-setup(1) provisions. Defaults to postmaster@<SERVER_NAME>.

NEXT_STAGE

(string, required for this stage) The stage the rewritten bounce is advanced to. Because the bounce is left unsigned, this is normally a pepsi-stage-dkim-sign(1) stage (which signs it as the postmaster’s domain), which in turn points at a delivery stage such as pepsi-stage-relay-to-internet(1).

BOUNCE_MESSAGE

(string, optional) Name of the template that renders the human-readable (text/plain) part of the bounce. The template file is bounce-<NAME>.<lang>.body under the [pepsi] TEMPLATE_DIR; it is a Mustache template selected by language (only English ships, and en is the fallback). When unset — or when rendering fails — a built-in English notice is used instead, so a bounce is always produced. The bundled bounce-default template is installed by make install.

The whole message state is passed to the template as the rendering context, augmented with the convenience variables server_name, postmaster, bounce_to, failed_recipient, diagnostic, action and the booleans failed / delayed / delivered. In particular the delivery stage records structured next-hop detail under state.bounceremote_mta, smtp_code, enhanced_status, phase and reply_text — so the template can state precisely why the next MTA refused the message. When a state.bounce.enhanced_status (RFC 3463) is present it is also used for the DSN’s machine-readable Status: field.

The failed recipient and diagnostic quoted in the bounce are taken from the state left on the row by the delivery stage that routed the message here (see BOUNCE_STAGE above); when absent (e.g. a hand-staged message) a generic notice is produced.

70.2.1.1.14.10. pepsi-stage-discard Options

A stage with PROGRAM = pepsi-stage-discard deletes the message (a staging / test sink). It ignores NEXT_STAGE — a discard is always terminal — and reads:

DISPOSITION

(optional) The simulated outcome: success (default) treats the message as delivered; failure treats it as a permanent delivery failure.

BOUNCE

(boolean, optional) Whether the discard may emit a DSN at all. Default no.

BOUNCE_STAGE

(string, optional) The DSN-generation stage a reportable discard is routed to (normally a pepsi-stage-bounce(1) stage). Without one, nothing is emitted and the row is just deleted.

The success path also consults the shared [pepsi] ORIGINATE_SUCCESS_DSN flag; a null-sender message is never bounced.

70.2.1.1.14.11. pepsi-stage-anti-spam Options

A stage with PROGRAM = pepsi-stage-anti-spam reads (besides a NEXT_STAGE):

BOUNCE_STAGE

(string, optional) The stage an unpaid-by-deadline message is routed to — normally a pepsi-stage-bounce(1) stage (a rejection DSN, honouring the sender’s NOTIFY) or a pepsi-stage-discard(1) stage (silent drop). Without one, an unpaid message is just deleted.

ORDER_CHOICES

(JSON, required) The Taler v1 order choices array, used verbatim: one or more OrderChoice objects describing the accepted payments (for example several amounts in different currencies, or token-family inputs/outputs). See the GNU Taler merchant API.

PAYMENT_DEADLINE

(duration, optional) How long the message is held paused awaiting payment before it is rejected; also the order’s pay_deadline. Uses h/m/s units only (calendar units such as d are rejected). Default 48 h.

DELAY_DSN_AFTER

(duration, optional) Send the sender a one-shot delayed delivery-status notification (RFC 3461) once a still-unpaid message has been held this long, without releasing the message — it stays paused until PAYMENT_DEADLINE. The DSN is sent only when the sender requested NOTIFY=DELAY and a BOUNCE_STAGE is wired (it is routed there with kind = delay). Only fires when it lands strictly before PAYMENT_DEADLINE. Uses h/m/s units only. Unset (the default) disables delay warnings.

SUMMARY

(string, optional) Human-readable order summary shown to the payer. Default E-mail delivery.

FULFILLMENT_MESSAGE

(string, optional) Message shown to the payer after a successful payment. Default Your e-mail has been queued for delivery.

BLOCK_RESPONSE_STAGE

(string, optional) The stage at which the payment-request auto-reply is injected — usually the head of a signing/relay chain (for example a pepsi-stage-dkim-sign(1) stage). Must reference an existing stage. When unset, no reply is sent (the message is still paused awaiting payment).

BLOCK_RESPONSE_FROM

(string, optional) The From: header of the auto-reply. When unset it defaults to the original recipient (the protected mailbox).

BLOCK_RESPONSE_SUBJECT

(string, optional) The Subject: of the auto-reply. Default Payment required to deliver your e-mail.

PAYMENT_MESSAGE_DEFAULT_LANGUAGE

(string, optional) The fallback language for the auto-reply body, used when the message carries no detected state.language or none of the detected languages has a payment-request.<lang>.body template (under [pepsi] TEMPLATE_DIR). That template must exist (pepsi-setup checks). Case-insensitive. Default EN.

The stage also requires the shared [pepsi-payments] section (the merchant backend and access token, documented below) and, for the payment webhook to release paused messages, the [pepsi-httpd] RESUME_AUTHORIZATION_TOKEN (see pepsi-httpd(1)).

70.2.1.1.14.12. pepsi-stage-auto-pay Options

A stage with PROGRAM = pepsi-stage-auto-pay reads:

MAX_TOTAL

(amount, required) The most this stage will spend, in total, to deliver one original message — a GNU Taler amount CURRENCY:VALUE (for example EUR:5). The cap is cumulative across every payment demand returning for that message (all sharing one Pepsi-Origin nonce, e.g. a mailing-list fan-out) and single-currency: a demand in any other currency is not paid. An account can override this for its own mail via pepsi-stage-edit-settings(1).

NEXT_STAGE

(string, required) Where a message is forwarded when it is not a payment demand, is not provably ours, or is not paid (over budget / settlement failure). A stage with no NEXT_STAGE errors at run time.

WALLET_MODE

(``local-user`` | ``shared``, default ``shared``) Which account the wallet helper pays from. local-user drops to the bounce recipient’s own local login (the original sender) and uses that user’s default wallet — the recipient must resolve to a permitted local account (the LOCAL_DOMAINS/TARGETS/ RECIPIENT_DELIMITER test, as for pepsi-stage-relay-to-maildir(1)). shared drops to a single dedicated account (WALLET_USER).

WALLET_USER

(string, default ``pepsi-wallets``) The shared account the helper drops to in WALLET_MODE = shared; its home holds the wallet database(s).

WALLET_SCOPE

(``per-sender`` | ``unified``, default ``per-sender``) In shared mode, how the shared account’s wallet databases are segregated: one per original-sender address (per-sender), or a single wallet shared by everyone (unified — e.g. a business where individual wallets are not sensible).

WALLET_CLI

(string, default ``taler-wallet-cli``) The GNU Taler wallet command-line tool the helper runs (a name resolved on $PATH or an absolute path).

WALLET_PAY_OPTIONS

(string, default none) Extra arguments appended to the wallet’s pay invocation, whitespace-separated. The literal none passes no extra options. (--yes and --choice-index 0 are always added by the helper to confirm the payment and to name the contract choice it priced.) Nothing is needed here by default — the wallet’s handle-uri already waits for the payment to reach a terminal state.

HELPER

(string, default ``pepsi-helper-auto-pay``) The privileged wallet helper binary (a name on $PATH or an absolute path).

The following options enable and configure the wallet self-service role (a locally-submitted message to the control address operates the sender’s own wallet by e-mail; see pepsi-stage-auto-pay(1)):

RESPONSE_STAGE

(string, optional) The stage at which the self-service reply is injected (so it is DKIM-signed and relayed). Setting it enables the self-service role; leaving it unset means the stage only pays inbound demands.

CONTROL_LOCAL_PART

(string, default ``pepsi-wallet``) The local part of the wallet control address: a locally-originated message to <part>@<served-domain> is a wallet-control message.

RESPONSE_FROM

(string, optional) The From: of the self-service reply. Defaults to <{CONTROL_LOCAL_PART}@{domain}> for the served domain addressed.

RESPONSE_SUBJECT

(string, default ``Pepsi wallet``) The Subject: of the self-service reply.

A manual withdraw (top-up) names the GNU Taler exchange to withdraw from in the request (Subject: withdraw CURRENCY:VALUE EXCHANGE-URL); because a wallet is multi-currency and can withdraw from many exchanges, there is no server-side exchange option.

The stage also uses the shared [pepsi-origin] section (the proof-of-origin secret) to verify that a demand is for mail this deployment actually sent; without it no demand can be verified and none is paid (self-service is unaffected). All wallet work is done by the setuid-root pepsi-helper-auto-pay(1); the stage binary is installed SGID pepsi-wallets so its worker may exec it.

70.2.1.1.14.13. pepsi-stage-check-whitelist Options

A stage with PROGRAM = pepsi-stage-check-whitelist reads:

WHITELIST_NAME

(string, required) The whitelist_name group to consult in the pepsi.whitelist table.

NEXT_STAGE

(string, required) Where the message advances after the check (whether or not it matched). A stage with no NEXT_STAGE errors at run time.

70.2.1.1.14.14. pepsi-stage-auto-whitelist Options

A stage with PROGRAM = pepsi-stage-auto-whitelist reads:

WHITELIST_NAME

(string, required) The whitelist_name group to populate in the pepsi.whitelist table. It should match the group the inbound pepsi-stage-check-whitelist(1) consults.

DKIM_REQUIRED

(boolean, optional) Value stored in the dkim_required column of every row this stage inserts. Default yes.

NEXT_STAGE

(string, required) Where the message advances after the recipients are recorded. A stage with no NEXT_STAGE errors at run time.

70.2.1.1.14.15. pepsi-stage-autocrypt-learn Options

A stage with PROGRAM = pepsi-stage-autocrypt-learn reads:

LEARN_KEYS

(boolean, optional) Store the sender’s own key material found in the message — S/MIME signer certificates, application/pgp-keys parts and the Autocrypt: header — at the inbound trust rank, where it can never silently replace a better-sourced key. Default YES. Only material claiming the sender’s own address is kept. Turning it off turns the whole stage into a pass-through, LEARN_GOSSIP included.

LEARN_GOSSIP

(boolean, optional) Also learn the other recipients’ keys from the Autocrypt-Gossip: fields inside a message that arrived encrypted (Autocrypt Level 1 §5.3), at the gossip rank — the bottom of the ladder, below inbound. Default YES; it does nothing unless LEARN_KEYS is also on.

This is what makes an encrypted reply-all possible to a correspondent one has never had direct mail from. A field is used only when it was genuinely inside the ciphertext, only when its addr appears in the message’s own To:, Cc: or Reply-To:, never for the sender’s own address, and never for an address in LOCAL_DOMAINS — nobody may introduce this host to its own users. A gossiped key can never displace a better-sourced one and never makes a signature count as verified.

LEARN_FROM_SPAM

(boolean, optional) Whether a message the pipeline has already scored as spam (state.spam = true) may still teach a key. Default NO, which is Autocrypt Level 1 §5.3’s “messages SHOULD be ignored … when the MUA believes the message to be spam”.

It is an option rather than a fixed rule because the belief is Pepsi’s own: an operator whose spam scoring is deliberately loose may prefer key continuity to the rule. Note that honouring the default requires the stage to be placed after whatever sets state.spam — see pepsi-stage-autocrypt-learn(1).

LOCAL_DOMAINS, TARGETS, RECIPIENT_DELIMITER

(optional) The shared locality options. Only LOCAL_DOMAINS is consulted, and only to refuse: a gossip field naming an address this host serves is a key substitution rather than an introduction. Defaults to [pepsi-ingress] ACCEPTED_DOMAINS.

NEXT_STAGE

(string, required) Where the message advances once the stage has learnt whatever it could. The stage never consumes a message, so a missing NEXT_STAGE is refused by pepsi-setup(1) and errors at run time.

70.2.1.1.14.16. pepsi-stage-detect-language Options

A stage with PROGRAM = pepsi-stage-detect-language reads:

NEXT_STAGE

(string, required) Where the message advances after classification (whether or not a language was recorded). A stage with no NEXT_STAGE errors at run time.

LANGUAGES

(list, optional) The candidate languages, as whitespace/comma-separated ISO 639-1 codes (case-insensitive); at least two distinct codes are required. The token * stands for every supported language and may be combined with explicit codes (which then come first). Detection only ever returns languages from this set, so it should cover the languages you expect to receive. Defaults to a broad common set (en de fr es it pt nl pl ru tr ar zh ja).

Narrow it. Every enabled language adds to the memory each worker process uses, and — more importantly — every language that your traffic never contains still competes for probability. The stage removes non-prose (URLs, addresses, base64 blobs, DNS records) from the body before classifying, which is what stops a single such token from deciding the answer outright; what remains after that is a genuine contest between the candidates, and a language nobody writes to you can still win it on a short message. pepsi-detect-language(1) reclassifies a saved message against any candidate set, so a narrower list can be tried before it is configured.

70.2.1.1.14.17. pepsi-stage-block-language Options

A stage with PROGRAM = pepsi-stage-block-language reads:

NEXT_STAGE

(string, required) Where a message scoring above THRESHOLD advances — and, under ENFORCEMENT = soft, where a flagged message continues too. A stage with no NEXT_STAGE errors at run time.

BOUNCE_STAGE

(string, required when a message can be bounced) Where a message scoring at or below THRESHOLD is routed so a failure DSN can be generated (typically pepsi-stage-bounce(1)). Consulted only under ENFORCEMENT = hard; a bounce with no BOUNCE_STAGE errors at run time. Worth setting even under soft, so that switching to hard is a one-word edit.

ENFORCEMENT

(``hard`` | ``soft``, optional) What happens to a message that fails the policy. hard (the default, and the behaviour the stage had before this option existed) routes it to BOUNCE_STAGE, so it is not delivered. soft delivers it anyway, marked: SUBJECT_FLAG_LABEL is appended to its Subject:, a Pepsi-Detected-Languages header records what was detected, and the message advances to NEXT_STAGE.

soft marks exactly the messages hard would have bounced, which is what makes it a training mode: run it for a while and the flags in the mailbox are a faithful preview of what enforcement would refuse, at no cost to a correspondent whose mail the lists have not been tuned for yet. Being an ordinary stage option it is per-address overridable (pepsi-settings(1)), so one account can stay in training while the rest of the deployment enforces.

Note the Subject: rewrite breaks the sender’s DKIM signature and this server’s ARC AMS (both over-sign Subject), exactly as pepsi-stage-vacation(1)’s VACATION_TAG does — free on a branch that ends in local delivery, not free on one that relays onward.

SUBJECT_FLAG_LABEL

(string, optional) The marker appended to a flagged message’s Subject: under ENFORCEMENT = soft. Defaults to [!LANG]. Appended only when the subject does not already contain it (case-insensitively), so a reply quoting it back through the stage does not collect a second one. An empty value re-applies the default; there is no way to switch the marker off, since flagging nothing is the same as leaving the stage out of the pipeline.

WHITELIST

(list, optional) Languages whose probability is added to the score, as whitespace/comma-separated ISO 639-1 codes (case-insensitive) plus the optional pseudo-code none (matching undetected mail). Empty when unset.

BLACKLIST

(list, optional) Languages whose probability is subtracted from the score, same syntax as WHITELIST. Empty when unset. A language may appear on at most one of the two lists.

THRESHOLD

(float, optional) The score a message must exceed to advance. Defaults to 0.0.

70.2.1.1.14.18. pepsi-stage-vacation Options

A stage with PROGRAM = pepsi-stage-vacation answers mail that arrives while the envelope recipient is away. Every option below is per-address overridable, and that is how the feature is meant to be used: the INI section is the operator’s default (no vacation), and each user’s leave dates — and, if they want, their own message text — come from their pepsi.settings row or from a config_override scope. See pepsi-stage-vacation(1).

NEXT_STAGE

(string, required) Where the message continues. It always does: this stage adds a reply, it never consumes a message. pepsi-setup(1) rejects a stage without it.

RESPONSE_STAGE

(string, required) Where the vacation notice is injected as a new null-sender message, so it is signed and relayed (typically the DKIM-signing stage). pepsi-setup(1) checks it names an existing stage.

VACATION_RANGES

(list, optional) Comma-separated YYYY-MM-DD:YYYY-MM-DD spans of days the recipient is away. Both endpoints are inclusive whole days in the server’s time zone; an empty end (2026-08-01:) is an open-ended leave and a bare date is that single day. Empty when unset, which switches the stage off for that address — the default, and the state of every recipient who has never configured a vacation. A range whose end precedes its start is rejected.

DEFAULT_LANGUAGE

(string, optional) The language whose message is used when none of the sender’s detected languages (state.language) has one. Defaults to en. Written with either separator: pt_br and pt-BR are the same tag.

DEFAULT_MESSAGE_SECTION

(string, optional) The configuration section holding one inline message template per language, as <LANG> = <template>. Defaults to pepsi-vacation-default-message, which ships in ${DATADIR}/config.d with ten languages. A per-address MESSAGE_<LANG> option of this section takes precedence over it, per language.

EMERGENCY_CONTACT

(address, optional) A single bare address offered to the message template as {{EMERGENCY_CONTACT}}. Unset by default, in which case a template’s {{#EMERGENCY_CONTACT}} block is skipped. A display name or a list of addresses is rejected.

VACATION_TAG

(string, optional) Appended to the forwarded message’s Subject: when a notice was sent, so the recipient can see which mail was answered for them. Defaults to [VACATION]; the sentinel value none disables tagging. An empty value does not — the parser reads an empty option as absent, which re-applies the default. Appending is idempotent.

SUPPRESS_DAYS

(integer, optional) How long after answering one correspondent they may be answered again. Defaults to 7 (the Sieve vacation default, RFC 5230 §4.1); 0 answers every message.

REQUIRE_ADDRESSED_TO

(boolean, optional) Answer only when the recipient appears in To: or Cc: (RFC 3834 §3), so a blind carbon copy or a harvested-list blast draws no reply. Defaults to yes.

SUBJECT_PREFIX

(string, optional) Prepended to the original subject to form the notice’s subject. Defaults to Auto: (RFC 5230 §4.5). A message with no subject yields the prefix alone, and an already-prefixed subject is not prefixed twice.

70.2.1.1.14.19. pepsi-stage-if Options

A stage with PROGRAM = pepsi-stage-if branches on a member of the message state JSON:

STATE_PATH

(string, required) The dot-separated path into the message state whose value is tested (object keys and integer array indices, e.g. spam, auth.dkim, dsn.rcpt.0.notify).

VALUE

(string, required, non-empty) The value the member at STATE_PATH is compared against, by its natural scalar text form (string, true/false, decimal number, or null). An empty option reads as unset.

TRUE_STAGE

(string, required) Where the message advances when the value equals VALUE. pepsi-setup(1) checks it names an existing stage.

FALSE_STAGE

(string, required) Where the message advances otherwise (the value differs, the path is absent, or it resolves to an array/object). pepsi-setup(1) checks it names an existing stage.

70.2.1.1.14.20. pepsi-stage-milter Options

A stage with PROGRAM = pepsi-stage-milter hands the message to an existing milter daemon (the sendmail/Postfix mail-filter protocol) and routes it by the filter’s verdict. See pepsi-stage-milter(1), and note in particular that the filter runs post-queue, so a REJECT costs a bounce where an MTA would have answered 5xx inside the SMTP session.

SOCKET

(string, required) Where the milter daemon listens, in sendmail’s S= grammar (which Postfix also accepts): unix:/path (or its synonym local:/path), inet:host:port, inet:port@host, inet6:[addr]:port or inet6:port@addr. A bare path with no scheme is rejected. Pepsi connects to this socket; it never starts, stops or confines the daemon behind it.

MILTER_NAME

(string, optional) Exported to the filter as the {daemon_name} macro and used in log messages. Defaults to the stage’s own name.

ACCEPT_STAGE

(string, optional) Where SMFIR_ACCEPT sends the message. Defaults to NEXT_STAGE, which makes accept and continue the same thing; set it to skip past the rest of a filter chain, which is what Postfix’s milter list does natively. pepsi-setup(1) checks it names an existing stage.

REJECT_STAGE

(string, optional) Where SMFIR_REJECT — and a tempfail that has exhausted MAX_LIFETIME — sends the message, and where individually rejected recipients are fanned out to. Defaults to the section’s BOUNCE_STAGE. Point it at a pepsi-stage-discard(1) to drop rejected mail instead of bouncing it. pepsi-setup(1) checks it names an existing stage.

QUARANTINE_STAGE

(string, optional) Where SMFIR_QUARANTINE sends the message. Unset means discard: Pepsi has no quarantine store, only a route. A quarantine request decides the routing regardless of the verdict that follows it, because a sendmail milter quarantines and then still returns an accept. pepsi-setup(1) checks it names an existing stage.

ON_FAILURE

(``tempfail``/``accept``/``reject``/``discard``, default ``tempfail``) What to do when the milter cannot be reached, times out, or speaks something unparsable — Postfix’s milter_default_action, with the same default. tempfail pauses the message for retry, so a filter that is down delays mail rather than losing it or letting it through unfiltered.

ALLOW_ACTIONS

(string, default ``addhdrs chghdrs chgbody``) The SMFIF_* modification actions offered to the filter, whitespace- or comma-separated: addhdrs, chghdrs, chgbody, addrcpt, delrcpt, addrcpt_par, chgfrom, quarantine; plus the shorthands all and none. There is no inshdr: libmilter gates header insertion on SMFIF_ADDHDRS too.

With no sandbox around the filter this mask is the one blast-radius lever available, and the default is where it earns its keep: every content filter works under it, while a filter that wants to rewrite the envelope has to be granted that explicitly. A filter requiring an action that was not offered is reported by name and takes the ON_FAILURE path, rather than having its modifications silently dropped the way Postfix drops them.

SMFIF_SETSYMLIST is deliberately not listed and needs no grant: it authorises no change to the message.

PROTOCOL_VERSION

(integer 2–6, default 6) The highest milter protocol version offered; the filter negotiates down from it. Postfix’s milter_protocol.

CONNECT_TIMEOUT

(duration, default ``30 s``) Bound on establishing the connection and completing the option negotiation — both halves of “can this filter be reached at all”. Postfix’s milter_connect_timeout.

COMMAND_TIMEOUT

(duration, default ``30 s``) Bound on the reply to any single command up to end-of-body. Postfix’s milter_command_timeout.

CONTENT_TIMEOUT

(duration, default ``300 s``) Bound on the end-of-message verdict, where a content filter does its real work. An SMFIR_PROGRESS from the filter restarts the clock. Postfix’s milter_content_timeout.

RETRY_INITIAL / RETRY_MAX_INTERVAL / RETRY_FACTOR / MAX_LIFETIME

(durations / number, optional) The shared retry schedule for the tempfail path, with the same meaning as in the relay stages. Defaults: 300 s, 1 h, 2, 120 h.

MACROS_CONNECT / MACROS_HELO / MACROS_MAIL / MACROS_RCPT / MACROS_DATA / MACROS_EOH / MACROS_EOM

(string, optional) The sendmail macros exported at each protocol phase, whitespace- or comma-separated. The defaults are Postfix’s, so a filter’s own documentation applies unchanged: j {daemon_name} v {if_name} _, {tls_version} {cipher} {cipher_bits}, i {auth_type} {auth_authen} {mail_addr}, {rcpt_addr}, and i for the remaining three. A macro whose value this deployment does not know is not sent at all. Set a list to the sentinel none to export nothing at that phase — an empty value reads as absent and re-applies the default. A filter that names its own list with SMFIF_SETSYMLIST overrides these.

70.2.1.1.14.21. pepsi-stage-route Options

A stage with PROGRAM = pepsi-stage-route chooses a next stage for each envelope recipient from that recipient’s domain, splitting the message onto sibling rows when the recipients disagree. It exists for a gateway sitting in front of another mail system (see the manual’s “Microsoft Exchange as a gateway” chapter): the domains behind such a gateway are exactly the domains whose public MX is the gateway, so routing one of them to a direct-to-MX relay is a mail loop rather than a delivery failure.

Lookup order per recipient is ROUTES, then the managed-domain set, then NEXT_STAGE.

ROUTES

(string, optional) Whitespace- or comma-separated <domain-pattern>=<stage> pairs, consulted first and in the order written. Patterns are matched case-insensitively and may contain *, which matches any run of characters; they are globs, not regular expressions, and are anchored at both ends, so *.example.org matches mail.example.org but neither example.org nor mail.example.org.evil.test. A value that looks like a regular expression is rejected rather than silently matching nothing. Explicit rules are consulted before the managed set so one subdomain can be carved out without removing its parent from MANAGED_DOMAINS.

MANAGED_DOMAINS

(string, optional) The domains behind this gateway, whitespace- or comma-separated, * globs permitted. Defaults to [pepsi-ingress] ACCEPTED_DOMAINS, so the set cannot drift from what ingress actually accepts. At least one domain must resolve, from either source.

MANAGED_STAGE

(string, optional) Where a recipient at a managed domain goes — in a gateway deployment, the stage that relays to the system behind it. Without it, managed recipients take NEXT_STAGE, which pepsi-setup(1) then has to prove is not a loop.

NEXT_STAGE

(string, required) Where every other recipient goes. Required: the stage never delivers, bounces or drops a message, so every recipient must have somewhere to be.

pepsi-setup(1) checks every target names an existing stage, and refuses a configuration in which a message for a managed domain could reach pepsi-stage-relay-to-internet(1) — walking forward through NEXT_STAGE and any further routing stages, but deliberately not through BOUNCE_STAGE (a bounce is a new message about a delivery that already failed, and legitimately reaches a relay).

70.2.1.1.14.22. pepsi-stage-aliases Options

A stage with PROGRAM = pepsi-stage-aliases expands the envelope recipients of a message through an alias mapping file before forwarding it:

ALIASES

(path, required) The alias map file, parsed like a Postfix virtual(5) table: each line maps a key (a full address, an @domain catch-all, or a * wildcard such as sales-*@example.org) to one or more comma/whitespace-separated target addresses; # comments and blank lines are ignored. Recipients matching a key are replaced by its targets (transitively, with a loop guard) and the result is de-duplicated; on competing wildcards the most specific match wins. The file is re-read only when its modification time changes; a missing file is treated as an empty map. pepsi-setup(1) syntax-checks it at install time when present. See pepsi-stage-aliases(1).

NEXT_STAGE

(string, required) Where the (expanded) message advances. pepsi-setup(1) checks it names an existing stage.

70.2.1.1.14.23. pepsi-stage-edit-settings Options

A stage with PROGRAM = pepsi-stage-edit-settings reads:

EDITABLE_STAGES

(list, required) Whitespace/comma-separated stage names whose options a control e-mail may change. A body addressing any stage not listed here is refused.

RESPONSE_STAGE

(string, required) Stage at which the reply is injected (typically the start of the outbound signing/relay path), so it is DKIM-signed and delivered. Must resolve to an existing stage.

NEXT_STAGE

(string, required) Where a non-control message is advanced. A stage with no NEXT_STAGE errors at run time on the first ordinary message.

SUBJECT

(string, optional) The exact subject that marks a control message, and the subject of the reply. Default Pepsi.

CONTROL_LOCAL_PART

(string, optional) Local part of the control address. Default pepsi.

RESPONSE_FROM

(string, optional) From: header of the reply. Default <CONTROL_LOCAL_PART@domain> for the controlled domain the message was addressed to.

70.2.1.1.14.24. pepsi-stage-vks-confirm Options

A stage with PROGRAM = pepsi-stage-vks-confirm follows the confirmation link in a key server’s address-verification mail, so an uploaded key becomes findable by address. It sits on the inbound path and advances everything it does not act on — nearly all mail — untouched. See pepsi-stage-vks-confirm(1) for the full list of conditions it checks before fetching anything.

NEXT_STAGE

(string, required) Where every message this stage does not consume goes. Required rather than optional: a stage on the inbound path that silently dropped what it did not recognise would lose ordinary mail. pepsi-setup(1) checks it names an existing stage.

VKS_HOST

(string, optional) The one host a verification mail may come from and a link may point at. Defaults to the host of [pepsi-keys] VKS_SERVER. Matched exactly: a subdomain of a key server is not the key server.

MAX_LINKS

(integer, optional) How many links one message may cost. Default 3. A genuine verification mail carries one; the bound is what stops a message that got past every other check from becoming an arbitrary number of requests.

DISCARD_CONFIRMED

(boolean, optional) Whether a confirmation mail the stage acted on is deleted rather than delivered. Default YES: it is machine mail about an action Pepsi took, addressed to a mailbox whose owner did not ask for it, and it has already been acted on by the time it would arrive.

REQUIRE_AUTHENTICATION

(boolean, optional) Whether SPF or DMARC must have passed, not merely the envelope sender matching VKS_HOST. Default YES. Note that a DKIM pass deliberately does not satisfy this: it says a signature verified, not whose.

There is no BOUNCE_STAGE: the stage never fails a message.

70.2.1.1.15. The [pepsi-dispatch] Section

Options for pepsi-dispatch(1). Per-stage worker-pool sizing (PARALLELISM, MAX_MESSAGES, QUEUE_LIMIT) lives in each [stage-<name>] section above, not here.

CONFIG_FILE

(path, optional) The configuration file passed to spawned workers via -c. Set it to this file’s path whenever the dispatcher is started with an explicit -c (the loaded path cannot be recovered otherwise); if unset, workers use the default configuration search.

POLL_INTERVAL

(duration, optional) Safety-net heartbeat: the longest the dispatcher sleeps when no sooner paused retry, worker reap or spawn-cooldown is due, at which point it re-scans for work and requeues any due paused rows. Default 30 s.

MAX_RUNTIME

(duration, optional) Maximum real time a worker may spend on a single message. A worker that exceeds it is killed (and replaced) and the message set to timeout. Default 300 s.

WORKER_IDLE_TIMEOUT

(duration, optional) How long an idle worker process is kept before it is stopped. Total concurrency is set per stage (PARALLELISM), not globally. Default 5 s.

STATS_INTERVAL

(duration, optional) How often the in-memory pipeline statistics (exported by pepsi-httpd(1) at /metrics) are flushed to the database, in a single transaction. Also flushed on shutdown; an idle dispatcher writes nothing. Default 60 s.

DB_POOL_SIZE

(number, optional) Maximum number of PostgreSQL connections in the dispatcher’s database pool. Unlike a single-connection stage worker, the dispatcher processes many workers’ results (advancing/failing their messages) concurrently with its own coordinator queries, so it keeps a small bounded pool. The per-statement work is brief, so a handful of connections absorbs heavy worker churn. Defaults to 8; raise it only if the coordinator is connection-starved, and keep the sum of every component’s pool below PostgreSQL’s max_connections.

70.2.1.1.16. The [pepsi-failure-bouncer] Section

Options for pepsi-failure-bouncer(1), which moves failed/timeout messages to a bounce stage so the sender is notified. Read only by that tool.

BOUNCE_STAGE

(string, required when the tool is used) The [stage-<name>] label a stuck message is moved to (and reset to pending). Normally the same bounce stage the pipeline already routes permanent failures to (e.g. bounce). A message already at this stage is left untouched, so a bounce that fails at the bounce stage does not loop. pepsi-setup(1) checks the named stage exists.

70.2.1.1.17. The [pepsi-sendmail] Section

Options for pepsi-sendmail(1), the Sendmail-compatible local submission client the Debian package installs as /usr/sbin/sendmail. Every option has a default, so a site needs no section at all.

The client is unprivileged and asserts no identity: it hands the message to a UNIX-domain ingress listener, which derives the sender’s account from the connecting process’s kernel-reported credentials (AUTH_PEERCRED, above).

SOCKET

(path, optional) The pepsi-ingress UNIX-domain submission socket. Default /run/pepsi/submission.sock.

Despite living in the client’s section, this option is read by both ends: pepsi-ingress serves this socket unconditionally and synthesises its listener from this path, so there is no [pepsi-ingress-listener-*] section for it and no second declaration that could name a different path. (There used to be, and pepsi-setup(1) used to cross-check the two; a structure that cannot disagree is better than a check that can pass while the deployment is broken.)

On systemd the descriptor is inherited when pepsi-ingress.socket already binds this path — matched by the address the descriptor is bound to, not by an FD_INDEX — so a change here needs a matching ListenStream= in the unit. Otherwise the server binds the path itself, and refuses to start if it cannot.

The special value none disables local submission entirely: no socket is served and pepsi-sendmail(1) refuses to run, so local programs must authenticate on 587/465. pepsi-setup(1) reports this once.

EHLO_NAME

(string, optional) Name announced in EHLO. Defaults to [pepsi-ingress] HOSTNAME. Cosmetic: the server authenticates the peer by its credentials, not by what it claims here.

70.2.1.1.18. The [pepsi-whitelist] Section

Options for pepsi-whitelist(1), the tool that manages the sender whitelist and can seed one from a user’s mailbox (import). Read only by that tool; every option is optional, so a site that only uses add/list/remove needs no section at all.

LOCAL_DOMAINS

(string, optional) Domains whose senders count as the user’s own mail when a mailbox is scanned: a message whose From:/Sender: is at one of them is one the user sent, so its recipients become whitelist entries. Comma- or space-separated. Defaults to [pepsi-ingress] ACCEPTED_DOMAINS, and is the same option pepsi-stage-relay-to-maildir(1) and pepsi-stage-dot-forward(1) use.

TARGETS, RECIPIENT_DELIMITER

(string, optional) As for the delivery stages. TARGETS bounds which local accounts import --all-users seeds (default: the /etc/login.defs UID_MIN..``UID_MAX`` range); RECIPIENT_DELIMITER strips a sub-address before an address is compared.

HOSTERS_FILE

(path, optional) File listing public e-mail hosters, one domain per line (# comments; a leading *. covers a subdomain tree). Domains in it are never proposed as an *@domain wildcard by import --auto-wildcard, however many correspondents the user has there — “everyone at gmail.com” is not a set of people the user knows, and whitelisting it would hand every spammer with a free account a bypass of the anti-spam gate. Their individual addresses are still imported. Defaults to ${DATADIR}/hosters.txt, which make install writes; point this at your own copy to extend the list, since the installed one is overwritten on upgrade.

WILDCARD_THRESHOLD

(number, optional) How many distinct addresses at one domain make import --auto-wildcard propose a single *@domain entry for it. Default 10.

MAX_ENTRIES

(number, optional) Most rows one import may add. Default 5000. Every row of a whitelist is matched against the From: of every inbound message that consults it, so this is a bound on a permanent per-message cost, not just on table size.

SCAN_HELPER

(string, optional) The mailbox-scan helper binary, located on $PATH. Default pepsi-helper-mailbox-scan (see pepsi-helper-mailbox-scan(1)).

MAILBOX

(path, optional) Mailbox to scan when the user names none. Defaults to the user’s ~/Maildir if it exists, else /var/mail/<login>.

70.2.1.1.19. The [pepsi-httpd] Section

Options for pepsi-httpd(1). The server also reads the served domains (ACCEPTED_DOMAINS), the MTA-STS mx host (the ingress HOSTNAME) and the [pepsi] MTA_STS_* options.

MAX_CONNECTIONS

(number, optional) Maximum number of HTTP connections served concurrently. Default 256.

DB_POOL_SIZE

(number, optional) Maximum number of PostgreSQL connections in the HTTP server’s database pool. The server handles requests concurrently, so unlike the single-connection stage workers it can use more than one. Defaults to 1; raise it only if the endpoints are bottlenecked on the single connection, and keep the sum of every component’s pool below PostgreSQL’s max_connections.

RESUME_AUTHORIZATION_TOKEN

(string, optional) Bearer token guarding POST /resume (compared in constant time); the endpoint is disabled (404) when unset. Use the Taler secret-token: form so the same value can be reused as the merchant pepsi-resume webhook’s Authorization header (see pepsi-stage-anti-spam(1) and the [pepsi-payments] section).

ADDIN

(boolean, optional) Whether the Outlook add-in routes (GET /addin/manifest.xml and GET /addin/taskpane.html) answer. Default no: a deployment that is not fronting Exchange has no use for them, and a manifest naming a gateway nobody configured is a support question waiting to happen. See the manual’s “Microsoft Exchange as a gateway” chapter.

ADDIN_URL

(string, optional) The public https:// origin substituted into the add-in assets. Unset (the default) derives it from each request’s Host header, which is what works both when pepsi-httpd terminates TLS itself and when it sits behind a reverse proxy — in the latter case it sees plain HTTP over a UNIX socket and cannot tell the public scheme from the connection. Set it when the Host reaching Pepsi does not name the gateway. The scheme is always https: Outlook refuses to load add-in resources over plain HTTP.

Each [pepsi-httpd-listener-<name>] section binds one socket, with the same SERVE (tcp/unix/systemd) and transport options as a [pepsi-ingress-listener-<name>] section, except a tcp listener defaults to PORT 443 and MODE is plain or tls (no STARTTLS). A tls listener may set TLS_CERT/TLS_KEY as a fallback certificate. It additionally accepts:

ADMIN = yes | no

Whether the administrative surface — the /api/v1 API and the /ui administration console — is served on this listener. Optional; defaults to no. On a listener without it those routes answer a plain 404 — byte-for-byte what any unknown path gets — so publishing the public listener does not publish administration.

pepsi-httpd(1) refuses to serve them on a flagged listener that would carry them in cleartext off this host: plaintext tcp is accepted only on a loopback address, while tls listeners and unix sockets always qualify. A flagged listener that fails the test logs a warning at start-up and serves the public endpoints only, and pepsi-setup(1) reports the same at validation time.

The shipped configuration flags one unix listener, which is the shape that needs no credentials at all: a connecting process is identified by SO_PEERCRED. See [pepsi-admin] below.

Each [pepsi-httpd-cert-<name>] section registers one certificate for SNI selection: SNI (one or more host names), TLS_CERT and TLS_KEY.

70.2.1.1.20. The [pepsi-admin] Section

Options for the administrative API served by pepsi-httpd(1) on a listener flagged ADMIN = yes. Every option has a default, so the section may be omitted entirely. The authentication model, the scope table and the endpoint reference are in the manual’s “The administrative API” chapter.

The same options govern the /ui administration console, which is a client of that API and introduces no configuration of its own: it shares the session timeouts, the rate limits, the body cap, the page size and the configuration-write connection. See the manual’s “The administration console” chapter.

ADMIN_GROUP

(string, optional) UNIX group whose members are local administrators over a UNIX-socket listener, identified by SO_PEERCRED. root always is, group or no group. Default pepsi-admin.

SESSION_IDLE

(duration, optional) Idle timeout of a browser session, pushed forward on every request. Default 30 m.

SESSION_LIFETIME

(duration, optional) Hard lifetime of a browser session, never extended. Default 12 h. There is no “remember me”: this console can revoke a key and read who corresponds with whom, and local administrators — who log in most often — authenticate by SO_PEERCRED and have no session to expire.

EVENT_RETENTION_DAYS

(number, optional) How long audit records are kept, in days. Default 90. pepsi-httpd(1) prunes both logs a few times a day; it is the one component holding DELETE on them that does not process mail.

MAIL_LOG_RETENTION_DAYS

(number, optional) How long mail-log records are kept, in days, when [pepsi] MAIL_LOG is on. Default 30.

Both retentions are integers of days rather than durations because Section::duration parses through jiff::SignedDuration, which accepts only h/m/s and below.

RATE_LIMIT

(number, optional) Requests per minute per source address (a UNIX-socket listener shares one key for the whole host). 0 disables the limit. Default 120.

LOGIN_RATE_LIMIT

(number, optional) Login attempts per minute, counted per account and per source address — many passwords against one account and one password against many accounts are different attacks. 0 disables the limit. Default 10.

MAX_BODY

(number, optional) Largest accepted request body, in bytes. Default 1048576.

PAGE_LIMIT

(number, optional) Default page size of a listing endpoint. A caller may ask for up to 1000; every listing honours limit and offset in its SQL and reports an exact total beside the page, and nothing returns an unbounded result set. Default 100.

CONFIG_DB

(PostgreSQL connection string, optional) Connection used for configuration writes. Unset by default, in which case PUT/DELETE on /api/v1/config answer 503 with the reason and every other endpoint is unaffected.

Writing pepsi.config_override belongs to the pepsi-config database role, which pepsi-setup(1) grants and explicitly revokes from every service account: a component that processes mail must not be able to rewrite the pipeline it runs in. pepsi-httpd is such an account, and peer authentication keys off the effective uid, so its own connection cannot be that role. Point this at a connection that authenticates as pepsi-config — a password in a secrets.d fragment readable only by the pepsi-httpd account, or a pg_ident map. The server checks with SELECT current_user at start-up and refuses anything else, because a boundary the code merely believes in is not a boundary.

The online setup enqueues through the same connection and for the same reason: only pepsi-config may INSERT into pepsi.setup_task, and the privileged applier refuses any row that says otherwise. Without CONFIG_DB the setup endpoints answer 503 too — the server can serve the questions and watch a task, and cannot ask for one.

APPLY_SOCKET

(path, optional) Where to ring for the privileged setup applier. Default /run/pepsi/setup-apply.sock.

After enqueuing a setup task, pepsi-httpd connects to this socket and closes it. That connection is the entire message: nothing is written to it and nothing is ever read from it, so it cannot become a command channel. What it does is let systemd’s socket activation start pepsi-setup apply, which takes its work from the database — where every row is validated and audited — and exits again when there is none.

A socket rather than a signal or a spawn because pepsi-httpd runs unprivileged and has no way to start a root process, and should not acquire one. This is the one mechanism that lets it cause a privileged process to run without being able to influence what that process does. A failure to connect is not an error: the task is durably queued either way, and pepsi-setup apply --once from cron or a console picks it up.

This path is also what APPLIER = auto probes; see below.

APPLIER

(auto|yes|no, optional) Whether a privileged setup change can happen from this server at all. Default auto.

The applier’s systemd units are packaged separately (Debian pepsi-httpd-admin, or make install INSTALL_ADMIN_UNITS=no from source), so that a deployment which is administered from a terminal can remove the one mechanism by which an HTTP request reaches /etc/pepsi. With them gone nothing drains pepsi.setup_task and an insert into it is inert — so the console must not go on offering forms whose submissions would silently never be applied.

auto

Probe two things: that pepsi-setup-apply.socket exists as a unit file where systemd looks for one (installed), and that APPLY_SOCKET exists, is a socket and is writable by this account (armed — connecting to a UNIX socket needs write permission). The socket is never connected to: connecting is the doorbell ring, which would start a root process on every page render. The probe fails closed: any uncertainty, including a path that cannot be examined and a socket node left behind by a removed package, reads as “no applier”.

yes

Assume the applier is reachable and skip the probe. For a deployment that drains the queue some other way — pepsi-setup apply --once from cron, a hand-written unit, a host without systemd — where the probe would report a capability that does exist. It is a promise the operator makes; nothing verifies it.

no

Refuse every privileged setup request whatever is installed. A way to make the console read-only without changing the package set.

When there is no applier, pepsi-httpd still serves the setup interview, the staged answers and the task history — read-only, with the controls disabled — and answers the mutating endpoints 503 setup_applier_unavailable. See pepsi-httpd(1).

70.2.1.1.21. The Egress Identity Option: PUBLIC_IP

PUBLIC_IP = ADDR [ADDR …]

Whitespace- or comma-separated list of public IPv4 and/or IPv6 addresses that send mail as your domains. Each becomes an ip4: or ip6: mechanism in the v=spf1 record pepsi-setup(1) suggests. No stage reads this option at run time — it is purely an input to the generated SPF record.

PUBLIC_IP is a per-stage option set in a relay stage’s [stage-<name>] section, and pepsi-setup(1) collects it from every stage whose PROGRAM is pepsi-stage-relay-to-internet(1) or pepsi-stage-relay-to-smarthost(1) — detected by PROGRAM, never by the section name — and unions the results. A deployment may therefore have several relay stages, each carrying its own PUBLIC_IP:

  • On a pepsi-stage-relay-to-internet stage it is this host’s own public sending IP(s): the host delivers directly to recipients’ MX from them.

  • On a pepsi-stage-relay-to-smarthost stage it is the smarthost’s egress IP(s): mail leaves the internet from the smarthost, so SPF must authorise it (many providers instead publish an include: you add to the record by hand).

If no relay stage sets PUBLIC_IP the generated record is a bare v=spf1 -all — which tells receivers no host may send for your domains and makes your own outbound mail fail SPF — and pepsi-setup(1) logs a warning. A receive-only deployment (no relay stage) has no PUBLIC_IP and is not warned.

Each address must be a globally-routable public IP — the address receivers see mail arrive from. pepsi-setup(1) warns (at validation and in the DNS output) if a PUBLIC_IP is a non-public address (loopback, RFC 1918 / unique-local, link-local, or CGNAT): behind NAT, use the host’s public egress IP, not its LAN address, or outbound mail over that address family will fail SPF.

Reverse DNS (PTR). Each pepsi-stage-relay-to-internet PUBLIC_IP — this host’s own egress IP — must also have a forward-confirmed reverse DNS record that names the stage’s SERVER_NAME: a PTR for the address whose host name in turn resolves (via A/AAAA) back to the same address, and which is the very name the stage announces in EHLO. Many receiving mail servers treat a missing, non-forward-confirmed, or generic/dynamic-looking PTR as a spam signal and reject or junk the mail, and a large family of them additionally compares the EHLO name against the PTR and refuses the session when the two name different hosts (HELO host does not match rDNS). Being inside your ACCEPTED_DOMAINS does not satisfy that test — a host answering to several names can announce one and reverse-resolve to another, both of them its own, and still be refused. pepsi-setup(1) warns for every PUBLIC_IP whose reverse DNS is not correct in this sense, including that mismatch, and prints the remediation (see pepsi-setup(1)). Reverse DNS for a PUBLIC_IP lives in the IP owner’s zone (in-addr.arpa / ip6.arpa) — usually your ISP or hosting provider — so unlike the forward records it is often not something you can publish yourself; where it cannot be changed, set SERVER_NAME (and [pepsi-ingress] HOSTNAME, the MX record and the TLS certificate) to the name the PTR already gives. A pepsi-stage-relay-to-smarthost PUBLIC_IP names the smarthost’s egress IPs, whose reverse DNS the smarthost operator controls, so those are not checked.

The unioned record is published identically for every served domain. That is correct but broad; in special setups where different domains send from different hosts, a hand-written per-domain SPF record (listing only that domain’s own sending IPs) can be tighter and still correct. pepsi-setup(1) does not compute that grouping automatically and notes this in its DNS output.

70.2.1.1.22. The [pepsi-stage-relay-to-smarthost] MTA Sections

70.2.1.1.22.1. The Smarthost (MTA) Sections

Each [pepsi-stage-relay-to-smarthost-mta-<name>] section defines one upstream smarthost; the suffix <name> is a free-form label used in logs. These sections are shared by every smarthost-relay stage. A recipient domain is routed to the MTA whose DOMAINS lists it, or to the single CATCH_ALL MTA.

HOST

(string, required) Host name or address of the smarthost to connect to.

PORT

(number, required) TCP port to connect on (e.g. 587 or 465).

MODE

(optional) Transport security: starttls (default), tls (implicit TLS) or plain (cleartext).

TLS_VERIFY

(boolean, optional) Whether the smarthost’s TLS certificate is verified against the system trust store. Default yes; no accepts any certificate (insecure, for testing only).

TLS_CA

(path, optional) An extra CA certificate (PEM) to trust in addition to the system store, e.g. for a private smarthost CA.

TLS_CLIENT_CERT / TLS_CLIENT_KEY

(path, optional; both-or-neither) A client certificate (PEM chain) and its private key to present to the smarthost during the TLS handshake (mutual TLS). Loaded fresh on every connection. Requires an encrypting MODE (tls or starttls) and applies to PKIX, TLS_VERIFY = no and DANE alike. The key is secret: keep it readable only by the pepsi-token group the relay stage runs SGID under (on Debian, place cert + key in the SGID /var/pepsi/tls directory). Set AUTH = external to also authenticate the SMTP session with the certificate via SASL EXTERNAL.

DANE

(optional) DANE/TLSA (RFC 7672) enforcement towards this smarthost: off, warn (the default) or strict. When the smarthost publishes DNSSEC-validated TLSA records at _<PORT>._tcp.<HOST>, the certificate is authenticated against them, taking precedence over TLS_VERIFY (PKIX). In warn a mismatch is logged and delivery proceeds; in strict a usable-but-mismatching record, or a failed TLSA lookup, defers the message. Ignored when MODE = plain. Requires a DNSSEC-validating resolver reached over a trusted path (Pepsi trusts the response AD bit); see the stage’s DNS_SERVERS.

ADDRESS_FAMILY

(optional) Which IP address families may be used to reach this smarthost: any, ipv4 (also spelled v4/v4only) or ipv6 (v6/v6only).

Precedence is: this entry, then the owning [stage-*] section, then any. The per-entry level is what makes the option useful: one misbehaving destination can be pinned without forcing every other destination — including one that publishes only AAAA — onto the same family.

The motivating case is Microsoft Exchange Online, which rejects mail from a sending IPv6 address with no PTR record (550 5.7.1 ... S820). A dual-stacked host prefers IPv6, so a machine with correct IPv4 reverse DNS and undelegated IPv6 reverse DNS delivers fine everywhere except there.

With any (the default, and the behaviour of every release before this option existed) the host name is handed to the system resolver as before. Any other value makes Pepsi resolve the host itself and try each address of the chosen family in turn. A destination that publishes no address of the chosen family is a transient failure, not a bounce: that is an operator error worth retrying past, not one worth destroying queued mail over.

AUTH

(optional) SMTP authentication mechanism: none (default), plain, login, cram-md5, digest-md5, scram-sha-1, scram-sha-256, scram-sha-1-plus, scram-sha-256-plus, auto, external, oauth, ntlm or gssapi. plain/login/cram-md5/digest-md5/the scram-* family/auto all require USERNAME and PASSWORD; oauth requires USERNAME and TOKEN_FILE; external (SASL EXTERNAL, RFC 4422) requires TLS_CLIENT_CERT / TLS_CLIENT_KEY and authenticates by that certificate (no password sent), with an optional USERNAME as the SASL authorization identity. cram-md5 (RFC 2195) and scram-sha-1/scram-sha-256 (RFC 5802 / RFC 7677) are challenge/response: the password is never sent, and SCRAM additionally authenticates the server to Pepsi (a bad server signature is a permanent failure). The -plus SCRAM variants add tls-server-end-point channel binding (RFC 5929). auto picks the strongest mechanism the smarthost advertises (SCRAM-SHA-256-PLUS > SCRAM-SHA-256 > SCRAM-SHA-1-PLUS > SCRAM-SHA-1 > CRAM-MD5 > LOGIN > PLAIN); it never selects the deprecated digest-md5, ntlm or gssapi. digest-md5 (RFC 2831) is also a challenge/response with server authentication (rspauth) but is deprecated (RFC 6331 moved it to Historic) and provided only for legacy smarthosts; selecting it logs a warning from pepsi-setup(1) and at runtime. ntlm is the de-facto Microsoft mechanism (no RFC; NTLMv2 only, with tls-server-end-point EPA channel binding) requiring USERNAME/PASSWORD/NT_DOMAIN; it is weak and does not authenticate the server, so pepsi-setup(1) warns. gssapi is SASL GSSAPI/Kerberos (RFC 4752); it takes no password, obtaining a service ticket for SERVICE_NAME from a credential cache (KRB5CCNAME). All are offered only over an encrypted transport (MODE = tls/starttls); ntlm and gssapi reject MODE = plain at configuration time.

USERNAME / PASSWORD

(string, required when AUTH is plain/login/cram-md5/digest-md5/scram-*/auto/ntlm) The SMTP-AUTH credentials. Keep PASSWORD in a mode-restricted file merged with @inline-secret@. For the scram-* mechanisms both are normalised with SASLprep (RFC 4013). USERNAME is also the optional authorization identity for AUTH = external and AUTH = gssapi.

NT_DOMAIN / NT_WORKSTATION

(string; NT_DOMAIN required when AUTH = ntlm) The Windows domain of the NTLM account, and an optional cosmetic client workstation name.

SERVICE_NAME

(string, optional; AUTH = gssapi) The Kerberos host-based service principal of the smarthost, in service@host form. Defaults to smtp@<HOST>.

KRB5CCNAME

(string, optional; AUTH = gssapi) The Kerberos credential cache to read for this smarthost (e.g. FILE:/var/pepsi/krb5/smarthost.cc), overriding the ambient KRB5CCNAME. Pepsi only reads the cache; an external keytab + k5start/cron job must keep the ticket fresh, and the cache must be readable by the relay worker (the SGID pepsi-token group; see /var/pepsi/krb5). A missing/expired ticket defers the message (transient failure) rather than bouncing it.

TOKEN_FILE

(path, required when AUTH = oauth) File holding the current OAuth 2.0 access token (whitespace-trimmed), re-read on every delivery. The SASL mechanism is chosen from the smarthost’s EHLO (OAUTHBEARER preferred, else XOAUTH2). The relay stage only reads this file; keep it current with pepsi-helper-token-refresh(1) (see the [pepsi-helper-token-refresh-*] sections) or any equivalent external job. A missing/expired/rejected token defers the message (transient failure) rather than bouncing it.

HELO_NAME

(string, optional) Name announced in EHLO to this smarthost. Defaults to the stage’s SERVER_NAME.

DOMAINS

(string) Whitespace/comma-separated recipient domains routed to this MTA (case-insensitive). Required unless CATCH_ALL = yes; a domain may be routed by at most one MTA.

CATCH_ALL

(boolean, optional) Whether this MTA receives every domain not matched by a DOMAINS entry. Default no; at most one MTA may set it. An MTA must list DOMAINS or set CATCH_ALL = yes.

70.2.1.1.23. The [pepsi-helper-token-refresh] Sections

These configure pepsi-helper-token-refresh(1), the optional service that keeps the OAuth access tokens of AUTH = oauth smarthosts current. The set of tokens to maintain is derived from the smarthost MTA sections: for each [pepsi-stage-relay-to-smarthost-mta-<name>] with AUTH = oauth the service looks for a matching [pepsi-helper-token-refresh-<name>] section. The service is not part of pepsi.target; enable it explicitly.

Because the per-target sections hold OAuth client secrets, keep them in a file readable only by the pepsi-helper-token-refresh user and include each with the @inline-secret@ directive (which a reader that cannot open the file — the relay stage, run as pepsi — silently skips, so the same pepsi.conf parses for both). See pepsi-helper-token-refresh(1) and the Extending the Pipeline manual chapter.

The optional base section [pepsi-helper-token-refresh] holds service-wide, non-secret options:

STATE_DIR

(path, optional) Private directory (mode 0700) for per-target rotated refresh tokens. Default /var/pepsi/token-refresh.

Each [pepsi-helper-token-refresh-<name>] section (<name> matching an OAuth smarthost MTA) holds:

TOKEN_ENDPOINT

(string, required) The provider’s OAuth 2.0 token endpoint URL (HTTPS).

GRANT

(optional) refresh_token (the default; e.g. Google) or client_credentials (e.g. Microsoft 365 app-only).

CLIENT_ID / CLIENT_SECRET

(string, required) The OAuth client credentials, sent in the request body.

REFRESH_TOKEN

(string, required for GRANT = refresh_token) The long-lived refresh token. A rotated value returned by the endpoint is persisted under STATE_DIR and then takes precedence.

SCOPE

(string, required for GRANT = client_credentials; optional otherwise) The requested scope.

REFRESH_MARGIN

(duration, optional) How long before a token’s stated expiry to refresh it. Default 5 m. Uses h/m/s units only.

70.2.1.1.24. The [pepsi-payments] Section

This optional section configures the GNU Taler payment integration. When it is absent (or MERCHANT_BACKEND_URL is unset) pepsi-setup(1) performs no merchant checks and no webhook is provisioned. The same section is read at run time by pepsi-stage-anti-spam(1) to create per-message payment orders.

MERCHANT_BACKEND_URL = URL

Base URL of the Taler merchant backend, optionally including the instance (…/instances/$ID). pepsi-setup(1) requests GET /config at the backend root (any trailing /instances/$ID is stripped) to confirm it is a taler-merchant backend, and GET /private/orders?limit=1 at this URL to confirm the access token. Required to enable the integration.

MERCHANT_ACCESS_TOKEN = TOKEN

Access token for the merchant backend, in the Taler secret-token:… form, sent as an Authorization: Bearer credential. It needs the orders-read and webhooks-read/webhooks-write permissions. Required when MERCHANT_BACKEND_URL is set.

The provisioned pepsi-resume webhook fires on the pay event and POSTs {"message_id":"{{order_id}}"} to https://<host>/resume (the shortest [pepsi-httpd-cert-*] SNI host), authenticated with [pepsi-httpd] RESUME_AUTHORIZATION_TOKEN. The merchant order_id must therefore equal the message’s external token.

70.2.1.1.25. The [pepsi-vacation-default-message] Section

The per-language vacation notices pepsi-stage-vacation(1) falls back to, one option per language:

[pepsi-vacation-default-message]
EN = Hello {{SENDER_NAME}},  I am away until {{VACATION_END}}.
DE = Hallo {{SENDER_NAME}},  ich bin bis {{VACATION_END}} abwesend.

The option name is the language tag (EN, DE, PT_BR — either separator), and the value is a Mustache template. Which language is used, which fields expand, and why two spaces become a newline are all documented in pepsi-stage-vacation(1).

This section normally already exists. make install ships it as ${DATADIR}/config.d/vacation.conf, and taler’s configuration loader parses every file in ${DATADIR}/config.d before pepsi.conf. So a stage configured with no message of its own still has ten languages available, and an operator who wants different wording redefines just the language they care about in pepsi.conf: later definitions win option by option, never section by section, so overriding EN leaves the other nine in place. Editing the shipped file works too, but a package upgrade overwrites it.

The section name is only a default (DEFAULT_MESSAGE_SECTION), so an operator who wants several message sets — a formal one for a support address, a terse one for everyone else — can define more sections and point different stages, domains or addresses at them.

An account owner never touches this section: their own text goes in MESSAGE_<LANG> in the stage’s own section, which the settings layer can carry and which wins per language.

70.2.1.1.26. The [pepsi-autoconfig] Section

This optional section publishes mail account autoconfiguration (draft-ietf-mailmaint-autoconfig): the XML document a mail client fetches, knowing only the user’s address, so it can configure itself instead of asking for eight settings the user usually cannot supply. It is served by pepsi-httpd(1) at /mail/config-v1.1.xml on autoconfig.<domain>, and at /.well-known/autoconfig/mail/config-v1.1.xml on the mail domain itself.

The feature is off until an incoming server is named. Omit the section — or set neither IMAP_HOST nor POP3_HOST — and both endpoints answer 404. That is deliberate rather than incidental: a client that finds a configuration document stops walking the fallback chain, so publishing one that cannot tell it how to read mail leaves the user worse off than publishing nothing at all.

Pepsi does not serve mailboxes (see pepsi-stage-relay-to-lmtp(1)), so the incoming half must name whatever MDA the deployment pairs with, typically Dovecot on the same host.

Two things outside this file are needed as well. An autoconfig.<domain> DNS record must resolve to this server, and the TLS certificate must cover that name — clients try the https://autoconfig.… URL first, and a certificate error there is a failed lookup. A deployment unwilling to add the name can rely on the /.well-known/ form alone, at the cost of being found only by clients that try it.

IMAP_HOST, POP3_HOST

(hostname, optional) The mailbox server to advertise. Setting either enables the feature; setting neither disables it. No default — an invented hostname would be worse than silence.

IMAP_PORT, POP3_PORT

(number, optional) Defaults 993 and 995, the implicit-TLS ports.

IMAP_SOCKET, POP3_SOCKET

(``SSL`` | ``STARTTLS`` | ``plain``, optional) Transport security, in the draft’s own vocabulary (SSL means implicit TLS). Default SSL.

IMAP_AUTH, POP3_AUTH

(optional) One of the draft’s authentication values: password-cleartext, password-encrypted, NTLM, GSSAPI, TLS-client-cert, OAuth, client-IP-address, none. Default password-cleartext — which, over SSL, is the ordinary password-over-TLS arrangement rather than anything sent in the clear. An unrecognised value is a start-up error, not a silently published typo.

SMTP_HOST, SMTP_PORT, SMTP_SOCKET, SMTP_AUTH

(optional) The submission server to advertise. Normally omit all four: they are derived from the [pepsi-ingress-listener-*] section flagged SUBMISSION = yes — its port, its MODE and its authentication mechanism — so moving submission from STARTTLS on 587 to implicit TLS on 465 updates the published document by itself, and the two cannot drift apart. Set them only when the public submission endpoint is not the listener, for instance behind a load balancer. Setting SMTP_HOST switches off the derivation entirely, so give the others too if they differ from 587/STARTTLS/ password-cleartext.

A UNIX-socket or systemd-activated submission listener is never advertised: a remote client cannot use the first, and the second has no port in the configuration to publish.

DISPLAY_NAME, DISPLAY_SHORT_NAME

(text, optional) What the client shows the user. Both default to the first [pepsi-ingress] ACCEPTED_DOMAINS entry.

DOCUMENTATION_URL

(URL, optional) A help page for the account settings, published as the draft’s <documentation> element. Omitted when unset.

[pepsi-autoconfig]
IMAP_HOST = mail.example.org
POP3_HOST = mail.example.org
DISPLAY_NAME = Example Mail
DISPLAY_SHORT_NAME = Example
# SMTP_* deliberately omitted: derived from the submission listener.

70.2.1.1.27. The [pepsi-tlsrpt] Section

This optional section configures SMTP TLS Reporting (RFC 8460). It has two independent halves; omit the section to disable both. pepsi-setup(1) reads it for the advertising half; the sender half is read at run time by the relay stages (session recording) and pepsi-tlsrpt(1) (the daily report job).

RUA = URI [, URI …]

Reporting address(es) advertised in the _smtp._tls.<domain> TXT record pepsi-setup(1) emits for each domain, so other senders report their TLS results toward our domains to us. A mailto: or https: URI, or a comma-separated list, stored verbatim. When unset, no record is published.

SEND_REPORTS = yes | no

Whether the relay stages record outbound TLS sessions (in pepsi.tls_session) and the pepsi-tlsrpt(1) report job is active. Optional; defaults to no.

REPORT_FROM = ADDRESS

Envelope sender and From: of the report e-mails pepsi-tlsrpt(1) sends. Required to send reports (its domain is the report submitter). Reports are not null-sender (RFC 8460 §5.3: they must be DKIM/DMARC-alignable); the report-about-a-report loop is prevented by the injected state.tlsrpt flag.

REPORT_STAGE = STAGE

Pipeline stage report e-mails are injected at (so they are signed and relayed), like the pepsi-stage-anti-spam(1) BLOCK_RESPONSE_STAGE. Must reference an existing [stage-*]. Required to e-mail (mailto:) reports.

ORGANIZATION = NAME

organization-name placed in emitted reports. Optional; defaults to the REPORT_FROM domain.

CONTACT = ADDRESS

contact-info placed in emitted reports. Optional.

RETAIN_DAYS = DAYS

How many days pepsi-tlsrpt prune keeps pepsi.tls_session counters. Optional; defaults to 7.

70.2.1.1.28. The [pepsi-telemetry-client] Section

This optional section configures the local feature-telemetry aggregator, pepsi-telemetry-client(1). All options have working defaults; the whole feature is gated by [pepsi] SHARE_TELEMETRY above, which is off unless the operator turned it on — so nothing here has any effect until it is. (The central collector, pepsi-telemetry(1), reads the separate [pepsi-telemetry] section instead.)

UNIXPATH = PATH

Local UNIX socket the daemon listens on for feature events; the same path the producer programs connect to. Optional; defaults to /run/pepsi-telemetry/socket.

UNIXPATH_GROUP = GROUP

Group the socket is chgrp-ed to (mode UNIXPATH_MODE), so the pepsi (stage workers) and pepsi-ingress users can connect. Optional; defaults to pepsi-telemetry.

UNIXPATH_MODE = OCTAL

Permission bits of the socket. Optional; defaults to 660.

SUBMIT_INTERVAL = DURATION

Interval between submissions to the collector (h/m/s units only — see the duration note above). Optional; defaults to 60 s.

MAX_FEATURES = N

Cap on the number of distinct feature names held in memory, bounding memory against a misbehaving local producer. Optional; defaults to 4096.

70.2.1.1.29. Stored Data

For every accepted message, pepsi-ingress(1) inserts one row into the pepsi.ingress table containing a local identifier (ingress_id), a random token, the reception timestamp (received_at), the envelope sender (mail_from; the empty string for the null sender <>), the envelope recipient list (rcpt_to), and the parsed from_header and subject headers. The message itself is stored split into two BYTEA columns — a headers column (the RFC 5322 header block up to but not including the blank-line separator, with the Authentication-Results header prepended; the ARC set is added later by pepsi-stage-arc(1)) and a body column (everything after the blank line; empty for a header-only message). The reconstruction invariant is raw = headers || CRLF || body. Splitting the message lets stages that only touch the headers or envelope load (and rewrite) just the headers column and never pull the (potentially large) body. The computed SPF, DKIM and DMARC verdicts are stored in the row’s state under an auth key, and the authserv_id under state.origin; the arc verdict starts as none and is filled in by the ARC stage. It then issues NOTIFY ingress with the new row identifier as payload.

Each row additionally carries its processing state for the stage pipeline that runs after ingress: a stage (the [stage-<stage>] section of the program currently responsible for advancing the message), a status (one of pending, running, paused, failed or timeout), a state (the JSON object carried with the message, documented in pepsi.state(7)), and a timeout (when a paused message should be re-queued). Every new message is inserted explicitly at the initial stage — stage = init, status = pending — so it enters the pipeline at [stage-init]. These columns are inspected and repaired with pepsi-queue(1).

The schema holds one other table, pepsi.dns_address: a cache of resolved A/AAAA addresses for recipient mail-exchanger hosts, with per-address connect health, maintained by pepsi-stage-relay-to-internet(1). Each row records a host, one resolved address, the DNS-TTL expires_at, a failed flag and the last_success_at time, so a working address is preferred until its TTL elapses and a host is re-resolved once all its cached addresses have failed or expired. It carries no message data and is not managed by pepsi-queue(1).

The state column is the JSON object carried with the message from ingress to its terminal stage; it is the channel through which stages communicate. Ingress seeds it with the connection’s SMTP-origin provenance, the inbound SPF/DKIM/DMARC verdicts and any RFC 3461 DSN parameters; later stages merge in their own verdicts and scratch. Its full layout — every key, who writes it and who consumes it — is documented in pepsi.state(7).

70.2.1.1.30. Example

A complete forwarder: receive on the three canonical SMTP ports, ARC-seal on arrival, SRS-rewrite, deliver direct-to-MX, and bounce failures (signing the bounce before delivery):

[pepsi]
KEY_DIR = /var/pepsi/keys
DKIM_SELECTOR = pepsi
ARC_DOMAIN = example.org
ARC_ALGORITHM = rsa

[pepsi-postgres]
CONFIG = postgres:///pepsi
# SQL_DIR defaults to ${DATADIR}/sql; set it when running from a checkout.

[pepsi-ingress]
HOSTNAME = mail.example.org
ACCEPTED_DOMAINS = example.org example.com
MAX_MESSAGE_SIZE = 26214400

# The three canonical SMTP listeners. TLS_CERT/TLS_KEY are omitted so
# pepsi-setup auto-fills the certbot path and obtains the certificate.

# Port 25: cleartext with opportunistic STARTTLS.
[pepsi-ingress-listener-mx]
SERVE = tcp
BIND_TO = 0.0.0.0
PORT = 25
MODE = starttls
MYNETWORKS = 127.0.0.0/8 ::1/128 10.0.0.0/8

# Port 465: implicit TLS (SMTPS), authenticated submission via Dovecot SASL.
[pepsi-ingress-listener-submissions]
SERVE = tcp
BIND_TO = 0.0.0.0
PORT = 465
MODE = tls
SUBMISSION = yes
SASL_TYPE = dovecot
SASL_PATH = /run/dovecot/auth-client-pepsi

# Port 587: cleartext submission upgraded with STARTTLS.
[pepsi-ingress-listener-submission]
SERVE = tcp
BIND_TO = 0.0.0.0
PORT = 587
MODE = starttls
SUBMISSION = yes
SASL_TYPE = dovecot
SASL_PATH = /run/dovecot/auth-client-pepsi

[pepsi-srs]
SRS_DOMAIN = srs.example.org
SECRET_FILE = /etc/pepsi/srs.secret

# init: ARC-seal the message as received, then hand to SRS.
[stage-init]
PROGRAM = pepsi-stage-arc
NEXT_STAGE = srs

[stage-srs]
PROGRAM = pepsi-stage-srs
NEXT_STAGE = deliver

# deliver: direct-to-MX; failures go to the bounce stage.
[stage-deliver]
PROGRAM = pepsi-stage-relay-to-internet
SERVER_NAME = mail.example.org
POSTMASTER = postmaster@example.org
BOUNCE_STAGE = bounce
# This host's public sending IP(s); pepsi-setup unions PUBLIC_IP from every
# relay stage into the SPF record.
PUBLIC_IP = 203.0.113.7 2001:db8::25

# bounce: build an unsigned DSN, sign it, then deliver it.
[stage-bounce]
PROGRAM = pepsi-stage-bounce
SERVER_NAME = mail.example.org
POSTMASTER = postmaster@example.org
NEXT_STAGE = bounce-sign
BOUNCE_MESSAGE = default

[stage-bounce-sign]
PROGRAM = pepsi-stage-dkim-sign
NEXT_STAGE = deliver

To relay via a smarthost instead, replace the deliver stage’s PROGRAM with pepsi-stage-relay-to-smarthost and add [pepsi-stage-relay-to-smarthost-mta-*] sections, e.g.:

[pepsi-stage-relay-to-smarthost-mta-smarthost]
HOST = smtp.relay.example.net
PORT = 587
CATCH_ALL = yes

70.2.1.1.31. Files

Every Pepsi component reads the same file. When a component is started without –config, the first existing path from the following list is used:

  • $XDG_CONFIG_HOME/pepsi.conf

  • $HOME/.config/pepsi.conf

  • /etc/pepsi/pepsi.conf

  • /etc/pepsi/pepsi.conf

make install places the default at /etc/pepsi/pepsi.conf and a reference pepsi.conf.sample beside it.

Before that file is read, every *.conf in ${DATADIR}/config.d is parsed, so packaged defaults are in place and pepsi.conf overrides them option by option (a section defined in both is merged, not replaced). Two ship today:

  • vacation.conf — the per-language vacation notices; see The [pepsi-vacation-default-message] Section above.

  • thunderbird.conf[pepsi] CRYPTO_ALLOW_DOWNGRADE = yes, so S/MIME goes out as AES-256-CBC EnvelopedData, which Thunderbird can read and our compiled-in AES-256-GCM default cannot be read as. The file explains itself at length and is meant to be deletable: removing it restores authenticated encryption with no other edit.

These files belong to the package: redefine the options you want in pepsi.conf rather than editing them, or an upgrade will discard the change.

70.2.1.1.32. See Also

pepsi-ingress(1), pepsi-dispatch(1), pepsi-httpd(1), pepsi-setup(1), pepsi-config(1), pepsi-queue(1), pepsi-whitelist(1), pepsi-settings(1), pepsi-tlsrpt(1), pepsi-failure-bouncer(1), pepsi-stage-arc(1), pepsi-stage-srs(1), pepsi-stage-encrypt(1), pepsi-stage-dkim-sign(1), pepsi-stage-relay-to-internet(1), pepsi-stage-relay-to-smarthost(1), pepsi-stage-bounce(1), pepsi-stage-discard(1), pepsi-stage-anti-spam(1), pepsi-stage-check-whitelist(1), pepsi-stage-auto-whitelist(1), pepsi-stage-detect-language(1), pepsi-stage-block-language(1), pepsi-stage-if(1), pepsi-stage-milter(1), pepsi-stage-aliases(1), pepsi-stage-vacation(1), pepsi-stage-edit-settings(1), pepsi.state(7), systemd.socket(5)

70.2.1.1.33. Bugs

Report bugs to the Pepsi issue tracker.