Sovite.SMTP.Client (sovite v0.6.0)

Copy Markdown View Source

An SMTP client (RFC 5321) for relaying messages to another server.

{:ok, client} = Client.connect({192, 0, 2, 25}, 25, helo: "mx.example.com")
{:ok, client, results} = Client.deliver(client, "a@example.com", ["b@example.net"], body)
:ok = Client.quit(client)

connect/3 reads the greeting and sends EHLO, falling back to HELO when the server rejects EHLO with a 5xx reply. One connection can carry any number of deliver/5 transactions.

With PIPELINING (RFC 2920), MAIL, every RCPT, and DATA are sent in one batch. SIZE (RFC 1870) and BODY=8BITMIME (RFC 6152) are sent when the server supports them, and REQUIRETLS (RFC 8689) and SMTPUTF8 (RFC 6531) when asked for. The body is dot-stuffed while streaming with Sovite.SMTP.DataEncoder, or sent in BDAT chunks of up to 1 MiB (CHUNKING, RFC 3030) when asked for and supported, and always for BINARYMIME. DSN parameters (RFC 3461) are sent to servers that support DSN, and left out for the others.

Replies are parsed with Sovite.SMTP.Reply.decode/2, so a hostile server cannot make the client buffer without limit. Every wait has a timeout; the defaults are those of RFC 5321 §4.5.3.2.

starttls/2 upgrades a connection (RFC 3207) and authenticate/3 logs in with SASL (RFC 4954). With the :tls option the connection is encrypted from the start instead (implicit TLS, RFC 8314).

LMTP

With protocol: :lmtp the client speaks LMTP (RFC 2033) instead: it greets with LHLO, and after the data the server answers once for each accepted recipient, so every recipient gets its own result at :data_end. LMTP servers usually listen on a Unix socket: pass {:local, path} as the address (the port is ignored).

Options

  • :helo - name to send in EHLO/HELO (or LHLO). Required.
  • :protocol - :smtp (default) or :lmtp.
  • :connect_timeout - milliseconds. Defaults to 30 seconds.
  • :greeting_timeout - for the 220 greeting. Defaults to 5 minutes.
  • :command_timeout - for EHLO, MAIL, RCPT, RSET, and QUIT replies. Defaults to 5 minutes.
  • :data_timeout - for the reply to DATA. Defaults to 2 minutes.
  • :send_timeout - for each block of message data. Defaults to 3 minutes.
  • :data_end_timeout - for the reply after the final dot. Defaults to 10 minutes.
  • :local_address - local IP address to connect from. Not used for Unix sockets.
  • :max_line_length / :max_lines - reply limits, see Sovite.SMTP.Reply.decode/2.
  • :tls - :ssl client options to use implicit TLS: the handshake runs right after connecting, before the greeting.
  • :tls_timeout - for TLS handshakes. Defaults to 60 seconds.

Summary

Types

Where to connect: an IP address, or a Unix socket.

DSN parameters (RFC 3461) for deliver/5: RET and ENVID for the message, and NOTIFY and ORCPT by recipient (nil when not given).

A failure that ends the connection. The reason is a rejection reply (at :greeting, :ehlo, :helo, or :lhlo), :timeout, :closed, a Sovite.SMTP.Reply.decode_error(), a socket error, or {:tls, reason} for a failed TLS handshake (at :tls or :starttls).

Why deliver/5 did not start a transaction. The connection stays usable.

The outcome for one recipient: the reply that decided it, and the command it answered. A 2xx reply at :data_end means the server took the message for this recipient.

The command a reply or error belongs to. :data_end is the final dot: after an error there, the server may or may not have accepted the message.

t()

Functions

Authenticates with SASL (RFC 4954), using the first of mechanisms the server offers.

Closes the connection without QUIT.

Connects, reads the greeting, and sends EHLO (or HELO, or LHLO).

Returns the extensions the server announced: upper-cased keywords mapped to their parameters ("" if none).

Returns true if the connection speaks LMTP.

Returns the local address the connection was asked to use (:local_address), if any.

Returns the server's address and port.

Sends QUIT and closes the connection. Errors are ignored.

Returns the name the server gave in its EHLO/HELO reply, for example to detect a connection to itself.

Upgrades the connection with STARTTLS and sends EHLO again, as RFC 3207 requires.

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

Types

address()

@type address() :: :inet.ip_address() | {:local, Path.t()}

Where to connect: an IP address, or a Unix socket.

dsn()

@type dsn() :: %{
  optional(:ret) => Sovite.SMTP.DSNParams.ret() | nil,
  optional(:envid) => String.t() | nil,
  optional(:recipients) => %{
    required(String.t()) => %{
      optional(:notify) => Sovite.SMTP.DSNParams.notify() | nil,
      optional(:orcpt) => Sovite.SMTP.DSNParams.orcpt() | nil
    }
  }
}

DSN parameters (RFC 3461) for deliver/5: RET and ENVID for the message, and NOTIFY and ORCPT by recipient (nil when not given).

error()

@type error() ::
  {stage(),
   Sovite.SMTP.Reply.t()
   | :timeout
   | :closed
   | Sovite.SMTP.Reply.decode_error()
   | {:tls, term()}
   | atom()}

A failure that ends the connection. The reason is a rejection reply (at :greeting, :ehlo, :helo, or :lhlo), :timeout, :closed, a Sovite.SMTP.Reply.decode_error(), a socket error, or {:tls, reason} for a failed TLS handshake (at :tls or :starttls).

refusal()

@type refusal() ::
  {:message_too_large, pos_integer()}
  | :eight_bit_not_supported
  | :binarymime_not_supported
  | {:invalid_address, String.t()}
  | :requiretls_not_supported
  | :smtputf8_not_supported

Why deliver/5 did not start a transaction. The connection stays usable.

  • {:message_too_large, limit} - larger than the server's SIZE.
  • :eight_bit_not_supported - an 8-bit message (body_type: :"8bitmime"), but the server does not support 8BITMIME.
  • {:invalid_address, address} - not a valid mailbox.
  • :requiretls_not_supported - requiretls: true, but the connection is not encrypted or the server does not offer REQUIRETLS.
  • :smtputf8_not_supported - smtputf8: true, but the server does not offer SMTPUTF8.
  • :binarymime_not_supported - a binary message (body_type: :binarymime), but the server does not offer BINARYMIME and CHUNKING.

result()

@type result() :: {recipient :: String.t(), stage(), Sovite.SMTP.Reply.t()}

The outcome for one recipient: the reply that decided it, and the command it answered. A 2xx reply at :data_end means the server took the message for this recipient.

stage()

@type stage() ::
  :connect
  | :tls
  | :greeting
  | :ehlo
  | :helo
  | :lhlo
  | :starttls
  | :auth
  | :mail
  | :rcpt
  | :data
  | :data_end
  | :rset
  | :xforward
  | :quit

The command a reply or error belongs to. :data_end is the final dot: after an error there, the server may or may not have accepted the message.

t()

@opaque t()

Functions

authenticate(client, credentials, mechanisms \\ ["SCRAM-SHA-256", "PLAIN", "LOGIN"])

@spec authenticate(t(), map(), [String.t()]) ::
  {:ok, t()} | {:error, t(), term()} | {:error, error()}

Authenticates with SASL (RFC 4954), using the first of mechanisms the server offers.

credentials has :username and :password (or :token for OAUTHBEARER). Mechanisms default to ["SCRAM-SHA-256", "PLAIN", "LOGIN"]. Do not send passwords over an unencrypted connection.

Returns {:error, client, reason} with the connection still usable: :no_mechanism (none offered in common), {:rejected, reply}, or a {:sasl, reason} from the mechanism (such as a server whose SCRAM-SHA-256 signature is wrong).

close(client)

@spec close(t()) :: :ok

Closes the connection without QUIT.

connect(address, port, opts)

@spec connect(address(), :inet.port_number(), keyword()) ::
  {:ok, t()} | {:error, error()}

Connects, reads the greeting, and sends EHLO (or HELO, or LHLO).

deliver(client, sender, recipients, body, opts \\ [])

@spec deliver(t(), String.t(), [String.t(), ...], Enumerable.t(), keyword()) ::
  {:ok, t(), [result()]} | {:error, t(), refusal()} | {:error, error()}

Sends one message to recipients.

body is an enumerable of iodata chunks with CRLF line endings, not dot-stuffed. Use sender "" for the null reverse-path.

Returns one result per recipient, in order. The transaction is reset when it fails, so the connection can be reused after {:ok, ...} and {:error, client, refusal}.

Options

  • :size - the message size in bytes, sent with SIZE and checked against the server's limit.
  • :body_type - :"7bit", :"8bitmime", :binarymime, or nil (not declared).
  • :chunking - send the message with BDAT when the server offers CHUNKING. Defaults to false.
  • :dsn - DSN parameters, see dsn/0, sent when the server offers DSN.
  • :priority - MT-PRIORITY (RFC 6710), sent when the server offers it.
  • :deliver_by - BY (RFC 2852), with the seconds left now, sent when the server offers DELIVERBY.
  • :rrvs - RRVS (RFC 7293) by recipient, sent when the server offers it.
  • :requiretls - send REQUIRETLS, so the server must relay the message only over verified TLS too (RFC 8689). Defaults to false.
  • :smtputf8 - the message needs SMTPUTF8 (RFC 6531): its addresses may be internationalized, or its header fields UTF-8. Sent with MAIL; a server that does not offer it refuses the transaction. Defaults to false, and then only ASCII addresses are accepted.
  • :xforward - the original client of the message, in the shape of Sovite.SMTP.Server.Session.xforward/0, sent with Postfix's XFORWARD before MAIL when the server offers it (only the attributes it lists; nil values as [UNAVAILABLE]). For content filters that send the mail back. Its replies are not checked.

extensions(client)

@spec extensions(t()) :: %{required(String.t()) => String.t()}

Returns the extensions the server announced: upper-cased keywords mapped to their parameters ("" if none).

lmtp?(client)

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

Returns true if the connection speaks LMTP.

local_address(client)

@spec local_address(t()) :: :inet.ip_address() | nil

Returns the local address the connection was asked to use (:local_address), if any.

peer(client)

@spec peer(t()) :: {address(), :inet.port_number()}

Returns the server's address and port.

quit(client)

@spec quit(t()) :: :ok

Sends QUIT and closes the connection. Errors are ignored.

server_name(client)

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

Returns the name the server gave in its EHLO/HELO reply, for example to detect a connection to itself.

starttls(client, ssl_opts)

@spec starttls(t(), [:ssl.tls_client_option()]) ::
  {:ok, t()}
  | {:error, t(), :not_offered | {:refused, Sovite.SMTP.Reply.t()}}
  | {:error, error()}

Upgrades the connection with STARTTLS and sends EHLO again, as RFC 3207 requires.

Returns {:error, client, reason} when the connection stays usable without TLS: :not_offered (no STARTTLS extension), or {:refused, reply}. Returns {:error, {:starttls, reason}} when it is gone, for example after a failed handshake ({:tls, ssl_reason}).

tls(client)

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

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