Skip to content

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:

BinaryRole
yarilo-lmtp-loginMTA-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-lmtpBackend delivery. Accepts preamble connections (YARILO\t...TOKEN=...\n), 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_mode).


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_mailbox_autocreate made. Without lda_mailbox_autocreate 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.3.0 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.5.3, 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.lmtp, see Services); the port the login service dials is lmtp_login_service.backend_port below.

A delivery to a folder that does not exist ​

A folder a delivery names (user+folder@ with lmtp_save_to_detail_mailbox, 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 when auto: subscribe. The two knobs below play no part in that;
  • otherwise, with lda_mailbox_autocreate: true, the delivery makes it, and subscribes it with 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.

yaml
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: 300

Message size and a full mailbox ​

Size. When quota.quota_mail_size is set, both yarilo-lmtp-login and the backend LMTP service offer it in the LHLO reply as SIZE <bytes>:

  • MAIL FROM:<...> SIZE=<n> above the limit is refused at once with 552.
  • A body that grows past the limit is refused with 552 5.3.4 Maximum message size exceeded 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.

Leave quota_mail_size 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_exceeded_message:

quota_full_tempfailReplyWhat the MTA does
false (default)552 5.2.2Bounces the message to the sender at once.
true452 4.2.2Keeps 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.2.2, a permanent failure the MTA bounces to the sender; on, 452 4.2.2, 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.7.0 Rate limit exceeded for recipient, 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.

yaml
protocol:
  lmtp:
    rate_limit:
      rate_limit_enabled: true
      rate_limit_per_recipient_burst: 100
      rate_limit_per_recipient_window_seconds: 60

In the Helm chart the same settings live under protocol.lmtp.rate_limit without the prefix (enabled, per_recipient_burst, per_recipient_window_seconds).

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_vacation_use_original_recipient 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_vacation_use_original_recipient 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
none final original
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:

ValueBehaviour
noneNo Delivered-To: header is added.
finalDelivered-To: shows the address after subaddress stripping (alice@example.com). Default.
originalDelivered-To: shows the RCPT TO address as received (alice+tag@example.com).

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:

NameEffect
whitespace-before-pathAllows whitespace between the command verb and <path>: MAIL FROM: <user@example.com>.
mailbox-for-pathAllows a bare mailbox name without a domain in RCPT TO: RCPT TO:<alice>.
yaml
protocol:
  lmtp:
    lmtp_client_workarounds:
      - whitespace-before-path
      - mailbox-for-path

protocol.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_addr 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.2.0 and the MTA retries. A recipient's userdb field proxy_timeout replaces it for that recipient.

yaml
protocol:
  lmtp:
    proxy:
      lmtp_proxy_timeout: 60

A recipient's userdb field proxy_timeout replaces it for that recipient; see AUTH for the format. When that lookup fails the recipient gets 451 4.3.0 Temporary user lookup failure; when the value cannot be read, 550 5.3.5 Internal user lookup failure. A user the userdb does not know is delivered with the global value.


Listener (service-level settings) ​

yaml
services:
  lmtp:
    enabled: true
    port: 24
    ssl_mode: no    # no | starttls | ssl

The 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) ​

yaml
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: 300

The 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_addr (standalone) or director_addr (director mode). When both are set, backend_addr 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_addr 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_tag 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:

yaml
lmtp_login_service:
  backend_addr: "yarilo-lmtp.yarilo.svc.cluster.local:24"
yaml
components:
  lmtpLogin:
    enabled: true
    backend_addr: "yarilo-lmtp.yarilo.svc.cluster.local:24"

Director mode:

yaml
lmtp_login_service:
  director_addr: "yarilo-director.yarilo.svc.cluster.local:9102"
  director_tag: "prod"
  backend_port: 10024
yaml
components:
  lmtpLogin:
    enabled: true
    director_addr: "yarilo-director.yarilo.svc.cluster.local:9102"
    director_tag: "prod"
    backend_port: 10024

Postfix main.cf:

mailbox_transport = lmtp:inet:[yarilo-lmtp-login.yarilo.svc.cluster.local]:24