Sovite.SMTP.Server.Session (sovite v0.6.0)

Copy Markdown View Source

The SMTP server protocol as a state machine, without I/O.

Feed it the bytes read from the client; it returns the bytes to send back and whether to close the connection. Policy and storage are left to a Sovite.SMTP.Server.Handler. Sovite.SMTP.Server.Connection runs a session on a socket.

{:continue, greeting, session} = Session.new(connection, hostname: "mx.example.com", handler: {MyHandler, []})
{:continue, replies, session} = Session.handle_input(session, "EHLO client.example\r\n")

Pipelined commands (RFC 2920) need no special handling: all complete lines in the input are processed, and their replies returned together.

STARTTLS

With starttls: true, STARTTLS (RFC 3207) is offered until the connection is encrypted. On STARTTLS the session replies 220 and returns {:starttls, replies, session}: send the replies, run the TLS handshake, then call handle_tls/2. Input received after the STARTTLS command and before the handshake is discarded, so a man-in-the-middle cannot inject commands into the encrypted session. After the handshake the session starts over, as the RFC requires: the client must send EHLO again.

LMTP

With lmtp: true the session speaks LMTP (RFC 2033): the client greets with LHLO (EHLO and HELO are refused), and the end of the data gets one reply per accepted recipient, all with the handler's reply. The handler sees LHLO as handle_helo(:lhlo, name, state). Without it, LHLO is an unknown command.

AUTH

With auth: true, AUTH (RFC 4954) is offered with the mechanisms the handler's auth_mechanisms/1 returns, but only over TLS unless plaintext_auth: true. The handler runs the SASL exchange, see Sovite.SMTP.Server.Handler. Lines of up to 12288 bytes are accepted while AUTH is offered, for large initial responses and tokens.

Greeting delay

A handler's init/2 may return {:pause, milliseconds, state} to hold the greeting back, as postscreen does: clients must wait for it (RFC 5321 §3.1), so one that talks first is most likely a spam bot. The session then returns no output, and timeout/1 is the time left. When it runs out, or as soon as the client sends anything, the handler's handle_greet/2 gets the early input ("" if none) and decides whether to greet the client. Early input of a client let in is then processed as commands.

Tarpit

With :tarpit_delay, every error reply from the :tarpit_afterth on adds that many milliseconds to the session's delay: the caller waits that long before sending the output (see take_delay/1). This slows down dictionary attacks and other clients that keep getting errors.

Pipelining

With forbid_unauth_pipelining: true, a client that sends more input after a command that must end a group (RFC 2920 §3.1: EHLO, DATA, NOOP, ...), or after any command before PIPELINING was offered, gets 554 5.5.0 and is disconnected: it did not wait for the reply.

XCLIENT and XFORWARD

Clients in :xclient_networks may use Postfix's XCLIENT command, and clients in :xforward_networks its XFORWARD command (see https://www.postfix.org/XCLIENT_README.html and XFORWARD_README.html). Both are offered in the EHLO reply only to those clients.

XCLIENT is for proxies such as nginx: it replaces what the session knows about the client (ADDR, PORT, NAME, REVERSE_NAME, DESTADDR, DESTPORT, HELO, PROTO, LOGIN), as if the client had connected itself. The handler's state is ended with terminate/2 and started again with init/2 and the new connection map, which has :client_name, :reverse_name, and :login when given; HELO is passed to handle_helo/3, and LOGIN counts as authenticated. The reply is a new greeting. The client that may use XCLIENT is decided at connect, so it may send it again.

XFORWARD is for content filters that send mail back: the attributes (NAME, ADDR, PORT, PROTO, HELO, IDENT, SOURCE) describe the original client of the next transaction only, and reach the handler in the transaction's :xforward map. They change nothing else.

SMTPUTF8

With smtputf8: true, SMTPUTF8 (RFC 6531) is offered in the EHLO and LHLO replies. A MAIL FROM with the SMTPUTF8 parameter sets smtputf8: true in the mail parameters, and lets the sender and the recipients of that transaction be internationalized addresses, with UTF-8 local parts and U-label domains (Sovite.Validators). Without it, such an address gets 553 5.6.7. Domains in U-labels reach the handler, and the transaction, converted to A-labels (Sovite.Validators.ascii_domain/1), so every domain has one form. The message may then have UTF-8 header fields (RFC 6532).

CHUNKING and BINARYMIME

With chunking: true, CHUNKING (RFC 3030) is offered: the client may send the message in BDAT chunks instead of with DATA. Each chunk is passed to the handler as it arrives, as DATA content is, but there is no dot-stuffing; bare line endings get the :bare_line_endings policy all the same. The first chunk calls handle_data/2, every chunk but the last is answered with 250, and BDAT ... LAST ends the message like the final dot. A chunk is always read to its end, also when it is refused, so its content can never be taken for commands.

With binarymime: true as well, BINARYMIME is offered: MAIL FROM with BODY=BINARYMIME sets body: :binarymime, and the message must be sent with BDAT. Its content is binary: it is passed on unchecked.

DSN

With dsn: true, DSN (RFC 3461) is offered. MAIL FROM may then have RET and ENVID (in the mail parameters), and RCPT TO may have NOTIFY and ORCPT (in the recipient parameters passed to handle_rcpt/3), see Sovite.SMTP.DSNParams. Acting on them is up to the handler.

ETRN

With etrn: true and a handler with handle_etrn/2, ETRN (RFC

  1. is offered: the client asks for the mail queued for its domain to be sent now. Not allowed in a mail transaction.

MT-PRIORITY, FUTURERELEASE, DELIVERBY, and RRVS

These are offered on request, and their parameters (see Sovite.SMTP.Params) reach the handler checked but not acted on:

  • mt_priority: true - MT-PRIORITY (RFC 6710): MAIL FROM may have MT-PRIORITY=-9..9 (priority in the mail parameters). The handler must not let untrusted clients raise it.
  • future_release: seconds - FUTURERELEASE (RFC 4865), with HOLDFOR and HOLDUNTIL up to that many seconds ahead (hold).
  • deliverby: true - DELIVERBY (RFC 2852): BY=seconds;R|N[T] (by). With a hold as well, the message must be due after its release.
  • rrvs: true - RRVS (RFC 7293): RCPT TO may have RRVS=time[;R|;C] (rrvs in the recipient parameters).

Options

  • :hostname - name in the greeting and EHLO reply. Required.
  • :handler - {module, opts}. Required.
  • :max_message_size - bytes, advertised with SIZE and enforced while streaming. Defaults to 10 MiB.
  • :max_recipients - per transaction. Defaults to 100.
  • :max_errors - error replies before the session is closed. Defaults to 10.
  • :max_line_length - command line length, including CRLF. Defaults to 2048.
  • :command_timeout / :data_timeout - milliseconds to wait for input. Default to 5 minutes (RFC 5321 §4.5.3.2.7).
  • :vrfy - answer VRFY with the handler. Defaults to false.
  • :bare_line_endings - :reject (default) or :normalize, see Sovite.SMTP.DataDecoder. Under :reject, a bare LF or CR in a command or in the data closes the session with 521.
  • :starttls - offer STARTTLS. Defaults to false.
  • :require_tls - refuse MAIL, RCPT, DATA, VRFY, and AUTH with 530 5.7.0 until the connection is encrypted. Defaults to false.
  • :auth - offer AUTH. Defaults to false.
  • :auth_required - refuse MAIL with 530 5.7.0 until the client has authenticated. Defaults to false.
  • :plaintext_auth - offer AUTH on unencrypted connections too. Defaults to false: passwords are never sent in the clear.
  • :max_auth_failures - failed AUTH attempts before the session is closed. Defaults to 3.
  • :lmtp - speak LMTP instead of SMTP. Defaults to false.
  • :requiretls - offer REQUIRETLS (RFC 8689) on encrypted connections. A MAIL FROM with the REQUIRETLS parameter sets requiretls: true in the mail parameters: the handler must then only relay the message over TLS that is verified with DANE or MTA-STS. Defaults to false.
  • :smtputf8 - offer SMTPUTF8, see "SMTPUTF8". Defaults to false.
  • :chunking / :binarymime - offer CHUNKING and BINARYMIME, see "CHUNKING and BINARYMIME". Default to false.
  • :dsn - offer DSN, see "DSN". Defaults to false.
  • :etrn - offer ETRN, see "ETRN". Defaults to false.
  • :mt_priority, :future_release, :deliverby, :rrvs - see "MT-PRIORITY, FUTURERELEASE, DELIVERBY, and RRVS". Default to false (nil for :future_release).
  • :tarpit_after - errors before the tarpit starts. Defaults to 3.
  • :tarpit_delay - milliseconds per error reply after that. Defaults to 0: no tarpit.
  • :forbid_unauth_pipelining - see "Pipelining". Defaults to false.
  • :xclient_networks / :xforward_networks - Sovite.Net networks whose clients may use XCLIENT / XFORWARD. Default to [].

The connection map may carry :tls (a Sovite.TLS.info()) when it is encrypted from the start (implicit TLS, RFC 8314).

Telemetry

[:sovite, :smtp, :server, :command, :stop] for every reply, with %{duration} and %{session_id, remote_ip, command, argument, reply_code, reply}. command is the verb ("MAIL"), "UNKNOWN", or "END-OF-MESSAGE" for the final dot or BDAT ... LAST. argument is only set for EHLO, HELO, LHLO, MAIL, RCPT, VRFY, and ETRN, and is network input. For AUTH it is the mechanism; SASL responses are never included.

Summary

Types

Who is connected. Passed to the handler's init/2.

MAIL FROM parameters. Those not given are nil (false for requiretls and smtputf8).

RCPT TO parameters (DSN and RRVS), nil when not given.

t()

The current mail transaction. Recipients are in the order given.

What XFORWARD said about the original client, see "XCLIENT and XFORWARD". :addr is an IP address, :port an integer, :source "LOCAL" or "REMOTE"; the others are strings.

Functions

Processes bytes received from the client.

The client sent nothing for timeout/1 milliseconds.

The TLS handshake after STARTTLS succeeded. Resets the session to its initial state, as RFC 3207 §4.2 requires, and tells the handler.

Returns the authenticated identity, or nil.

Whether the session is between transactions: no mail transaction, message data, AUTH exchange, or TLS handshake in progress. A server that shuts down gracefully closes idle sessions first, and the others once they become idle.

Starts a session for connection and returns the greeting. A :session_id is generated if missing.

Returns the session ID.

Returns the milliseconds to wait before sending the output of the last call (see "Tarpit" above), and resets it.

Ends the session, for example when the connection closed or the server shuts down. Calls the handler's terminate/2.

Milliseconds to wait for more input before calling handle_timeout/1.

Returns the TLS details if the connection is encrypted, else nil.

Types

connection()

@type connection() :: %{
  :session_id => String.t(),
  :remote_ip => :inet.ip_address(),
  optional(:remote_port) => :inet.port_number(),
  optional(:local_ip) => :inet.ip_address(),
  optional(:local_port) => :inet.port_number(),
  optional(:listener) => String.t(),
  optional(:tls) => Sovite.TLS.info() | nil,
  optional(:client_name) => String.t() | nil,
  optional(:reverse_name) => String.t() | nil,
  optional(:login) => String.t() | nil
}

Who is connected. Passed to the handler's init/2.

mail_params()

@type mail_params() :: %{
  size: non_neg_integer() | nil,
  body: :"7bit" | :"8bitmime" | :binarymime | nil,
  requiretls: boolean(),
  smtputf8: boolean(),
  ret: Sovite.SMTP.DSNParams.ret() | nil,
  envid: String.t() | nil,
  priority: -9..9 | nil,
  hold: Sovite.SMTP.Params.hold() | nil,
  by: Sovite.SMTP.Params.by() | nil
}

MAIL FROM parameters. Those not given are nil (false for requiretls and smtputf8).

rcpt_params()

@type rcpt_params() :: %{
  notify: Sovite.SMTP.DSNParams.notify() | nil,
  orcpt: Sovite.SMTP.DSNParams.orcpt() | nil,
  rrvs: Sovite.SMTP.Params.rrvs() | nil
}

RCPT TO parameters (DSN and RRVS), nil when not given.

result()

@type result() :: {:continue | :close | :starttls, iodata(), t()}

t()

@opaque t()

transaction()

@type transaction() :: %{
  sender: String.t(),
  params: mail_params(),
  recipients: [String.t()],
  xforward: xforward()
}

The current mail transaction. Recipients are in the order given.

xforward()

@type xforward() :: %{
  optional(:name) => String.t() | nil,
  optional(:addr) => :inet.ip_address() | nil,
  optional(:port) => :inet.port_number() | nil,
  optional(:proto) => String.t() | nil,
  optional(:helo) => String.t() | nil,
  optional(:ident) => String.t() | nil,
  optional(:source) => String.t() | nil
}

What XFORWARD said about the original client, see "XCLIENT and XFORWARD". :addr is an IP address, :port an integer, :source "LOCAL" or "REMOTE"; the others are strings.

Functions

handle_input(session, bytes)

@spec handle_input(t(), binary()) :: result()

Processes bytes received from the client.

handle_timeout(session)

@spec handle_timeout(t()) :: result()

The client sent nothing for timeout/1 milliseconds.

handle_tls(session, info)

@spec handle_tls(t(), Sovite.TLS.info()) :: result()

The TLS handshake after STARTTLS succeeded. Resets the session to its initial state, as RFC 3207 §4.2 requires, and tells the handler.

identity(session)

@spec identity(t()) :: String.t() | nil

Returns the authenticated identity, or nil.

idle?(session)

@spec idle?(t()) :: boolean()

Whether the session is between transactions: no mail transaction, message data, AUTH exchange, or TLS handshake in progress. A server that shuts down gracefully closes idle sessions first, and the others once they become idle.

new(connection, opts)

@spec new(map(), keyword()) :: result()

Starts a session for connection and returns the greeting. A :session_id is generated if missing.

session_id(session)

@spec session_id(t()) :: String.t()

Returns the session ID.

take_delay(session)

@spec take_delay(t()) :: {non_neg_integer(), t()}

Returns the milliseconds to wait before sending the output of the last call (see "Tarpit" above), and resets it.

terminate(session, reason)

@spec terminate(t(), term()) :: :ok

Ends the session, for example when the connection closed or the server shuts down. Calls the handler's terminate/2.

timeout(session)

@spec timeout(t()) :: timeout()

Milliseconds to wait for more input before calling handle_timeout/1.

tls(session)

@spec tls(t()) :: Sovite.TLS.info() | nil

Returns the TLS details if the connection is encrypted, else nil.