Sovite.Milter (sovite v0.6.0)

Copy Markdown View Source

A milter client: passes SMTP sessions to a mail filter such as Rspamd, OpenDKIM, OpenDMARC, or ClamAV-milter over Sendmail's milter protocol (version 6), with the semantics of Postfix's milter support.

{:ok, milter} = Milter.connect({:inet, "127.0.0.1", 11332}, name: "rspamd")
{:ok, :continue, milter} = Milter.connect_info(milter, "mx.example.net", {{192, 0, 2, 1}, 52_314})
{:ok, :continue, milter} = Milter.helo(milter, "mx.example.net")
{:ok, :continue, milter} = Milter.mail(milter, "alice@example.net", ["SIZE=1234"])
{:ok, :continue, milter} = Milter.rcpt(milter, "bob@example.com")
{:ok, :continue, milter} = Milter.data(milter)
{:ok, :continue, milter} = Milter.header(milter, "Subject", " Hello")
{:ok, :continue, milter} = Milter.end_of_headers(milter)
{:ok, :continue, milter} = Milter.body(milter, "Hi Bob.\r\n")
{:ok, :accept, modifications, milter} = Milter.end_of_message(milter)
:ok = Milter.quit(milter)

The client is a struct owned by the calling process; every call blocks until the milter answers or a timeout expires. connect/2 negotiates the protocol version, the actions the milter may take, the steps it wants to see, and the macros it wants at each step. One connection carries one SMTP session, which can have any number of transactions: end_of_message/2 or abort/1 ends a transaction.

Steps

There is one function per SMTP step. Each returns {:ok, reply, milter} (or, at the end of the message, the modifications too):

  • A step the milter declined at negotiation (or that its protocol version does not know) is not sent, and returns :continue. The macros for a declined step are still sent, as Postfix does, so the milter can read them at later steps.
  • A step the milter will not reply to is sent without waiting, and returns :continue.
  • After the milter accepted, discarded, or rejected the message, the remaining steps of the transaction are not sent and return that reply. A reply to rcpt/4 other than :accept or :discard is about that recipient only.
  • After a reply other than :continue to connect_info/4 or helo/3, or a :shutdown reply, the milter is done with the whole session: every later step returns that reply without I/O.
  • When the milter answers a body chunk with SMFIR_SKIP, the rest of the body is not sent.

SMFIR_PROGRESS makes the client wait on, with a fresh timeout.

Replies

  • :continue - go on. At the end of the message, the same as :accept.
  • :accept - accept the message (or, at the connection steps, the session) without further filtering.
  • :reject - reject with a generic 5xx reply.
  • :tempfail - reject with a generic 4xx reply.
  • :discard - accept the message and throw it away.
  • {:reply, code, enhanced, text} - reject with this SMTP reply. code is 4xx or 5xx, enhanced an RFC 3463 code or nil, and text a string, or a list of lines for a multi-line reply.
  • :shutdown - close the SMTP session with a 421 reply (SMFIR_SHUTDOWN or SMFIR_CONN_FAIL).

Headers

Header values are as they come after the colon in the message, continuation lines and all: header/3 takes " Hello" for Subject: Hello, and header modifications carry values in the same form, with CRLF line breaks. The client adds or strips the one leading space as the milter negotiated (SMFIP_HDR_LEADSPC). Sovite.Milter.Headers.apply/2 applies header modifications to the header fields of a message.

Options

  • :name - label in telemetry metadata. Defaults to the address, as in "inet:127.0.0.1:11332".
  • :connect_timeout - milliseconds to connect. Defaults to 30 seconds.
  • :command_timeout - milliseconds to wait for the negotiation and the replies to the SMTP steps. Defaults to 30 seconds.
  • :content_timeout - milliseconds to wait for the replies to headers, the body, and the end of the message, and to send data. Defaults to 5 minutes.
  • :actions - the actions the milter may take, see Sovite.Milter.Packet. Defaults to all of them.
  • :max_packet_size - bytes in a packet from the milter. Defaults to 64 MiB.

Errors

connect/2 returns {:error, {:connect, reason}} when the connection fails. Any call returns {:error, reason} when the milter does not answer in time (:timeout), closes the connection (:closed), the socket fails (an :inet.posix() reason), or it breaks the protocol ({:protocol, protocol_error()}). The connection is closed then, and the client cannot be used again.

Telemetry

  • [:sovite, :milter, :connect, :start] - %{system_time}, %{milter, address}
  • [:sovite, :milter, :connect, :stop] - %{duration}, %{milter, address, result}, where result is {:ok, version} or {:error, reason}
  • [:sovite, :milter, :reply] - %{duration}, %{milter, stage, reply}, for each step the milter replied to. stage is the name of the function (:connect_info, :helo, ..., :end_of_message), and reply a reply/0, or :skip for a body chunk. At :end_of_message the metadata also has modifications, their number.
  • [:sovite, :milter, :error] - %{}, %{milter, stage, reason}, when a call fails and closes the connection.

Summary

Types

Where the milter listens. A host is a name or an IP address; with :inet6, names are resolved to IPv6 addresses.

The SMTP client, as passed to connect_info/4.

Why a call failed. The connection is closed.

What was negotiated, from info/1.

Macros for a step (Sendmail's {client_addr}, i, {auth_authen}, ...): a map or a list of name and value pairs. When the milter asked for certain macros at a step, only those are sent.

A change the milter asks for at the end of the message, in the order it sent them.

How the milter broke the protocol: a Sovite.Milter.Packet.error(), a response that does not belong at this step, a protocol version below 2, a modification of an action it did not negotiate, or a malformed reply code.

A reply to a step. See the module documentation.

t()

Functions

Ends a transaction that did not reach end_of_message/2 (RSET, a rejected message, or a lost client). The connection stays usable for the next transaction.

Sends a piece of the body, as received (CRLF line endings, not dot-stuffed). It goes in chunks of up to 64 KiB, each one answered unless the milter negotiated otherwise, so pass large pieces.

Closes the connection.

Connects to a milter and negotiates the protocol. See the module documentation for options.

Reports the SMTP client connection. hostname is the client's verified host name; Postfix sends the address in brackets, "[192.0.2.1]", when it has none.

Reports the DATA command.

Reports the end of the header.

Reports the end of the message and collects the milter's modifications. This ends the transaction.

Sends a header field. value is everything after the colon, with continuation lines, without the final line break.

Reports the HELO or EHLO name.

Returns what was negotiated with the milter.

Reports MAIL FROM, which starts a transaction: the sender ("" for the null sender) and its ESMTP arguments, such as "SIZE=1000".

Parses a milter address in Postfix or Sendmail syntax: inet:host:port, inet:port@host, inet6:port@host, unix:/path, or local:/path. IPv6 addresses may be in brackets.

Ends the session politely and closes the connection.

Reports a RCPT TO recipient and its ESMTP arguments. A milter that negotiated :rejected_recipients (see info/1) also wants recipients the MTA rejected, with the {rcpt_mailer} macro set to "error".

Reports an SMTP command the MTA does not know, such as "FOO bar". The milter's reply is about that command only.

Types

address()

@type address() ::
  {:inet, String.t() | :inet.ip_address(), :inet.port_number()}
  | {:inet6, String.t() | :inet.ip6_address(), :inet.port_number()}
  | {:unix, Path.t()}

Where the milter listens. A host is a name or an IP address; with :inet6, names are resolved to IPv6 addresses.

client()

@type client() ::
  {:inet.ip_address(), :inet.port_number()} | {:unix, Path.t()} | :unknown

The SMTP client, as passed to connect_info/4.

error()

@type error() :: :timeout | :closed | {:protocol, protocol_error()} | :inet.posix()

Why a call failed. The connection is closed.

info()

@type info() :: %{
  name: String.t(),
  version: 2..6,
  actions: [Sovite.Milter.Packet.action()],
  protocol: [Sovite.Milter.Packet.protocol_flag()],
  macros: %{required(atom()) => [String.t()]}
}

What was negotiated, from info/1.

macros()

@type macros() :: %{optional(String.t()) => String.t()} | [{String.t(), String.t()}]

Macros for a step (Sendmail's {client_addr}, i, {auth_authen}, ...): a map or a list of name and value pairs. When the milter asked for certain macros at a step, only those are sent.

modification()

@type modification() ::
  {:add_header, String.t(), String.t()}
  | {:insert_header, non_neg_integer(), String.t(), String.t()}
  | {:change_header, non_neg_integer(), String.t(), String.t()}
  | {:delete_header, non_neg_integer(), String.t()}
  | {:add_recipient, String.t(), [String.t()]}
  | {:delete_recipient, String.t()}
  | {:change_sender, String.t(), [String.t()]}
  | {:replace_body, iodata()}
  | {:quarantine, String.t()}

A change the milter asks for at the end of the message, in the order it sent them.

  • {:add_header, name, value} - append a header field.
  • {:insert_header, index, name, value} - insert a header field at position index of the header (0 is the top).
  • {:change_header, index, name, value} - replace the indexth (from 1) field called name.
  • {:delete_header, index, name} - delete the indexth field called name.
  • {:add_recipient, address, args} - add a recipient, with ESMTP arguments.
  • {:delete_recipient, address} - remove a recipient.
  • {:change_sender, address, args} - replace the envelope sender.
  • {:replace_body, iodata} - replace the body (CRLF line endings).
  • {:quarantine, reason} - hold the message.

Addresses are without angle brackets; the null sender is "".

protocol_error()

@type protocol_error() ::
  Sovite.Milter.Packet.error()
  | {:unexpected_response, byte()}
  | {:unsupported_version, non_neg_integer()}
  | {:action_not_negotiated, Sovite.Milter.Packet.action()}
  | {:malformed_reply_code, String.t()}

How the milter broke the protocol: a Sovite.Milter.Packet.error(), a response that does not belong at this step, a protocol version below 2, a modification of an action it did not negotiate, or a malformed reply code.

reply()

@type reply() ::
  :continue
  | :accept
  | :reject
  | :tempfail
  | :discard
  | :shutdown
  | {:reply, 400..599, String.t() | nil, String.t() | [String.t()]}

A reply to a step. See the module documentation.

t()

@opaque t()

Functions

abort(milter)

@spec abort(t()) :: {:ok, t()} | {:error, error()}

Ends a transaction that did not reach end_of_message/2 (RSET, a rejected message, or a lost client). The connection stays usable for the next transaction.

body(milter, data)

@spec body(t(), iodata()) :: {:ok, reply(), t()} | {:error, error()}

Sends a piece of the body, as received (CRLF line endings, not dot-stuffed). It goes in chunks of up to 64 KiB, each one answered unless the milter negotiated otherwise, so pass large pieces.

close(milter)

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

Closes the connection.

connect(address, opts \\ [])

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

Connects to a milter and negotiates the protocol. See the module documentation for options.

Milters that answer with protocol version 2 to 5 are accepted, without the steps, flags, and actions their version does not know.

connect_info(milter, hostname, client, macros \\ [])

@spec connect_info(t(), String.t(), client(), macros()) ::
  {:ok, reply(), t()} | {:error, error()}

Reports the SMTP client connection. hostname is the client's verified host name; Postfix sends the address in brackets, "[192.0.2.1]", when it has none.

data(milter, macros \\ [])

@spec data(t(), macros()) :: {:ok, reply(), t()} | {:error, error()}

Reports the DATA command.

end_of_headers(milter, macros \\ [])

@spec end_of_headers(t(), macros()) :: {:ok, reply(), t()} | {:error, error()}

Reports the end of the header.

end_of_message(milter, macros \\ [])

@spec end_of_message(t(), macros()) ::
  {:ok, reply(), [modification()], t()} | {:error, error()}

Reports the end of the message and collects the milter's modifications. This ends the transaction.

Modifications come only with a final :continue or :accept; the caller should drop them with any other reply.

header(milter, name, value)

@spec header(t(), String.t(), String.t()) :: {:ok, reply(), t()} | {:error, error()}

Sends a header field. value is everything after the colon, with continuation lines, without the final line break.

helo(milter, name, macros \\ [])

@spec helo(t(), String.t(), macros()) :: {:ok, reply(), t()} | {:error, error()}

Reports the HELO or EHLO name.

info(milter)

@spec info(t()) :: info()

Returns what was negotiated with the milter.

mail(milter, sender, args \\ [], macros \\ [])

@spec mail(t(), String.t(), [String.t()], macros()) ::
  {:ok, reply(), t()} | {:error, error()}

Reports MAIL FROM, which starts a transaction: the sender ("" for the null sender) and its ESMTP arguments, such as "SIZE=1000".

parse_address(arg1)

@spec parse_address(String.t()) :: {:ok, address()} | {:error, :invalid_address}

Parses a milter address in Postfix or Sendmail syntax: inet:host:port, inet:port@host, inet6:port@host, unix:/path, or local:/path. IPv6 addresses may be in brackets.

iex> Sovite.Milter.parse_address("inet:127.0.0.1:11332")
{:ok, {:inet, {127, 0, 0, 1}, 11332}}
iex> Sovite.Milter.parse_address("inet:8891@localhost")
{:ok, {:inet, "localhost", 8891}}
iex> Sovite.Milter.parse_address("unix:/run/opendkim/opendkim.sock")
{:ok, {:unix, "/run/opendkim/opendkim.sock"}}

quit(milter)

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

Ends the session politely and closes the connection.

rcpt(milter, recipient, args \\ [], macros \\ [])

@spec rcpt(t(), String.t(), [String.t()], macros()) ::
  {:ok, reply(), t()} | {:error, error()}

Reports a RCPT TO recipient and its ESMTP arguments. A milter that negotiated :rejected_recipients (see info/1) also wants recipients the MTA rejected, with the {rcpt_mailer} macro set to "error".

unknown(milter, line)

@spec unknown(t(), String.t()) :: {:ok, reply(), t()} | {:error, error()}

Reports an SMTP command the MTA does not know, such as "FOO bar". The milter's reply is about that command only.