LMTP configuration
LMTP (RFC 2033) is the Local Mail Transfer Protocol used for final delivery from an external MTA (Postfix, Exim, etc.) into the yarilo mailbox. It operates on port 24 and returns per-recipient status codes, making it a drop-in replacement for SMTP-based local delivery.
Architecture
yarilo's LMTP stack is split into two components:
| Binary | Role |
|---|---|
yarilo-lmtp-login | MTA-facing proxy. Accepts one LMTP session from the MTA, tracks each recipient in warden (CONNECT), issues a service-scoped SESSION token per recipient via yarilo-auth master protocol, then at DATA time fans out one preamble TCP connection to yarilo-lmtp per recipient. |
yarilo-lmtp | Backend delivery. Accepts preamble connections (YARILO\t.), verifies the token with yarilo-auth (VERIFY, service=lmtp enforced), and delivers to the mailbox. No XCLIENT, no HAProxy, no direct warden access. |
Why fan-out? Each recipient may hash to a different backend pod (in director mode). A single multi-recipient DATA payload is split per recipient so each backend receives exactly the messages it is responsible for. Per-recipient status codes are merged and returned to the MTA.
Token scoping. SESSION tokens are issued with service=lmtp. The yarilo-lmtp PreambleListener rejects tokens issued for any other service (imap, pop3, submission), preventing cross-service replay.
See Services configuration for listener-level settings (port, ssl_).
protocol.lmtp
login_greeting
- Default
Yarilo ready.- Type
- string
- Helm
protocol.lmtp.login_greeting
Text appended to the 220 banner.
lmtp_add_received_header
- Default
true- Type
- boolean
- Also accepted as
add_received_header- Helm
protocol.lmtp.add_received_header
Prepend a Received: header to every delivered message.
lmtp_save_to_detail_mailbox
- Default
false- Type
- boolean
- Also accepted as
save_to_detail_mailbox- Helm
protocol.lmtp.save_to_detail_mailbox
Deliver user+folder@domain to the folder mailbox instead of INBOX.
lda_mailbox_autocreate
- Default
false- Type
- boolean
- Helm
protocol.lmtp.lda_mailbox_autocreate- See also
lda_mailbox_autosubscribe
A delivery to a folder that does not exist creates it. Off, the message goes to the detail mailbox if that exists, otherwise to INBOX.
lda_mailbox_autosubscribe
- Default
false- Type
- boolean
- Helm
protocol.lmtp.lda_mailbox_autosubscribe- See also
lda_mailbox_autocreate
Subscribe a folder that lda_ made. Without lda_ it does nothing, and startup refuses it.
lmtp_verbose_replies
- Default
false- Type
- boolean
- Also accepted as
verbose_replies- Helm
protocol.lmtp.verbose_replies
Include diagnostic details in 4xx and 5xx replies. Useful for debugging; leave it off in production.
lmtp_user_concurrency_limit
- Default
10- Type
- integer
- Also accepted as
user_concurrency_limit- Helm
protocol.lmtp.user_concurrency_limit
Concurrent deliveries per user, counted across the cluster through yarilo-warden and enforced by yarilo-lmtp-login at RCPT TO. Past it the recipient gets 451 4. and the MTA retries. -1 is unlimited; 0 refuses startup. With warden unreachable a delivery is accepted without the limit and a warning is logged.
lmtp_max_recipients
- Default
0- Type
- integer
- Helm
protocol.lmtp.lmtp_max_recipients
Recipients per transaction, on the backend and in yarilo-lmtp-login, advertised as LIMITS RCPTMAX=N. The next RCPT gets 452 4., and the MTA sends the rest in another transaction. 0 is unlimited.
read_timeout
- Default
300- Type
- integer, seconds
- Helm
protocol.lmtp.read_timeout
Read timeout per command.
write_timeout
- Default
300- Type
- integer, seconds
- Helm
protocol.lmtp.write_timeout
Write timeout per command.
The backend's listen address and port are listener settings (services., see Services); the port the login service dials is lmtp_ below.
A delivery to a folder that does not exist
A folder a delivery names (user+folder@ with lmtp_, or a Sieve fileinto) may not exist yet:
- if a namespace configures it with
auto(see Namespaces), the delivery makes it like any other open, subscribed whenauto: subscribe. The two knobs below play no part in that; - otherwise, with
lda_, the delivery makes it, and subscribes it withmailbox_ autocreate: true lda_;mailbox_ autosubscribe: true - otherwise the message goes to the detail mailbox when that is another folder and exists, then to
INBOX. The delivery is not refused.
fileinto :create makes the folder regardless of these settings.
Synthesised Message-ID
lmtp_add_message_id
- Default
true- Type
- boolean
- Helm
protocol.lmtp.add_message_id
Synthesise a Message-ID: when a message arrives without one. An existing header is never rewritten.
A message stored without a Message-ID cannot be replied to and cannot be threaded, and that is permanent: the header is part of the stored bytes, so nothing can add it afterwards without rewriting mail. Such a message is its own root in every conversation, no later reply can name it in In-Reply-To or References, and JMAP reports its messageId as null.
Behind an MTA the header is already present and this key changes nothing. Fed LMTP directly — a script, or a deployment with no MTA in front — delivery is the last place the identity can still be given, which is why the default is true. Set it to false if the stored bytes must be exactly what arrived.
The identifier is 128 bits of randomness at the server's hostname:
Message-ID: <[email protected]>Unique among all messages, as RFC 5322 §3.6.4 requires, rather than unique within one host or one run.
An existing Message-ID is never rewritten, not even a malformed one: whatever a sender wrote is what a reply quotes back in References, so replacing it would break the conversation the message already belongs to. Only a message whose header section has no Message-ID field at all is given one — a header inside a forwarded or quoted message is body, and does not count.
The header is added before Sieve runs, before the message is stored and before the conversation is recorded, so a script, the stored bytes and the thread all see the same identity.
protocol:
lmtp:
login_greeting: "Yarilo ready."
lmtp_add_received_header: true
lmtp_add_message_id: true
lmtp_save_to_detail_mailbox: false
lmtp_hdr_delivery_address: final
lmtp_verbose_replies: false
lmtp_user_concurrency_limit: 5
read_timeout: 300
write_timeout: 300Message size and a full mailbox
Size. When quota. is set, both yarilo-lmtp-login and the backend LMTP service offer it in the LHLO reply as SIZE <bytes>:
MAIL FROM:<.above the limit is refused at once with. . > SIZE=<n> 552.- A body that grows past the limit is refused with
552 5.while it is being read. The rest is read and discarded rather than held in memory, and the connection stays open for the next transaction.3. 4 Maximum message size exceeded
Leave quota_ empty or 0 for no limit; then LHLO offers a bare SIZE with no number, which RFC 1870 reads as "supported, no fixed limit".
Full mailbox. A recipient over quota is answered after DATA, with the text of quota.:
quota_ | Reply | What the MTA does |
|---|---|---|
false (default) | 552 5. | Bounces the message to the sender at once. |
true | 452 4. | Keeps the message queued and retries until the mailbox has room or the queue lifetime runs out. |
Turn it on only when a full mailbox is expected to be emptied soon and a delayed message is better than a bounce. Otherwise the sender learns of the failure only when the MTA gives up, often days later.
quota_full_tempfail
- Default
false- Type
- boolean
- Helm
protocol.lmtp.quota_full_tempfail
Reply for a recipient whose mailbox is full: off, 552 5., a permanent failure the MTA bounces to the sender; on, 452 4., a temporary failure the MTA keeps queued and retries.
Recipient rate limiting
Caps how many messages one sender may deliver to one recipient inside a moving window. Beyond the cap that recipient is refused with 451 4., and the sender retries later. Nothing is lost and nothing is bounced. The connection stays open, and the other recipients of the same transaction are answered as usual.
Counters live in yarilo-locks, so the limit is cluster-wide — a sender does not get a fresh allowance by reaching a different backend pod. The key is the pair (sender IP, recipient mailbox), so one noisy sender cannot exhaust another's allowance, and one busy mailbox does not throttle its neighbours.
rate_limit_enabled
- Default
true- Type
- boolean
- Also accepted as
enabled- Helm
protocol.lmtp.rate_limit.enabled
Gates the whole check.
rate_limit_per_recipient_burst
- Default
100- Type
- integer
- Also accepted as
per_recipient_burst- Helm
protocol.lmtp.rate_limit.per_recipient_burst- See also
rate_limit_per_recipient_window_seconds
Messages allowed per sender IP and recipient inside one window.
rate_limit_per_recipient_window_seconds
- Default
60- Type
- integer, seconds
- Also accepted as
per_recipient_window_seconds- Helm
protocol.lmtp.rate_limit.per_recipient_window_seconds- See also
rate_limit_per_recipient_burst
Width of the window.
protocol:
lmtp:
rate_limit:
rate_limit_enabled: true
rate_limit_per_recipient_burst: 100
rate_limit_per_recipient_window_seconds: 60In the Helm chart the same settings live under protocol. without the prefix (enabled, per_, per_).
When to raise it. The defaults suit ordinary mail and do not suit bulk delivery into a single account — a migration, a load test, a mailing-list expansion landing in one mailbox. Those are what the burst and the window are for. Turning the check off entirely leaves nothing between a misbehaving sender and a mailbox.
If a lock-service call fails, the delivery is accepted: the availability of the rate limiter is never allowed to block legitimate mail.
Original recipient (ORCPT)
A relay may name a recipient's original address with the ORCPT parameter of RCPT TO (RFC 3461). yarilo-lmtp-login takes it and passes it on to the backend, which takes it too, without either of them offering DSN. Sieve's vacation uses it as one of the user's addresses when sieve_ is on; see Sieve.
Postfix sends no ORCPT here
Postfix adds ORCPT only for a server that announces DSN, and yarilo does not announce DSN: that would make it responsible for delivery notifications (RFC 3461), which it does not send. So with Postfix in front, no ORCPT arrives and sieve_ has no effect. It takes effect with a relay that sends ORCPT without DSN, such as another IMAP server's LMTP proxy placed in front of yarilo during a migration.
Delivered-To header
lmtp_hdr_delivery_address
- Default
final- Type
- string
- Values
nonefinaloriginal- Also accepted as
hdr_delivery_address- Helm
protocol.lmtp.hdr_delivery_address
What the Delivered-To: header carries; the values are described below.
The values:
| Value | Behaviour |
|---|---|
none | No Delivered-To: header is added. |
final | Delivered-To: shows the address after subaddress stripping (alice@example.). Default. |
original | Delivered-To: shows the RCPT TO address as received (alice+tag@example.). |
Client workarounds
lmtp_client_workarounds
- Default
[]- Type
- list
- Also accepted as
client_workarounds- Helm
protocol.lmtp.client_workarounds
Compatibility shims for MTA clients that break the protocol; the names are described below. Unknown names are ignored.
The names:
| Name | Effect |
|---|---|
whitespace-before-path | Allows whitespace between the command verb and <path>: MAIL FROM: <user@example.. |
mailbox-for-path | Allows a bare mailbox name without a domain in RCPT TO: RCPT TO:<alice>. |
protocol:
lmtp:
lmtp_client_workarounds:
- whitespace-before-path
- mailbox-for-pathprotocol.lmtp.proxy
yarilo-lmtp-login reads this section. For each recipient it opens one connection to the backend that the director's LOOKUP names (or to backend_ in standalone mode). Deliveries to different backends run in parallel, and the per-recipient status codes are merged before the reply to the MTA. The backend, yarilo-lmtp, ignores the section.
lmtp_proxy_timeout
- Default
125- Type
- integer, seconds
- Also accepted as
timeout- Helm
protocol.lmtp.proxy.lmtp_proxy_timeout
Cap on one backend delivery: the connect, the LMTP transaction and the body together. Past it the recipient gets 451 4. and the MTA retries. A recipient's userdb field proxy_ replaces it for that recipient.
protocol:
lmtp:
proxy:
lmtp_proxy_timeout: 60A recipient's userdb field proxy_ replaces it for that recipient; see AUTH for the format. When that lookup fails the recipient gets 451 4.; when the value cannot be read, 550 5.. A user the userdb does not know is delivered with the global value.
Listener (service-level settings)
services:
lmtp:
enabled: true
port: 24
ssl_mode: no # no | starttls | sslThe real client IP and recipient identity are carried in the YARILO preamble from yarilo-lmtp-login; no HAProxy or XCLIENT handling on the backend.
Example: backend node (yarilo-lmtp)
services:
lmtp:
enabled: true
port: 24
ssl_mode: no
protocol:
lmtp:
lmtp_add_received_header: true
lmtp_hdr_delivery_address: final
lmtp_user_concurrency_limit: 5
read_timeout: 300
write_timeout: 300The backend listens only for preamble connections from yarilo-lmtp-login. MTAs connect to yarilo-lmtp-login, not directly to this port.
lmtp_login_service
Configuration for yarilo-lmtp-login. Set either backend_ (standalone) or director_ (director mode). When both are set, backend_ wins and the service logs a warning at start.
backend_addr
- Default
- none
- Type
- string
- Helm
components.lmtpLogin.backend_addr- See also
director_addr
Fixed address of the yarilo-lmtp backend, for standalone mode. When both this and director_ are set, this wins and a warning is logged at start.
director_addr
- Default
- none
- Type
- string
- Helm
components.lmtpLogin.director_addr- See also
backend_addr
Address of yarilo-director (:9102) for the per-recipient LOOKUP.
director_tag
- Default
- none
- Type
- string
- Helm
components.lmtpLogin.director_tag
Tag pool for the LOOKUP. Empty selects the untagged pool; there is no full-ring mode. A per-user director_ from the userdb overrides it.
backend_port
- Default
0- Type
- integer
- Helm
components.lmtpLogin.backend_port
Port that replaces the one in the LOOKUP result. 0 uses the result as it is.
Standalone mode:
lmtp_login_service:
backend_addr: "yarilo-lmtp.yarilo.svc.cluster.local:24"components:
lmtpLogin:
enabled: true
backend_addr: "yarilo-lmtp.yarilo.svc.cluster.local:24"Director mode:
lmtp_login_service:
director_addr: "yarilo-director.yarilo.svc.cluster.local:9102"
director_tag: "prod"
backend_port: 10024components:
lmtpLogin:
enabled: true
director_addr: "yarilo-director.yarilo.svc.cluster.local:9102"
director_tag: "prod"
backend_port: 10024Postfix main.:
mailbox_transport = lmtp:inet:[yarilo-lmtp-login.yarilo.svc.cluster.local]:24