85.3.1. pepsi.state¶
per-message state object carried through the Pepsi pipeline
- Manual section:
7
85.3.1.1.1. Name¶
pepsi.state - the JSON state object attached to every Pepsi message.
85.3.1.1.2. Description¶
Every message in flight is one row of the pepsi.workqueue table. Besides the
envelope and the message body, each row carries a free-form JSON object — the
state column (PostgreSQL JSONB) — that travels with the message from
ingress to its terminal stage. The state is the only channel through
which the stages of the pipeline communicate: one stage records a verdict or a
piece of provenance, a later stage reads it.
This page documents the layout of that object: the keys the stock stages write, when they are written, and which stages consume them. It is a data-format reference, independent of any configuration; the pipeline graph and the options that drive each stage are described in pepsi.conf(5).
85.3.1.1.3. Merge semantics¶
state is built up incrementally. Every terminal stage helper that mutates a
row merges its new keys into the existing object (the SQL terminal functions
evaluate state || $new), rather than replacing it. Consequently a key
written early — the origin provenance seeded by ingress, the dsn
parameters — survives for the whole lifetime of the message unless a stage
deliberately overwrites that specific key.
The merge is shallow: state || {"auth": {"arc": "pass"}} would replace
the whole auth object, not just its arc member. Stages that update one
member of a nested object therefore read the current object, amend it, and write
it back whole (that is what set_auth_verdict/set_auth_fields do for
auth.arc).
The one structural rewrite is dsn.rcpt, which is an array parallel to
rcpt_to: every helper that reduces a row to a subset of its recipients, or
fans a group out onto a sibling row, re-slices that array in lockstep, and the
alias stage rebuilds it when it writes an entirely new recipient list. A stage
must never touch rcpt_to without going through those helpers.
There is exactly one exception: pepsi-stage-bounce(1) clears state
to null when it rewrites a message into a delivery-status notification,
because the bounce is a brand-new null-sender message that inherits none of the
original’s provenance.
85.3.1.1.4. Top-level keys¶
The keys below are written by the stock pipeline. A stage that does not recognise a key simply leaves it untouched (the merge rule preserves it), so the set is open-ended; custom stages may add their own.
85.3.1.1.4.1. origin¶
(object) The SMTP-origin metadata of the connection that delivered the
message, seeded by pepsi-ingress(1). It is read by pepsi-stage-arc(1)
(which needs the authserv_id and the rendered ar_results fragment to
reproduce the ARC Authentication-Results identity without re-running
SPF/DKIM/DMARC) and by the relay stages, whose per-hop 8BITMIME/SMTPUTF8 downgrade
decision consults the original BODY=/smtputf8 declarations:
{
"origin": {
"remote_ip": "203.0.113.7", // connecting client IP (null for local injection)
"helo": "mail.example.com", // HELO/EHLO name announced by the client
"esmtp": true, // client used EHLO (ESMTP) rather than HELO
"smtputf8": false, // SMTPUTF8 requested on MAIL FROM
"body_8bit": false, // BODY=8BITMIME declared on MAIL FROM (RFC 6152)
"declared_size": 12345, // SIZE= declared on MAIL FROM (null if absent)
"tls": { "version": "TLSv1.3",
"cipher": "TLS13_AES_128_GCM_SHA256",
"sni": "mx.example.net" }, // null on a cleartext session
"listener": { "local_addr": "0.0.0.0:25", "mode": "starttls" },
"reverse_dns": "host.example.com", // PTR name of remote_ip (null if none)
"iprev": "pass", // RFC 8601 iprev / FCrDNS verdict
"auth_method": "sasl", // how the session authenticated (null if not)
"auth_mechanism": "PLAIN", // SASL mechanism (sasl sessions only)
"auth_identity": "alice", // authentication id / peer login
"auth_authzid": null, // authorization id, when distinct
"from_bound": true, // From: bound to the account (see below)
"authserv_id": "mail.example.org", // this receiver's id (for pepsi-stage-arc)
"ar_results": ";\r\n\tdkim=pass header.d=example.com;\r\n\tdmarc=pass ..."
// rendered Authentication-Results body, reused by
// pepsi-stage-arc for the AAR (no SPF/DKIM/DMARC re-run)
}
}
The four auth_* members describe how the session identified itself, as
opposed to the sibling auth key below, which is about what the message
claims. They are recorded because local_origin is a single boolean and
collapses two very different statements: “an account we authenticated submitted
this” and “this arrived from an address we chose to trust”. A policy stage — or
a milter, which is handed them as the RFC 4954 {auth_type} /
{auth_authen} / {auth_author} / {auth_ssf} macros — cannot recover
that distinction from the boolean.
auth_methodHow the session authenticated:
sasl(an RFC 4954AUTHsucceeded),peercred(a UNIX-socket peer whose uid resolved to a permitted login),client-cert(a TLS client certificate matched a pin) ormynetworks(the peer address is inMYNETWORKS).nullwhen the session did not authenticate. A later SASLAUTHoverwrites an earlier connection-time method: it is the stronger claim, and the only one that carries a username.auth_mechanismThe SASL mechanism that succeeded (
PLAIN,LOGIN), upper-cased. Set only forauth_method = sasl. The non-SASL methods leave itnullrather than putting a non-mechanism token in a field a filter may be comparing against a list of real mechanisms.auth_identityThe authentication identity: the SASL authcid, or the login a UNIX peer’s uid resolved to.
nullformynetworksandclient-cert, which authenticate an address or a key and name nobody.peercredtherefore yields an identity with no mechanism — the honest description of a session the kernel authenticated rather than SASL.auth_authzidThe authorization identity, recorded only when the client asked to act as somebody other than whom it authenticated as (SASL
PLAIN’s authzid field).nullwhen they are the same, which is the normal case: RFC 4954 permits an empty authzid and clients routinely repeat the authcid there.from_boundtruewhen theFrom:field names exactly one mailbox and that mailbox matched a concrete (wildcard-free)USERNAME_MAPentry of the authenticated account, or its defaultlogin@HOSTNAME;falseotherwise, including for a session with no account grant. 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 a key from the message, or runs an e-mail key command, only whenauth_methodismynetworksorclient-cert, or it issasl/peercredwithfrom_bound = true: an account granted*@example.orgmay send as its colleagues but must not be able to plant a key for them.
85.3.1.1.4.2. auth¶
(object) The inbound authentication verdicts ingress computed, also reflected
in the prepended Authentication-Results header. The arc verdict is
seeded as none and overwritten by pepsi-stage-arc(1) once it
re-evaluates the inbound ARC chain (its spf/dkim/dmarc siblings are
left untouched). Read by pepsi-stage-check-whitelist(1) (whose
dkim_required gate accepts a row on auth.dkim or auth.arc):
{
"auth": {
"spf": "pass", // SPF verdict (pass/fail/softfail/neutral/temperror/permerror/none)
"dkim": "pass", // DKIM verdict (strongest of the message signatures)
"dmarc": "pass", // DMARC verdict (pass/fail/temperror/permerror/none)
"arc": "none", // inbound ARC chain verdict (filled in by pepsi-stage-arc)
"arc_sealers": [] // d= of each inbound ARC-Seal (filled in by pepsi-stage-arc)
}
}
85.3.1.1.4.3. local_origin¶
(boolean) Seeded by ingress: true when the delivering session was
authenticated (the client was in MYNETWORKS, completed a SASL AUTH,
presented a matching TLS client certificate, or — on a UNIX socket with
AUTH_PEERCRED, which is the default there — was a peer whose uid resolved to
a permitted login, the path every locally submitted message takes; see the
[pepsi-ingress-listener-*] sections in pepsi.conf(5)), false
otherwise. It marks mail originating from a trusted sender and is preserved
through the pipeline for downstream stages to test (the per-address settings
layer keys on the sender for local_origin messages, and
pepsi-stage-edit-settings(1) only treats a message as a control message
when it is local_origin).
85.3.1.1.4.4. dsn¶
(object) The RFC 3461 delivery-status-notification parameters the sender
requested, seeded by ingress: the message-level ret/envid and a
per-recipient array parallel to rcpt_to of notify/orcpt.
Every stage must preserve and honour it — the relay stages propagate the
parameters to the next hop only when it advertises DSN, and
pepsi-stage-bounce(1) consults the per-recipient notify to decide
whether to emit a report at all:
{
"dsn": {
"ret": "hdrs", // RET= (full|hdrs), if given
"envid": "QQ314159", // ENVID= xtext, if given
"rcpt": [ { "notify": ["success", "failure"], // NOTIFY= keywords
"orcpt": "rfc822;user@example.com" } ] // ORCPT= xtext
}
}
85.3.1.1.4.5. bounce¶
(object) Written by any stage that routes a message to its
BOUNCE_STAGE — the delivery stages
(pepsi-stage-relay-to-internet(1), pepsi-stage-relay-to-smarthost(1),
pepsi-stage-relay-to-maildir(1), pepsi-stage-relay-to-lmtp(1)) and
pepsi-stage-discard(1), but equally pepsi-stage-milter(1),
pepsi-stage-block-language(1), pepsi-stage-anti-spam(1),
pepsi-stage-encrypt(1), pepsi-stage-decrypt(1),
pepsi-stage-secretary(1) and pepsi-stage-dot-forward(1). They all build it with the same shared helpers,
so the shape below is the same whichever stage wrote it. It is consumed by
pepsi-stage-bounce(1) to build the report. Its kind is
permanent (a failure bounce, Action: failed), success (a positive
report, Action: delivered) or delay (Action: delayed);
diagnostic and failed_recipient are the human-readable reason and the
affected recipient, and the optional notify/orcpt/envid are copied
from the recipient’s state.dsn entry so the bounce stage can honour
NOTIFY and echo the original ENVID/ORCPT. The relay stages also
record structured next-hop detail (remote_mta, smtp_code,
enhanced_status, phase, reply_text) so a bounce template can state
precisely why the next MTA refused the message, and enhanced_status feeds the
DSN Status: field. The message-level ret rides along here too, for the
same reason as envid: the bounce stage clears state as it rewrites the
message, so anything it needs after that point has to be inside this object:
{ "bounce": { "kind": "permanent",
"diagnostic": "550 5.1.1 user unknown",
"failed_recipient": "bob@example.net",
"remote_mta": "mx.example.net",
"smtp_code": 550,
"enhanced_status": "5.1.1",
"phase": "rcpt",
"reply_text": "user unknown",
"notify": ["failure"],
"orcpt": "rfc822;bob@example.net",
"envid": "QQ314159",
"ret": "hdrs" } }
85.3.1.1.4.6. attempts / last_error / delay_sent¶
Scratch the delivery stages record when they pause a message for retry:
attempts (number) is the number of delivery attempts so far, which is what
drives the retry backoff, and delay_sent (boolean, present only when true)
marks that the one-shot NOTIFY=DELAY DSN has already been emitted, so it is
never sent twice.
last_error (string) is the most recent error text. It is not confined
to the retry path: the delivery, milter, crypto, DKIM-signing and
~/.forward stages also write it when they fail a message terminally, so on a
failed row it is the first thing to read (pepsi-queue(1) shows it as
part of the row’s state, and pepsi-status(1) lists it under each stuck
message).
85.3.1.1.4.7. temporary_failures / temporary_stage¶
(number / string) Written, together with last_error, by the stage
scaffolding when a stage’s error was not marked permanent – the host’s fault
rather than the message’s (see pepsi-dispatch(1)): the number of times the
message has been paused for it so far at temporary_stage, which drives the
back-off from one minute doubling up to an hour. A count recorded at another
stage is not continued. Removed when the worker gives up.
85.3.1.1.4.8. failed_stage / failure_class / failed_at¶
The record of a failure that happened on this host rather than at the next hop.
failed_stage (string) is the stage it happened at. failure_class
(string) says how it ended: permanent (the stage marked the error as a
defect of the message), retries-exhausted (retried until MAX_LIFETIME),
crashed or timed-out (the message crashed or hung its worker three
times). failed_at (string, RFC 3339) is stamped by the database whenever
the row becomes failed or timeout; pepsi-failure-bouncer(1)’s
MIN_AGE counts from it. pepsi-stage-bounce(1) builds a DSN from
failed_stage and failure_class when no relay reported the failure
(bounce above), with a generic text; last_error is never copied into a
DSN.
85.3.1.1.4.9. arc_temperrors¶
(number) Written by pepsi-stage-arc(1) each time validating an inbound
ARC chain met a temporary DNS error: the message is paused and retried, and
after the third such attempt the chain is sealed cv=fail as a failed one
would be.
85.3.1.1.4.10. strikes¶
(number) Written by pepsi-dispatch(1) each time the message crashed its
worker or held it past MAX_RUNTIME; the third strike fails the message.
85.3.1.1.4.11. retry_since¶
(string, RFC 3339) Written by pepsi-stage-bounce(1) on the DSN it
builds, which replaces the message in its row: the moment the stages’
MAX_LIFETIME is counted from instead of the row’s arrival time, so a DSN is
not born already expired.
85.3.1.1.4.12. srs¶
(object) Written by pepsi-stage-srs(1) when it rewrites the envelope sender, recording the address it replaced:
{ "srs": { "original": "alice@example.org" } }
It exists for a DSN this deployment originates itself. SRS necessarily runs
before the delivery stage — rewriting the envelope for the hand-off is its
whole purpose — so by the time a delivery stage calls complete_success, or a
relay routes a failure to its BOUNCE_STAGE, the row’s mail_from is one of
our own SRS aliases. pepsi-stage-bounce(1) would then address the DSN to
that alias, sending it out to the next hop and back in through our own MX to be
decoded — surviving the entire inbound pipeline as a null-sender message — only
to reach an address that was in the row all along. Where that round trip does not
work, the DSN is lost with nothing reporting it.
The bounce stage therefore prefers this value over mail_from. It is recorded
rather than recovered by reversing the alias because Srs::reverse needs the
SRS HMAC secret, and a stage whose job is composing a bounce has no reason to
hold key material: the sender is ordinary message metadata, which is what
state is for.
Written only when absent, so a message rewritten twice keeps the address that names a real correspondent rather than the intermediate alias. A bounce (null sender) is never rewritten and so never records one; an empty value reads as absent, and the bounce stage falls back to the envelope sender.
85.3.1.1.4.13. pay_deadline¶
(number) Written by pepsi-stage-anti-spam(1): Unix epoch seconds, the
deadline by which the pay-to-send order must be settled. It is kept in state
(rather than the row’s timeout column) because the dispatcher nulls
timeout whenever it re-queues a paused message. A value written as a JSON
string is read as “no deadline”, so anything setting this by hand must write a
number. The stage short-circuits its payment gate when
state.paid is already set.
85.3.1.1.4.14. spam / paid¶
(boolean) Anti-abuse short-circuit flags.
spam is written by pepsi-stage-check-whitelist(1), which sets it
false on a whitelist match (and otherwise leaves it unset), and by
pepsi-ingress(1), which sets it false for a message whose only
recipient is <Postmaster> (RFC 5321 §4.5.1 reachability), and by
pepsi-stage-secretary(1), which sets it false on a message it releases
because its sender confirmed. A false value makes
pepsi-stage-block-language(1), pepsi-stage-secretary(1) and
pepsi-stage-anti-spam(1) forward the message without applying their gate.
A true value makes pepsi-stage-anti-spam(1) drop the message rather
than gate it, sends it down pepsi-stage-secretary(1)’s timeout path,
suppresses key learning in pepsi-stage-autocrypt-learn(1) unless that
stage’s LEARN_FROM_SPAM is on, and blocks the RFC 3834 auto-responders that
use the shared suppression rules (pepsi-stage-vacation(1), the secretary’s
challenge and the mailing-list auto-responses). Only an explicit boolean
counts: an absent key triggers neither behaviour.
Note that no stock stage ever writes spam = true: the writers above
only ever write false. The true value is the hook a milter-driven
pipeline, an operator or a site-specific stage uses — the same arrangement as
paid below.
paid is written by pepsi-stage-anti-spam(1) itself, and only ever as
false: it stores paid = false alongside pay_deadline on every pause,
which is what marks the message as awaiting payment. When the merchant reports
the order settled the stage simply advances the message, so no stage in the tree
ever writes paid = true — that value is an override hook for an operator or a
site-specific stage, and the payment gate is skipped when it is set (as it is
when spam is false).
85.3.1.1.4.15. dot-forwarders¶
(array of strings) The lower-cased login names whose ~/.forward files
forwarded this row, i.e. the forwarding chain that led to it, maintained by
pepsi-stage-dot-forward(1) as a forwarding-loop guard. A forwarded row
gets the chain of the row it came from plus the one login that forwarded it, so
the list describes one forwarding path, not every ~/.forward the message
has passed through. Because a forwarded message restarts the pipeline, a
recipient whose user is already listed here is bounced instead of being run
through its ~/.forward again, so a cycle (~bob → alice, ~alice →
bob) terminates.
85.3.1.1.4.16. language¶
(string) Written by pepsi-stage-detect-language(1) as an
Accept-Language-style string describing the detected body language(s), with
q-values in preference order.
pepsi-stage-block-language(1) reads it to score the message against its
allow/deny lists, but it is equally the localisation key: every stage that
composes prose for a human picks its template or message language from it,
falling back to its own DEFAULT_LANGUAGE when it is absent or names nothing
supported — pepsi-stage-vacation(1) (the out-of-office notice),
pepsi-stage-anti-spam(1) (the payment request),
pepsi-stage-secretary(1) (the challenge),
pepsi-stage-encrypt(1), pepsi-stage-auto-pay(1) and
pepsi-stage-secure-link(1) (their reply messages) and pepsi-stage-edit-settings(1) (its confirmation or error
reply). That is why a deployment that does no language blocking may still want
the detection stage in the pipeline.
85.3.1.1.4.17. vacation¶
(object) Written by pepsi-stage-vacation(1) when it has answered a message on the recipient’s behalf, recording what it did:
{ "vacation": { "notified": true,
"recipient": "alice@example.org", // who is away
"range": "2026-08-01:2026-08-14",
"language": "de" } } // the notice's language
Nothing reads it: it exists so the decision is visible in pepsi-queue(1), so pepsi-stage-if(1) can branch on it, and so a support question about a surprising out-of-office reply is answerable from the row rather than from the log. Absent on every message that was not answered.
85.3.1.1.4.18. secretary¶
(object) Written by pepsi-stage-secretary(1). On a message it holds:
{ "secretary": { "challenge": "00112233445566778899aabbccddeeff", // the cookie
"deadline": 1769812345, // Unix seconds, when it expires
"whitelist": "correspondents" } } // where a confirmation writes
A confirmation adds "confirmed": true as it releases the row (the stage then
forwards it with spam = false); a bounced challenge adds "abandoned": true,
sending it down the timeout path early. A message the stage would not challenge
and routed to its UNCHALLENGEABLE_STAGE carries { "secretary": {
"unchallengeable": "<reason>" } } instead. The timeout path removes the key
entirely: nothing of a challenge outlives it. Like pay_deadline, the deadline
is kept in state because the dispatcher clears the row’s timeout when it
requeues a paused message.
85.3.1.1.4.19. keydisc¶
(object) What a parked message is waiting for: the correspondent address whose key could not be found in the cache, and when the wait began:
{ "keydisc": { "address": "bob@example.com", // the address a key is wanted for
"since": 1769812345 } } // Unix seconds, when the park happened
It is written by the key-discovery park terminal, which in one round-trip
commits whatever the stage had already rewritten, merges this key, sets the row
paused with a retry timeout, and enqueues a request in
pepsi.key_request. A pepsi-keydisc(1) service that settles that request
matches waiters on keydisc.address — through a partial index on exactly that
expression — and flips them back to pending, so a stage must not rename or
hand-write the key. The retry timeout is the safety net for a deployment
running no discovery service at all: it is always set later than the discovery
deadline, so the release normally happens first.
The release only moves the row’s status and clears its timeout, so the
key survives it: a resumed message still carries the record of the wait that has
just ended, and a stage that parks again simply overwrites it.
85.3.1.1.4.20. secure_link¶
(object) Marks a message that pepsi-httpd(1)’s secure-link portal injected: a reply a recipient typed into the portal after opening a message pepsi-stage-secure-link(1) had sealed there:
{ "secure_link": { "reply": true,
"token": "b3d1…" } } // the portal token replied to
local_origin is deliberately absent from such a message, and that absence
is the whole security property: it is the single bit separating a portal reply
from an open relay. Without it the reply cannot take the submission path — no
relaying as us, no auto-whitelisting of its recipient, no SRS-as-local-origin —
and is treated as what it is, inbound mail that happens to have originated at our
own web server.
85.3.1.1.4.21. tlsrpt¶
(boolean) A loop-guard flag set on a message that is itself an SMTP TLS
Reporting report (pepsi-tlsrpt(1)). The relay stages skip recording a
pepsi.tls_session row for such a message, so a report about a failed report
delivery cannot recurse.
85.3.1.1.4.22. auth.arc¶
Overwritten by pepsi-stage-arc(1) with the verdict of the inbound ARC
chain it re-evaluated, replacing the none ingress seeded under auth (see
the auth key above).
85.3.1.1.4.23. auth.arc_sealers¶
(array of strings) Written by pepsi-stage-arc(1) alongside auth.arc:
the d= of every ARC-Seal the message arrived carrying, lower-cased,
trailing root dot removed, de-duplicated and ordered by instance so the ADMD
closest to the originator comes first. Our own ARC_DOMAIN is never present —
the list is read before this stage’s own set is rendered:
{ "auth": { "arc": "pass", "arc_sealers": ["lists.example.org", "mx.forwarder.example"] } }
It exists because auth.arc alone cannot support a policy decision: RFC 8617
§8.4 states that a valid chain conveys no trustworthiness, only that the
named ADMDs handled the message, and leaves the rest to the receiver. Naming an
acceptable intermediary is that local policy, and this is what such a rule is
matched against — the sealer_domain rows of
pepsi-stage-check-whitelist(1).
The array is written whether or not the chain validated, because it is the record
of what the message claimed; a seal parses without verifying. Every reader must
therefore require auth.arc = pass before trusting an entry, which
check-whitelist does by discarding the whole list otherwise.
85.3.1.1.4.24. crypto¶
(object) What the end-to-end cryptography stages did. The outbound half,
crypto.out, is written by pepsi-stage-encrypt(1):
{ "crypto": { "out": {
"signed": true,
"protocol": "openpgp",
"signer": "alice@example.org",
"signer_fingerprint": "ABCD…",
"recipients": {
"bob@example.net": { "outcome": "encrypted",
"protocol": "openpgp",
"fingerprint": "1234…",
"key_source": "wkd",
"trust": "wkd-advanced",
"content_algorithm": "seipdv2-aead",
"downgraded": false } },
"key_attached": true } } }
signer is the From: author, never the envelope sender: the signature is
about the author the recipient sees. Each recipient’s outcome is one of
encrypted, signed-only, cleartext, secure-link, bounced or
oversize, and route names the ON_NO_KEY/ON_OVERSIZE path taken
when it was not the ordinary one. reason carries a short phrase for the
operator’s log and for the bounce.
content_algorithm and downgraded are recorded so an operator can answer
“who are we still talking to in SEIPDv1?” without reading logs. downgraded
appears only beside a content_algorithm: false on a recipient that was
not encrypted at all would read as “we encrypted, without downgrading”, which is
a different claim.
key_source and trust are both own when the recipient is an address
this deployment holds its own identity for. That is not a rank on the discovery
ladder: our own key pre-empts the pepsi.peer_key cache entirely for such an
address, so no MIN_TRUST floor applies and no discovered key was consulted.
See pepsi-stage-encrypt(1).
Because the stage splits divergent recipients one per row, a row’s
recipients object normally holds exactly the recipients in that row’s
rcpt_to.
key_attached (boolean, present only when true) records that the sender’s
public key went out as an application/pgp-keys file
(ATTACH_KEYS_AS_FILES). client_protected (boolean, present only when
true) records that the user’s own mail client had already encrypted the
submission, so the stage left it alone: no encryption, signature, Autocrypt:
header or key file of Pepsi’s.
The inbound half, crypto.in, is written by pepsi-stage-decrypt(1):
{ "crypto": { "in": {
"encrypted": true,
"outer_gossip": true,
"decrypted": true,
"decryption": "decrypted",
"protocol": "openpgp",
"decrypted_with": { "identity": 7,
"address": "bob@example.org",
"fingerprint": "ABCD…" },
"signature": { "status": "valid",
"signer": "alice@example.net",
"fingerprint": "1234…",
"key_source": "wkd",
"trust": "wkd-advanced",
"algorithm": "ed25519",
"signed_at": 1785000000,
"covers_plaintext": true },
"layers": [ { "kind": "encrypted", "protocol": "openpgp",
"encoding": "pgp-mime", "verdict": "good",
"container": "seipdv1-mdc", "downgraded": true },
{ "kind": "signed", "protocol": "openpgp",
"encoding": "pgp-mime", "verdict": "good" } ] } } }
decryption is not-encrypted, decrypted, failed or for-client;
on failed a sibling failure key carries the machine-readable reason
(no-decryption-key, decryption-failed, too-large, …). route names
the ON_DECRYPT_FAILURE/ON_BAD_SIGNATURE path taken when it was not the
ordinary one.
for-client means the message is encrypted to the recipient’s own MUA
key (an identity with custody = client, whose private half lives only in
their mail client) and was passed on unopened, even when one of our own keys
was a recipient too. It is not a failure: ON_DECRYPT_FAILURE is not
consulted. The sibling for_client is then true and client_fingerprint
names the key. Nothing was verified, so signature_verified is not set.
signature.covers_plaintext is present whenever there is a signature verdict:
true when the verdict was granted by a signature over the plaintext,
false when it came from a layer wrapped around ciphertext.
pepsi-stage-autocrypt-learn(1) records that a correspondent holds one of
our keys (pepsi.peer_has_own_key) only on a valid or valid-untrusted
signature with covers_plaintext = true whose signer is the From:
address, on a message we decrypted.
outer_gossip (boolean, present only when true) records that the message
as it arrived already carried an Autocrypt-Gossip: field on its outer
header block — something any hop on the path could have written, and which
inline-PGP reassembly can carry through decryption. It is written here because
the plaintext is committed over the arriving bytes, so nothing downstream could
answer the question later. Together with encrypted it is what
pepsi-stage-autocrypt-learn(1) consults before learning any gossiped key:
an unauthenticated outer field vetoes the lot. If LEARN_GOSSIP is on and
nothing is being learnt from an encrypted message, this is the key to look at.
signature.status is one of:
noneNo signature.
validCryptographically sound and the key was anchored — an X.509 chain to a
ca_trustanchor, or an OpenPGP key from a ranked discovery source.valid-untrustedSound, but the key could not be tied to anything: a first-seen key, one learnt from the message itself, or a certificate from a CA this deployment holds no anchor for. Never reported as ``valid``; that distinction is what the stage exists to make.
invalidThe signature does not verify, or the signer’s certificate is expired, revoked or bound to a different address.
signature.failuresays which.unverifiableNo key was available for the claimed signer.
signature.key_source and signature.trust here carry the crypto layer’s
own provenance vocabulary (anchor, dane, wkd, autocrypt,
pinned, attached, unknown), which is coarser than the outbound half’s
ladder names. A signature checked against our own identity for the claimed
sender reports pinned — the strongest value that vocabulary has, and the
right one, since the key is on record for that address. It is the same
pre-emption the outbound half spells own: for an address this deployment
holds a key for, no peer_key row is offered to the verifier at all. See
pepsi-stage-decrypt(1).
A message carrying several signature layers takes the worst of their
verdicts — among the layers that cover the plaintext. A signature layer marked
covers_ciphertext (see below) is consulted only when there is no other, and
even then can never yield valid.
layers is ordered outermost first, and the order matters: it is what the
subject tags are rendered from, so encrypted(signed(body)) and
signed(encrypted(body)) are distinguishable. It therefore survives a pause,
which is why the stage records it rather than recomputing it. container and
downgraded appear on ciphertext layers only, so an operator can see how much
inbound mail is still pre-AEAD.
"covers_ciphertext": true appears on a signature layer that has a ciphertext
layer nested inside it — the outer signature of signed(encrypted(body)). It
is present only when true. Such a signature was computed over the ciphertext, so
it says that the signer transmitted the blob and nothing about what came out of
it; anybody who obtains an encrypted message can wrap it in their own signature
and forward it. The layer keeps its own honest verdict, but it cannot raise
signature.status to valid and so cannot set signature_verified. In
signed(encrypted(signed(body))) only the outer layer is marked, and the inner
one — the one that speaks for the content — decides the verdict.
The storage half, crypto.store, is written by
pepsi-stage-reencrypt(1) on a row whose crypto.in says the message was
decrypted:
"crypto": {
"in": { … },
"store": { "outcome": "reencrypted", "protocol": "openpgp",
"fingerprint": "…", "container": "seipdv2-aead",
"downgraded": false }
}
outcome is reencrypted (sealed to the recipient’s own MUA key, named by
fingerprint), plaintext (filed as it is: no usable MUA key under
ON_NO_CLIENT_KEY = plaintext, or not a local recipient — reason says
which) or bounced (refused under ON_NO_CLIENT_KEY = bounce; reason).
The stage merges the whole crypto object, so crypto.in survives, and the
presence of store is what keeps it from sealing a row twice.
85.3.1.1.4.25. encrypted¶
(boolean) Set to true by pepsi-stage-encrypt(1) when the message on
this row really was encrypted to a recipient key, and by
pepsi-stage-decrypt(1) when the message arrived encrypted.
pepsi-stage-auto-whitelist(1) copies it into the new whitelist row’s
signature_required column, so that a correspondent who was reached under
encryption is later held to the same standard. Absent (rather than false)
when nothing was encrypted.
85.3.1.1.4.26. signature_verified¶
(boolean) Set to true by pepsi-stage-decrypt(1) for the valid
signature verdict, and only that one.
pepsi-stage-check-whitelist(1)’s signature_required gate tests it, so
letting valid-untrusted set it would open a gate the operator meant to hold
for a trusted key — and, for the same reason, a signature that only covers
ciphertext (covers_ciphertext, above) never sets it either. Absent (rather
than false) otherwise.
85.3.1.1.4.27. milter¶
(object) What pepsi-stage-milter(1) did — the filter’s verdict, the protocol version negotiated, how long the conversation took, and every modification applied, labelled:
{ "milter": { "stage": "spam-filter",
"verdict": "reject",
"version": 6,
"elapsed_ms": 42,
"actions": ["addheader:X-Spam-Status", "replbody"],
"reply_code": "550 5.7.1 blocked",
"quarantine_reason": "virus found",
"rejected_recipients": 1 } }
verdict is continue, accept, reject, tempfail, discard or
— when the filter could not be spoken to at all — failed, in which case a
sibling error carries the diagnostic and the routing followed the stage’s
ON_FAILURE. reply_code, quarantine_reason and rejected_recipients
are present only when they apply.
Nothing reads it. It exists so pepsi-queue(1) can show why a message was
tagged, so pepsi-stage-if(1) can branch on it, and so a support question
about a surprising header is answerable from the row rather than from the log —
the same rationale as vacation above. A rejected message additionally carries
the usual bounce object, with the filter’s own SMTP code, RFC 3463 enhanced
status and text split into their own fields.
85.3.1.1.4.28. crypto_policy_refused¶
(boolean, present only when true) Written alongside last_error by
pepsi-stage-encrypt(1), pepsi-stage-decrypt(1) and
pepsi-stage-secure-link(1) when they fail a message because the configured
policy refused it — ENCRYPT = required with no usable recipient key and no
ON_NO_KEY route, a message that arrived encrypted with no ON_DECRYPT_FAILURE
route, and so on. It distinguishes a deliberate refusal from a transport or
programming error, which otherwise look identical on a failed row: retrying
the first one will never help.
85.3.1.1.4.29. milter_error¶
(string) Written by pepsi-stage-milter(1) when a filter rejected a
message but neither REJECT_STAGE nor BOUNCE_STAGE was configured, so the
stage had nowhere to route it. Distinct from milter.error inside the
milter object above, which is the diagnostic for a filter that could not be
spoken to at all.
85.3.1.1.4.30. list¶
(object) The mailing-list descriptor. pepsi-stage-list(1) writes it on
every row it routes to a list role (the list id, the role, the
address the message arrived at, and a token or verp address when the
sub-address carried one); pepsi-stage-list-post(1) writes a per-member
descriptor (id, member, lang, serial, duplicate) on each
delivery row it fans out, plus pending_rcpt on a partially fanned-out
posting and rejected on a refused one; pepsi-list(1) sets
moderator_approved (and digest on a digest issue). Read by
pepsi-stage-list-post(1), pepsi-stage-list-command(1),
pepsi-stage-list-deliver(1) and pepsi-stage-list-bounce(1); see
those pages for the fields each one uses.
85.3.1.1.4.31. dispatch_error¶
(string) Written by pepsi-dispatch(1) when it forces a row to a terminal
state it did not reach on its own — the diagnostic for a stage program that
crashed (failed), one that exceeded MAX_RUNTIME (timeout), or a row
sitting at a stage the configuration does not define. pepsi-status(1)
lists it under each stuck message.
85.3.1.1.5. See Also¶
pepsi.conf(5), pepsi-ingress(1), pepsi-dispatch(1), pepsi-stage-arc(1), pepsi-stage-bounce(1), pepsi-stage-check-whitelist(1), pepsi-stage-anti-spam(1), pepsi-stage-detect-language(1), pepsi-stage-block-language(1), pepsi-stage-vacation(1), pepsi-stage-secretary(1), pepsi-stage-milter(1), pepsi-stage-encrypt(1), pepsi-stage-decrypt(1), pepsi-stage-auto-whitelist(1), pepsi-stage-list(1), pepsi-tlsrpt(1), pepsi-keydisc(1), pepsi-keys(1)