70.1.1. pepsi-ingress¶
receive incoming e-mail over SMTP and store it for Pepsi
- Manual section:
1
70.1.1.1.1. Name¶
pepsi-ingress - SMTP server that stores incoming e-mail in PostgreSQL.
70.1.1.1.2. Synopsis¶
pepsi-ingress [GLOBAL-OPTIONS] serve
70.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 ingress 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
column, so that downstream stages which only touch the headers need not load the
body. The message enters the stage
pipeline at the initial stage (stage = init). After the row has been
committed, a NOTIFY is issued on the ingress channel to wake the
downstream processors.
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. 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). 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 or
ESMTPS per RFC 3848) 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.
70.1.1.1.3.1. Authentication¶
Before a message is stored, pepsi-ingress authenticates it so that downstream — after Pepsi has relayed the mail and broken the original SPF and DKIM alignment — receivers can still see the verdict Pepsi reached at the boundary. For each accepted message it:
verifies SPF (on the
MAIL FROMidentity, using the connecting client’s IP), DKIM and DMARC; andprepends an
Authentication-Resultsheader reporting these results, with the[pepsi-ingress] HOSTNAMEas theauthserv-id, and records the rendered result text understate.origin.ar_results.
ARC is handled separately: ingress no longer verifies the ARC chain or 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. This keeps the ARC signing keys out of
the SMTP front end.
70.1.1.1.3.2. 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.
70.1.1.1.3.3. 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 (a non-ASCII address there cannot be
downgraded and is bounced). See pepsi-stage-relay-to-internet(1).
70.1.1.1.3.4. ESMTP parameter validation¶
pepsi-ingress validates the MAIL FROM/RCPT TO parameters strictly. The
recognised parameters are SIZE and BODY (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.
70.1.1.1.3.5. Client authentication¶
Each listener can authenticate its clients, marking accepted mail as locally originated. A session is authenticated when any one of three 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
/32or/128host).SASL — a
SASL_TYPEbackend (currentlydovecot, via theSASL_PATHauth-client socket) accepts anAUTHexchange.AUTH(mechanismsPLAINandLOGIN) is advertised and accepted only on a TLS-protected session (implicit TLS or afterSTARTTLS); a cleartextAUTHattempt is refused with538.TLS client certificate — the client presents a certificate whose
SubjectPublicKeyInfoSHA-256 (64 hex chars) is listed inTLS_AUTH_CLIENT, or whose chain signature-validates up to a listed CA key.
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; an authenticated AUTH also sets the
RFC 3848 ESMTPSA keyword in the trace header. An unrecognised client
certificate never aborts the handshake — it simply leaves the session
unauthenticated.
70.1.1.1.3.6. 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 FROMis refused with530 5.7.0 Authentication required; the client must first satisfy one of the Client authentication mechanisms above. The flag therefore requires a TLS listener and at least one configured mechanism (SASL_TYPE,TLS_AUTH_CLIENTorMYNETWORKS), andpepsi-setuprejects a submission listener that has none.Submission fixups are applied to every accepted message: a missing
Date:header is added (§8.2) and a missingMessage-ID:is generated as<random@HOSTNAME>(§8.3). Headers that are already present are left untouched, and the fixups run before theReceived: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.
70.1.1.1.3.7. Submission identity (RFC 6409 §6 / §8.1)¶
A SASL-authenticated session is bound to the set of e-mail addresses its
username is allowed to use. 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
AUTHcommand is refused with535 5.7.8and the session stays unauthenticated;not listed (or no
USERNAME_MAPconfigured) — the defaultusername@HOSTNAME.
Given that set, two checks run on a SASL-authenticated submission:
Envelope and header identity (§6). The
MAIL FROMenvelope sender and the messageFrom:address must each be one of the allowed addresses, otherwise the message is refused with550 5.7.1(atMAIL FROMand at end-of-DATArespectively). The null sender<>and a message with no parseableFrom:address are exempt.Sender fixup (§8.1). When the
From:address is an allowed address that differs from the canonical identity, aSender:header naming the canonical identity is prepended (unless the message already carries one). Like the other fixups it is added before the trace header, so it is covered by the ARC seal and DKIM signature.
These checks apply only to sessions authenticated by SASL AUTH (which carry a
username). A MYNETWORKS or TLS-client-certificate session has no SASL
username and is not subject to the identity mapping.
70.1.1.1.4. 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 is the point: 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.
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.
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 a
forwarder accepts such mail (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.
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)).
70.1.1.1.4.1. Overload protection¶
Two mechanisms keep a flood of connections — including clients that complete the
TCP handshake and then go silent without ever closing — from exhausting the
server. Both operate on a source range: a single IPv4 address (a /32), or
the /32 prefix of an IPv6 address. Local (UNIX-socket) connections are exempt.
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 aDATA/BDATbody withinDATA_TIMEOUT(default 180 s), is closed with a421. The sameCOMMAND_TIMEOUTbounds a stalledSTARTTLS(or implicit-TLS) handshake.Connection ceiling with eviction. The server admits at most
min(MAX_CONNECTIONS, MAX_OPEN_SOCKETS)concurrent connections.MAX_OPEN_SOCKETSguards against file-descriptor exhaustion; when unset it is derived from the softRLIMIT_NOFILEminusRESERVED_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 (a421, 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.
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 421
and is closed without occupying a slot. (On an implicit-TLS listener the refusing
421 is omitted, since no plaintext may precede the handshake; the connection
is simply closed.)
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.
70.1.1.1.4.2. 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.
70.1.1.1.4.3. 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.
70.1.1.1.5. 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).
70.1.1.1.6. 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,debugortrace(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.
70.1.1.1.7. Signals¶
- SIGINT, SIGTERM
Initiate shutdown. The process stops accepting new connections and exits.
70.1.1.1.8. 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.
70.1.1.1.9. Environment¶
- XDG_CONFIG_HOME, HOME
Used to locate the default configuration file when –config is not given (see FILES).
- LISTEN_FDS, LISTEN_PID, LISTEN_FDNAMES
Honoured for systemd socket activation. A listener configured with
SERVE = systemdadopts the file descriptor passed by systemd at the index given by itsFD_INDEXoption.The shipped
pepsi-ingress.socketpasses four: ports 25, 465 and 587, which the listener sections claim byFD_INDEX0, 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 unprivilegedpepsi-ingressnever needs write access to/run/pepsi, a runtime directorypepsi-httpdand the setup-apply doorbell also declare and that systemd re-chowns to whichever of them started last.The local submission socket has no
FD_INDEXand no listener section.pepsi-ingressserves 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’sListenStream=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.
70.1.1.1.10. 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/pepsi.conf
Every Pepsi component shares the same configuration file, so this is the same list each one searches.
70.1.1.1.11. 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
70.1.1.1.12. See Also¶
pepsi-config(1), pepsi.conf(5), pepsi-setup(1), systemd.socket(5)
70.1.1.1.13. Bugs¶
Report bugs to the Pepsi issue tracker.