Sovite.SMTP.Client (sovite v0.2.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. The body is dot-stuffed while streaming with Sovite.SMTP.DataEncoder.

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.

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 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.

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
  | {:invalid_address, String.t()}

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.

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
  | :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", or nil (not declared).

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).

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.