85.1.1. pepsi-ingress

receive incoming e-mail over SMTP and store it for Pepsi

Manual section:

1

85.1.1.1.1. Name

pepsi-ingress - SMTP server that stores incoming e-mail in PostgreSQL.

85.1.1.1.2. Synopsis

pepsi-ingress [GLOBAL-OPTIONS] serve

85.1.1.1.3. Description

pepsi-ingress is the mail-receiving component of Pepsi. It runs an SMTP server (a Message Transfer Agent) that accepts incoming e-mail over cleartext, implicit TLS and STARTTLS. A message body may be sent with DATA or, for clients that announce it, with CHUNKING/BDAT (RFC 3030) — advertised in the EHLO response. It stores each accepted message — together with its envelope, a small amount of parsed metadata, and the SMTP-origin metadata of the delivering connection (client IP, HELO/EHLO name, TLS, listener, reverse-DNS/iprev; see pepsi.conf(5)) — in the workqueue table of the pepsi schema of a PostgreSQL database. The message is stored split into a headers column (everything up to the blank line) and a body, so that downstream stages which only touch the headers need not load the body. The body is a row of its own table, workqueue_body, which the message row references: every recipient row the pipeline later splits off the message shares that one copy (see pepsi.conf(5), Stored Data). The message enters the stage pipeline at the initial stage (stage = init). Once the row has been committed, a NOTIFY on the workqueue channel wakes pepsi-dispatch(1). That notification is issued out of band and coalesced — at most one per DISPATCH_WAKE_INTERVAL (default 5 ms), so a burst of admissions wakes the dispatcher once — because a transaction carrying a notification cannot group-commit, which would serialise every concurrent SMTP session behind its own fsync. Nothing is at risk if one is lost: the dispatcher’s POLL_INTERVAL sweep is the safety net, so the cost is latency.

The server accepts mail for a configured set of domains. Within those domains it accepts every local-part without trying to verify that the mailbox exists; recipients outside the configured domains are rejected as relaying (550). The domainless reserved mailbox <Postmaster> is always accepted regardless of the configured domains (RFC 5321 §4.5.1) and rewritten to a routable address — [pepsi-ingress] POSTMASTER when set, otherwise postmaster@HOSTNAME — so the rest of the pipeline (aliases, local delivery, relay) routes it like any ordinary recipient. To make postmaster mail reach a person, either point POSTMASTER at a real mailbox or add an alias for the rewritten address in a pepsi-stage-aliases ALIASES map. Such a message is also committed with state.spam = false so that the language and pay-to-send gates (pepsi-stage-block-language(1), pepsi-stage-anti-spam(1)) forward it, keeping the postmaster mailbox reachable as RFC 5321 §4.5.1 requires — but only when ``<Postmaster>`` was the transaction’s sole recipient. A message addressed to the postmaster and an ordinary mailbox is committed without the bypass and passes the gates like any other, because otherwise a spammer could exempt a real victim’s mail simply by adding a <Postmaster> RCPT beside it. When SRS is configured (the shared [pepsi-srs] section; see pepsi-stage-srs(1)), a recipient in the SRS domain whose local-part is a valid Sender Rewriting Scheme token is reverse- decoded to the original sender and accepted for relay there — even though that sender’s domain is not one we serve, because the valid signature authorises it; a forged or expired token is rejected (550), without saying which of the two it was. Because accepting such a recipient authorises a relay, that accept/reject pair is an online oracle over the SRS signature; a connection that presents three invalid SRS recipients is therefore logged at warn and closed with 421, so a search for a valid token costs one connection per three guesses and is visible to abuse monitoring. This is how bounces to mail Pepsi forwarded find their way back to the original senders. Receipt of a message is confirmed to the client (250) only after the message has been durably committed to the database; if the database is unavailable the client receives a transient error (451) and is expected to retry.

As required by RFC 5321 §4.4, pepsi-ingress prepends a Received: trace header to every accepted message, naming the connecting client (HELO/EHLO name, reverse-DNS and IP), this server, the transport used (SMTP, ESMTP, ESMTPS, ESMTPA or ESMTPSA per RFC 3848 — see Authentication below) and the time of receipt; a for clause names the recipient only for a single-recipient transaction. The header is added before authentication so that the ARC seal (added later by pepsi-stage-arc(1)) covers it.

For an encrypted session the ESMTPS keyword on its own does not record which TLS version and cipher were negotiated. As recommended by RFC 8314 §4.3, pepsi-ingress therefore appends the negotiated parameters as a comment after the with keyword, e.g.:

by mail.example.org (pepsi-ingress) with ESMTPS
    (version=TLSv1_3 cipher=TLS13_AES_128_GCM_SHA256)

These values are the same TLS parameters recorded in the message state under origin.tls (see pepsi.state(7)); whichever of the version/cipher the TLS stack reports is included.

An X-Pepsi-List-Hops: header (the mailing-list loop counter) is removed from every message arriving on an unauthenticated session, since a sender could otherwise reset the counter; an authenticated session’s copy is kept.

Multiple SMTP streams are processed concurrently. Several listeners can be configured, each bound to its own TCP port, UNIX-domain socket, or a socket inherited from a systemd parent through socket activation, and each with its own transport mode. All behaviour is driven by an INI-style configuration file (see pepsi.conf(5)).

The database schema is installed by pepsi-setup(1), which applies the migrations of every component to the shared pepsi schema; pepsi-ingress itself has no schema-initialization command.

85.1.1.1.3.1. SMTP extensions

The EHLO response advertises exactly these keywords, in this order:

SIZE <MAX_MESSAGE_SIZE>
8BITMIME
SMTPUTF8
PIPELINING
CHUNKING
DSN
ENHANCEDSTATUSCODES

SIZE (RFC 1870) announces [pepsi-ingress] MAX_MESSAGE_SIZE, which is a server-wide setting rather than a per-listener one. Two more keywords are conditional: STARTTLS (RFC 3207) is advertised on a listener that has TLS material while the session is still cleartext — that is, MODE = starttls, since MODE = tls is encrypted from the first byte — and AUTH PLAIN LOGIN (RFC 4954) only on an already-encrypted session whose listener configures a SASL_TYPE backend.

Nothing else is offered. BINARYMIME in particular is neither advertised nor accepted (see 8-bit and internationalised content); VRFY is answered with the non-committal 252 and EXPN is declined with 502, since RFC 5321 §7.3 makes disclosing list membership an information leak rather than a service.

85.1.1.1.3.2. Authentication

Before a message is stored, pepsi-ingress authenticates it, so that receivers downstream — after Pepsi has relayed the mail and broken the original SPF and DKIM alignment — can still see the verdict Pepsi reached at the boundary. For each accepted message it:

  1. verifies SPF (on the MAIL FROM identity, using the connecting client’s IP), DKIM and DMARC; and

  2. prepends an Authentication-Results header reporting these results, with the [pepsi-ingress] HOSTNAME as the authserv-id, and records the rendered result text under state.origin.ar_results.

ARC is handled separately: ingress neither verifies the ARC chain nor seals the message. The pepsi-stage-arc(1) pipeline stage verifies any inbound ARC chain and adds the fresh ARC set (AAR/AMS/AS) as the [pepsi] ARC_DOMAIN identity, reading the session context (authserv-id, client IP, HELO/EHLO, and the ar_results fragment) ingress recorded in the row’s state. Reusing that fragment lets the stage build the AAR without re-running SPF/DKIM/DMARC, so the authentication checks happen exactly once, and it keeps the ARC signing keys out of the SMTP front end.

The verification is fail-open: a DNS failure or unparseable message never rejects the mail; it is recorded as temperror/none and accepted. Only when DMARC_ENFORCE is set does a definite DMARC failure under a quarantine/reject policy cause a 550 rejection; by default such mail is accepted (and the ARC stage seals the failing result) for the final receiver to judge. The SPF/DKIM/DMARC verdicts are stored in the row’s state.auth object (as spf/dkim/dmarc/arc), and the authserv-id under state.origin; the arc verdict starts as none and is filled in later by the ARC stage. The DNS lookups happen synchronously in the DATA phase, so a slow resolver delays the 250.

The work is bounded, because the number of signatures is the sender’s choice and each costs a DNS lookup and a public-key verification. At most MAX_DKIM_SIGNATURES (default 10) DKIM-Signature fields are verified, those whose d= is the From: domain or a parent or child of it first — the only ones DMARC can use — so padding a message with signatures cannot push the one that matters out. SPF and the selected signatures are checked concurrently under one deadline of VERIFY_TIMEOUT (default 20 s), and DMARC under a second one, so a message never holds its session in verification for more than twice that. A check that misses its deadline is recorded as temperror for that check alone: a timed-out signature whose domain does not align with the From: domain is one DMARC ignores, so a forger cannot slow their own DNS down to turn a DMARC failure into something DMARC_ENFORCE does not act on.

85.1.1.1.3.3. Delivery Status Notifications (DSN)

pepsi-ingress advertises the DSN extension (RFC 3461) in its EHLO response and accepts its parameters: RET and ENVID on MAIL FROM and NOTIFY and ORCPT on RCPT TO. Their syntax is validated — an invalid RET/NOTIFY value, a NOTIFY=NEVER combined with other keywords, or a malformed ENVID/ORCPT xtext is rejected with 501 — and the accepted values are stored in the row’s state.dsn (message-level ret/envid plus a per-recipient array of notify/orcpt). The stage pipeline honours them: the relay stages propagate the parameters to the next hop when it also advertises DSN, and pepsi-stage-bounce(1) generates a failure bounce only when the recipient’s NOTIFY requests it (FAILURE, the default when absent), echoing ENVID/ORCPT into the DSN. Every stage must preserve state.dsn.

85.1.1.1.3.4. 8-bit and internationalised content

pepsi-ingress advertises 8BITMIME (RFC 6152) and SMTPUTF8 (RFC 6531) in its EHLO response, so a message may carry raw 8-bit octets in its body and UTF-8 in its headers and envelope. The BODY= parameter on MAIL FROM is accepted and recorded (state.origin.body_8bit); only 7BIT and 8BITMIME are valid — BINARYMIME is not advertised and is rejected with 501. The relay stages re-advertise BODY=8BITMIME / SMTPUTF8 to a next hop that supports them, and downgrade the message when it does not: an 8-bit body towards a non-8BITMIME hop is MIME-re-encoded to a 7-bit content-transfer-encoding, and UTF-8 headers towards a non-SMTPUTF8 hop are rewritten as RFC 2047 encoded-words, or RFC 2231 for a MIME parameter such as an attachment’s filename (a non-ASCII address there cannot be downgraded and is bounced). A message whose body cannot be converted at all — malformed past what the re-encoder can repair — is bounced rather than sent 8-bit, as RFC 6152 §3 requires. See pepsi-stage-relay-to-internet(1).

85.1.1.1.3.5. ESMTP parameter validation

pepsi-ingress validates the MAIL FROM/RCPT TO parameters strictly. The recognised parameters are SIZE, BODY and SMTPUTF8 (and AUTH) on MAIL FROM and the DSN parameters above; a SIZE= value that is not a non-negative integer is rejected with 501 (RFC 1870 §6.1) rather than silently dropped. A parameter the server does not implement is rejected with 555 5.5.4 (RFC 5321 §4.1.1.11) — it is not silently ignored. The one exception is the RFC 4954 AUTH= parameter on MAIL FROM (which an authenticated client may attach because AUTH is advertised): it is accepted and ignored — Pepsi does not propagate the asserted identity onward — rather than rejected.

85.1.1.1.3.6. Client authentication

Each listener can authenticate its clients, marking accepted mail as locally originated. A session is authenticated when any one of four per-listener mechanisms accepts it:

  • MYNETWORKS — the connecting IP is within one of a whitespace/comma separated list of trusted IPv4/IPv6 CIDR networks (a bare address is taken as a /32 or /128 host).

  • SASL — a SASL_TYPE backend (currently dovecot, via the SASL_PATH auth-client socket) accepts an AUTH exchange. AUTH (mechanisms PLAIN and LOGIN) is advertised and accepted only on a TLS-protected session (implicit TLS or after STARTTLS); a cleartext AUTH attempt is refused with 504 5.5.4 (RFC 4954 §4, which deprecates the 538 reply for this).

  • TLS client certificate — the client presents a certificate whose SubjectPublicKeyInfo SHA-256 (64 hex chars) is listed in TLS_AUTH_CLIENT, or whose chain signature-validates up to a listed CA key.

  • AUTH_PEERCRED — on a UNIX-domain socket, the kernel-attested uid of the peer (SO_PEERCRED) is resolved to a login and fed to the USERNAME_MAP machinery, so a caller can only send as itself. Default ``yes`` for a ``SERVE = unix`` listener (set AUTH_PEERCRED = no to turn it off); refused outright on a TCP listener, where a peer has no kernel-attested identity. This is what authenticates the local submission socket pepsi-sendmail(1) uses, and it sets the RFC 3848 ESMTPA keyword. For a SERVE = systemd listener it is accepted but never inferred, since the family lives in the unit; a warning is logged if the inherited descriptor turns out to be TCP.

SASL_TYPE and TLS_AUTH_CLIENT require a TLS listener (MODE = tls or starttls). An authenticated session is recorded with state.local_origin = true (otherwise false) and is permitted to relay to any domain, not only the served domains. A session that carries an identity — SASL AUTH, or AUTH_PEERCRED on a UNIX socket, but not MYNETWORKS or a client certificate — also sets the RFC 3848 A in the trace header’s with keyword: ESMTPSA over TLS, and plain ESMTPA for the cleartext UNIX submission socket, which is where locally submitted mail (pepsi-sendmail(1), cron) comes from. Filter on both, not only ESMTPSA. An unrecognised client certificate never aborts the handshake — it simply leaves the session unauthenticated.

85.1.1.1.3.7. Message submission (RFC 6409)

A listener carrying SUBMISSION = yes is treated as a Message Submission Agent (RFC 6409) rather than an MX:

  • Authentication is mandatory. An unauthenticated MAIL FROM is refused with 530 5.7.0 Authentication required; the client must first satisfy one of the Client authentication mechanisms above. The flag therefore requires at least one configured mechanism (SASL_TYPE, TLS_AUTH_CLIENT, MYNETWORKS or AUTH_PEERCRED); a submission listener with none is a configuration error, refused when the file is parsed — so by pepsi-setup(1) and by the server itself at start-up. It also requires a TLS-protected MODE (tls or starttls) — except on a local socket, i.e. SERVE = unix or a SERVE = systemd listener that inherits one, where there is no network to protect. The shipped local submission socket is exactly that: MODE = plain with SUBMISSION = yes and AUTH_PEERCRED. Deferring the rule is not dropping it — a plaintext submission listener that turns out at bind time to have inherited a network socket makes pepsi-ingress refuse to start.

  • Submission fixups are applied to every accepted message: a missing Date: header is added (§8.2) and a missing Message-ID: is generated as <random@HOSTNAME> (§8.3). Headers that are already present are left untouched, and the fixups run before the Received: trace header is prepended, so the added fields are covered by the downstream ARC seal and DKIM signature.

The flag has no effect on an MX listener; leave it off (the default) for port 25.

85.1.1.1.3.8. Submission identity (RFC 6409 §6 / §8.1)

A session that carries a username is bound to the set of e-mail addresses that username is allowed to use. Two of the four mechanisms above carry one: a SASL AUTH exchange (the authcid), and AUTH_PEERCRED on a UNIX socket (the login the kernel-reported uid resolves to). Both are subject to everything in this section; MYNETWORKS and a TLS client certificate are not, because they authenticate an address or a key rather than an account.

By default the username alice is mapped to the single address alice@HOSTNAME (the [pepsi-ingress] HOSTNAME domain); the optional USERNAME_MAP file overrides this mapping. Each non-blank, non-comment line is:

username: addr1@example.org addr2@example.org

a # begins a comment, the key is the SASL username (before the first :), and the value is the whitespace/comma separated list of allowed addresses, which may be empty. Usernames are matched case-insensitively and addresses are lower-cased. The file is re-read whenever its modification time changes (checked on each authentication), so edits take effect without a restart; a missing or unreadable file is treated as empty (every user falls back to the default).

An allowed address may use a * wildcard (a *-glob matched against the candidate address), so alice: *@example.org lets alice use any address at example.org and a bare * permits any address at all. A wildcard entry names no single canonical identity, so it is skipped for the Sender: fixup below (the first concrete, wildcard-free address is the canonical identity, if any). pepsi-setup syntax-checks a configured USERNAME_MAP at install time (see pepsi-setup(1)). The wildcard is a glob, not a regular expression: write *@example.org, never .*@example.org (a literal dot followed by a glob — it matches nothing, so the user would be denied every address it was meant to grant). Setup rejects such a pattern and names the glob you meant.

The mapping has three outcomes for a username:

  • listed with addresses — exactly those addresses (and wildcard patterns) are allowed; the first concrete (wildcard-free) one is the canonical identity used for the Sender: fixup;

  • listed with no addresses — the user is denied: even though SASL accepted the credentials, the AUTH command is refused with 535 5.7.8 and the session stays unauthenticated. On a peer-credential session there is no command to refuse, so the session simply never authenticates — and on a submission listener its first MAIL FROM is then met with 530 5.7.0. This is how a system account (www-data, say) is excluded from submitting at all;

  • not listed (or no USERNAME_MAP configured) — the default username@HOSTNAME.

Given that set, two checks run on such a submission:

  • Envelope and header identity (§6). The MAIL FROM envelope sender and the message From: address must each be one of the allowed addresses, otherwise the message is refused with 550 5.7.1 (at MAIL FROM and at end-of-DATA respectively). The null sender <> and a message with no parseable From: address are exempt. RFC 5322 §3.6.2 makes From: a mailbox list, so a value may legally name several mailboxes — and every one of them must be an allowed address, not merely one. Checking a single mailbox would be no check at all, because mail readers display the first while a permitted one could sit anywhere in the list.

  • Sender fixup (§8.1). The Sender: field is the MSA’s, not the client’s: an address a client puts there is subject to no identity check on any path, and Pepsi would go on to DKIM-sign it as a claim about who sent the message. So whenever the account has a canonical identity, an existing Sender: is replaced or removed, which §8.1 explicitly permits (“The MSA MAY add or replace the ‘Sender’ field”).

    When the From: address is an allowed address that differs from the canonical identity, or names more than one mailbox, a Sender: header naming the canonical identity is prepended — for the multi-mailbox case RFC 5322 §3.6.2 requires a Sender: to be present, and §8.1 is what lets the MSA supply it. When From: is a single mailbox naming the canonical identity, a Sender: is redundant with it (§3.6.2: “SHOULD NOT be used if it is redundant with the ‘From:’ field”) and is removed instead.

    A user granted only wildcard patterns has no single canonical identity to assert, so neither applies and an existing Sender: is left alone — there is nothing it could be proved wrong about. Like the other fixups this happens before the trace header, so the result is covered by the ARC seal and DKIM signature.

These checks apply to every session that carries a username, whether it came from SASL AUTH or from AUTH_PEERCRED. A MYNETWORKS or TLS-client-certificate session has none to map and is not subject to them.

Beside the section 6 check, ingress records how the From: was allowed, as state.origin.from_bound: true exactly when From: names a single mailbox that matched a concrete (wildcard-free) entry of the account’s allowed addresses, or the default username@HOSTNAME; false otherwise, and always false for a session with no username. Being permitted to send as an address is enough to send; being bound to it is what lets a submission speak for it. pepsi-stage-encrypt(1) registers the key a user’s mail client advertises, or runs an e-mail key command, only for a bound From: (or a MYNETWORKS/client-certificate relay): a *@example.org grant lets one account send as its colleagues, and must not let it plant a key for them. pepsi-setup(1) warns about accounts that hold wildcard grants.

85.1.1.1.3.9. Local submission socket

One listener is never configured: the local submission socket that pepsi-sendmail(1) — and so /usr/sbin/sendmail, cron, and anything else that posts mail locally — connects to. pepsi-ingress serves it unconditionally, because providing that path is a property of being a mail server rather than a feature to switch on. It is not an optional listener section, because that would make the facility depend on three separate things agreeing (the section existing, its UNIXPATH matching the client’s socket, and the runtime directory being writable), each easy to get wrong with the same silent symptom: local mail disappears.

The path is [pepsi-sendmail] SOCKET (default /run/pepsi/submission.sock), read from that one option by both ends, so the client and the server cannot name different sockets. Everything else is fixed, because none of it is a choice: the listener is called local-submission in the log, and is MODE = plain with SUBMISSION = yes and AUTH_PEERCRED. When it is bound here rather than inherited from pepsi-ingress.socket it is created mode 0666; the permissive mode grants nothing, because opening the socket conveys no authority — the kernel supplies the caller’s uid and USERNAME_MAP decides what it may send as.

An explicit [pepsi-ingress-listener-*] section that serves the same path wins and nothing is synthesised beside it, so an operator who wants different options there (a stricter mode, a UNIXPATH_GROUP, no peer credentials) writes one. Failing to bind it is fatal, as for every other listener: a server up with only some of what it was asked to serve is the failure this arrangement exists to remove. The one way to switch it off is SOCKET = none, which also makes pepsi-sendmail(1) refuse to run; with that sentinel set and no listener sections at all, the server would listen on nothing and refuses to start.

85.1.1.1.3.10. Mailbox quota at RCPT

When a recipient is on one of the served domains, pepsi-ingress may refuse it in the SMTP session because the mailbox is known to be full — 452 4.2.2 or 552 5.2.2 according to [pepsi] MAILBOX_OVER_QUOTA. Refusing here rather than accepting and bouncing later keeps the sending MTA on the line, so it tells its user, and Pepsi generates no backscatter to an envelope sender that may well be forged.

Four properties make that safe to do from a database row.

No resolution. The recipient’s local part — lower-cased and stripped of any sub-address at [pepsi] RECIPIENT_DELIMITER — is compared to a passwd login exactly. Aliases, ~/.forward files and the passwd database itself belong to the delivery stage; pepsi-ingress guesses at none of them, so an address that is not spelled like a login simply matches nothing and is accepted.

No measurement. pepsi-ingress cannot read a 0700 Maildir, and must never gain the pepsi-maildir group that would let it run the helper that can. It reads figures somebody else wrote, and holds only SELECT on the table.

Only a fresh measurement counts. The running usage estimate is deliberately not consulted: it over-counts, and refusing on it would shut a mailbox whose owner has since emptied it — permanently, since with every message refused no delivery would ever run to re-measure it. A measurement older than [pepsi] MAILBOX_QUOTA_MAX_AGE accepts the message and lets the delivery path take a fresh look; pepsi-quota(1) reconcile keeps at-limit accounts measured.

Every uncertainty accepts. No row, no measurement, a database error — all mean accept, in keeping with the rest of inbound processing.

The domainless <Postmaster> mailbox is accepted regardless, because RFC 5321 §4.5.1 requires it to stay reachable.

Set [pepsi] MAILBOX_QUOTA_RCPT_CHECK = no to skip the lookup entirely and leave all enforcement to delivery time.

85.1.1.1.3.11. Unknown recipients at RCPT

A recipient on a served domain that nothing on this host would accept is refused in the SMTP session with 550 5.1.1, for the same reason as a full mailbox: the sending MTA tells its own user, instead of Pepsi bouncing the message later to an envelope sender that spam forges (backscatter, the quickest way onto a blocklist). [pepsi-ingress] VERIFY_RECIPIENTS (default auto) controls it.

pepsi-ingress does not run the pipeline to find out. It asks whether anything that can make an address real vouches for it:

  • a local account, resolved exactly as the Maildir and ~/.forward stages resolve it;

  • an alias, matched by the aliases stage’s own code;

  • a mailing list;

  • postmaster@, abuse@ and the control address of any stage that answers one;

  • the MDA behind an LMTP stage, or the backend behind a gateway route, asked with MAIL FROM:<> and RCPT TO and no message.

Which of these apply to which served domain is derived from the [stage-*] sections at start-up; pepsi-setup run prints the result. A domain is refused-when-unknown only when every way its mail can go has something that can answer. A catch-all alias, a stage running a program Pepsi does not know, or a backend nobody may ask leaves the domain open, as before.

Every uncertainty accepts: an alias map or recipient list that cannot be read, a database error, a probe that fails, times out or is refused for any reason but “no such mailbox”, or more than 16 probes in flight. An address is refused only when every source that applies has said no. The pipeline’s own handling of unknown mailboxes stays behind this, for the cases it lets through.

A “yes” from a probe is cached for an hour and a “no” for five minutes, so a new mailbox is reachable within minutes. After MAX_INVALID_RECIPIENTS (default 10) unknown recipients a session is closed with 421. A dictionary attack then costs connections, which the connection limits meter, rather than a lookup per guess.

VRFY still answers 252 for every address: RCPT already tells a sender whether an address exists, as it does at every MTA, and a second, cheaper way to ask would only serve address harvesting.

85.1.1.1.3.12. Queue admission control

pepsi-ingress stops accepting mail while the queue, or the disk under it, is at a limit set in [pepsi] (see pepsi.conf(5)):

  • MAX_QUEUE_ROWS — queued messages (default one million recipient rows);

  • MAX_QUEUE_BYTES — queued header blocks plus distinct bodies (default unlimited);

  • MIN_FREE_SPACE — free space on the filesystem of FREE_SPACE_PATH (default 1 GiB on /var/lib/postgresql).

The answer is 452 4.3.1 Insufficient system storage, a temporary refusal, given at RCPT, at DATA (or the first BDAT chunk, whose octets are still read so the session stays framed) and once more after the end of data, when the message’s own size is known and a single message that would cross MAX_QUEUE_BYTES or MIN_FREE_SPACE is refused on its own. The sending MTA keeps the message and retries, while the queue that is already there drains; the throttle lifts by itself. Local submission is treated alike, and pepsi-sendmail(1) turns the 452 into EX_TEMPFAIL, so cron retries.

The check is cheap: the queue is measured (the pepsi.queue_usage() function, two sequential scans) at most once per QUEUE_CHECK_INTERVAL (default 10 s) for the whole server, and every command in between is answered from that figure. pepsi-ingress needs no access to queued mail for it — only the generated size columns header_octets and workqueue_body.octets. Every uncertainty admits: a failed measurement, or a probe path that does not exist (a database on another host), leaves mail flowing.

85.1.1.1.3.13. Overload protection

Several mechanisms keep a flood of connections — including clients that complete the TCP handshake and then go silent without ever closing — from exhausting the server. They operate on a source range: a single IPv4 address (a /32), or the /32 prefix of an IPv6 address. Local (UNIX-socket) connections form one range of their own, which is exempt from the rate limit and from eviction — a local submitter is a user of this machine, and pepsi-sendmail must not be turned away because a remote host is busy — and is bounded by its own ceiling instead.

  • Per-command timeouts (RFC 5321 §4.5.3.2). A session that does not send its next command within COMMAND_TIMEOUT (default 300 s), or the next block of a DATA/BDAT body within DATA_TIMEOUT (default 180 s), is closed with a 421. The same COMMAND_TIMEOUT bounds a stalled STARTTLS (or implicit-TLS) handshake.

  • Connection ceiling with eviction. The server admits at most min(MAX_CONNECTIONS, MAX_OPEN_SOCKETS) concurrent connections. MAX_OPEN_SOCKETS guards against file-descriptor exhaustion; when unset it is derived from the soft RLIMIT_NOFILE minus RESERVED_SOCKETS (default 64, held back for the database pool, listeners, stdio and DNS sockets). When the ceiling is reached and a new connection arrives, the server makes room by closing the longest-idle connection in the range whose total idle time is largest (a 421, then close), so the greediest source loses its most-idle slot first. “Idle” is time a connection spends blocked awaiting client input between commands — not time spent receiving a body or processing.

  • Per-range ceiling. One source range may hold at most MAX_CONNECTIONS_PER_IP (default 16) connections at once, and all local connections together at most MAX_LOCAL_CONNECTIONS (default 32). The rate limit below does not bound concurrency on its own — one connection a second, each kept busy, fills any ceiling in minutes, and eviction reclaims only idle connections — and without the local ceiling one local user could hold every slot and starve remote SMTP. A connection over either is refused like a rate-limited one. 0 disables either ceiling.

In addition, accepted new connections are rate-limited per source range by a token bucket: CONN_RATE_PER_SECOND (default 1) refill rate with a CONN_RATE_BURST (default 1) burst. A connection over the rate gets a best-effort 421 and is then closed, holding a slot for the duration of that write — deliberately, so a flood of rate-limited connections cannot spawn unbounded reject tasks that each pin a descriptor outside MAX_OPEN_SOCKETS. When the rate limit trips while the descriptor ceiling is already reached, no slot is taken and the connection is closed with no reply at all, since the 421 write would itself spend the descriptor being conserved. (On an implicit-TLS listener the refusing 421 is likewise omitted, since no plaintext may precede the handshake; the connection is simply closed.)

Within and across sessions:

  • Messages per session. A session may start at most MAX_MESSAGES_PER_SESSION (default 100) mail transactions (RSET does not reset the count); the next MAIL is answered 421 and the connection closed, so a sender with more to deliver reconnects — and the rate limit meters that.

  • Routing loops. A message that has already passed through this host’s ingress MAX_SELF_HOPS (default 5) times is refused at the end of data with 554 5.4.6. The count comes from the Received: fields ingress itself writes. The refusal turns a loop — an address on our own domain relayed to our own MX, again and again — into one bounce by the stage that tried to relay it.

  • Password guessing. Three failed AUTH attempts close a session. Across sessions, a source range may fail AUTH at most AUTH_FAILURES_PER_HOUR (default 20) times in a token bucket refilling at that rate; once it is spent, every further AUTH from the range is answered 421 and the connection closed without the SASL backend being asked, until the bucket refills.

  • Local submission rate. A UNIX-socket session authenticated by AUTH_PEERCRED is limited per uid, across its sessions, to LOCAL_MESSAGES_PER_MINUTE (default 60) messages with a burst of LOCAL_MESSAGE_BURST (default 120). A MAIL over the rate is answered 451 4.7.1, which pepsi-sendmail(1) turns into EX_TEMPFAIL.

0 disables any of these three. The cross-session counters live in the server’s memory, so a restart forgets them.

85.1.1.1.3.14. Privilege dropping

The SMTP ports (25/465/587) are privileged, so pepsi-ingress is often started as root. It does not, however, serve as root: once every configured listener has been bound (and any TLS key material read), the process drops to the unprivileged service account pepsi-ingress, dropping all supplementary groups and switching to that account’s group and user id before accepting a single connection. The account must therefore exist before the server is started as root; create it (for example useradd --system --no-create-home --shell /usr/sbin/nologin pepsi-ingress) as part of installation.

If the drop cannot be completed while running as root — most often because the pepsi-ingress account does not exist — the server logs an error and exits without serving, rather than risk running as root. When started by a non-root user no privilege change is performed (the server simply runs as the invoking user); it will never run as root.

85.1.1.1.3.15. TLS key material

The TLS_CERT/TLS_KEY of every MODE = tls/starttls listener is read once, at start-up, before the privilege drop above.

Under systemd the unit runs as the unprivileged pepsi-ingress user from the outset and never holds root, so it cannot open certbot’s root-only /etc/letsencrypt/{live,archive}. pepsi-setup(1) therefore writes a drop-in /etc/systemd/system/pepsi-ingress.service.d/10-tls-credentials.conf with one LoadCredential= entry per file: systemd opens them as root at unit start and hands the service private copies under $CREDENTIALS_DIRECTORY, which pepsi-ingress consults before falling back to reading the configured path itself. Re-run pepsi-setup run after adding, moving or removing a certificate so the drop-in is regenerated. Because credentials are materialised when the unit starts, a renewed certificate is served only after a restart — the certbot deploy hook pepsi-setup installs performs it. Started directly (as root, dropping privileges after binding), the server reads the files itself and no credential is involved. See pepsi-httpd(1), which describes the same mechanism in full.

Unlike pepsi-httpd, which serves many certificates and skips one it cannot load, a listener here has exactly one: if it cannot be read there is nothing to fall back to, and the server exits rather than silently offer no TLS on a port the configuration says is encrypted.

85.1.1.1.4. Commands

serve

Bind every configured [pepsi-ingress-listener-*] socket and serve SMTP connections until interrupted. Requires that the schema has been installed with pepsi-setup(1).

85.1.1.1.5. Global Options

These global options precede the subcommand (a trailing flag is rejected).

-c FILE, –config FILE

Read the configuration from FILE instead of searching the default locations (see FILES).

-L LOGLEVEL, –log LOGLEVEL

Set the logging verbosity. LOGLEVEL is one of error, warn, info, debug or trace (default: info).

-v, –verbose

Show log messages from all sources, including third-party libraries that are filtered out by default.

-h, –help

Print a usage summary and exit.

-V, –version

Print the version and exit.

85.1.1.1.6. Signals

SIGINT

Initiate shutdown: the process stops serving and exits 0. Sessions still in progress are not drained, and need not be — a message is confirmed to its client (250) only once it has been committed, so an interrupted session either has its message safely in the queue or was never told otherwise, and the sending MTA retries.

SIGTERM

Not caught: the default disposition terminates the process, which is safe for the same reason. This is what systemctl stop pepsi-ingress sends.

85.1.1.1.7. Exit Status

0

Successful completion (including a clean shutdown of serve).

1

An error occurred, for example a malformed configuration file, a failed database connection, or a listener that could not be bound. The reason is written to the log.

85.1.1.1.8. Environment

XDG_CONFIG_HOME, HOME

Used to locate the default configuration file when –config is not given (see FILES).

LISTEN_FDS, LISTEN_PID, LISTEN_FDS_FIRST_FD

Honoured for systemd socket activation. A listener configured with SERVE = systemd adopts the file descriptor passed by systemd at the index given by its FD_INDEX option. LISTEN_FDS_FIRST_FD gives the number of the first passed descriptor and defaults to 3 (SD_LISTEN_FDS_START); it is read for the benefit of supervisors other than systemd that set it. LISTEN_FDNAMES is not consulted: a FileDescriptorName= names a whole .socket unit’s descriptors rather than one ListenStream=, so it could not tell these listeners apart. The local submission socket is instead found by its bound address — see below.

The shipped pepsi-ingress.socket passes four: ports 25, 465 and 587, which the listener sections claim by FD_INDEX 0, 1 and 2, and the local submission socket /run/pepsi/submission.sock, which is claimed differently — see below. Binding that UNIX socket in the unit rather than in the server is deliberate: systemd creates it as root before the service starts, so the unprivileged pepsi-ingress never needs write access to /run/pepsi, a runtime directory pepsi-httpd and the setup-apply doorbell also declare and that systemd re-chowns to whichever of them started last.

The local submission socket has no FD_INDEX and no listener section. pepsi-ingress serves it unconditionally (from [pepsi-sendmail] SOCKET) and finds the descriptor by asking the kernel which of the passed sockets is bound to that path. An index would be a position among the unit’s ListenStream= entries and would therefore shift whenever an SMTP port is added or removed; the bound address cannot drift out of step with anything. If no passed descriptor matches, the path is bound by the server instead — which is what happens off systemd, and what makes failing to bind it fatal there.

Every other descriptor passed must be claimed by some listener section. One that is not is reported with a warning at start-up naming its index, because systemd keeps accepting connections on it that nothing will ever answer — a client then blocks until its own read timeout expires (two minutes, for pepsi-sendmail(1)) rather than being refused at once. Both are a deferral, but one of them stalls the caller for every message. If you remove a listener section, remove its ListenStream= from the unit too.

JOURNAL_STREAM

When set (i.e. when running under systemd), timestamps are emitted in UTC without a local time-zone offset.

85.1.1.1.9. Files

When –config is not given, the first existing file from the following list is used:

  • $XDG_CONFIG_HOME/pepsi.conf

  • $HOME/.config/pepsi.conf

  • /etc/pepsi/pepsi.conf

  • /etc/pepsi.conf

Every Pepsi component shares the same configuration file, so this is the same list each one searches.

85.1.1.1.10. Examples

Install the schema (once, with pepsi-setup) and start serving:

pepsi-setup   -c /etc/pepsi/pepsi.conf
pepsi-ingress -c /etc/pepsi/pepsi.conf serve

Check which domains mail is accepted for (with pepsi-config(1)):

pepsi-config -c /etc/pepsi/pepsi.conf get pepsi-ingress ACCEPTED_DOMAINS

Dump the merged configuration with source annotations:

pepsi-config -c /etc/pepsi/pepsi.conf dump --diagnostics

85.1.1.1.11. See Also

pepsi-config(1), pepsi.conf(5), pepsi-setup(1), pepsi-dispatch(1), pepsi-sendmail(1), pepsi.state(7), systemd.socket(5)

85.1.1.1.12. Bugs

Report bugs to the Pepsi issue tracker.