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/4other than:acceptor:discardis about that recipient only. - After a reply other than
:continuetoconnect_info/4orhelo/3, or a:shutdownreply, 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.codeis 4xx or 5xx,enhancedan RFC 3463 code ornil, andtexta string, or a list of lines for a multi-line reply.:shutdown- close the SMTP session with a 421 reply (SMFIR_SHUTDOWNorSMFIR_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, seeSovite.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}, whereresultis{:ok, version}or{:error, reason}[:sovite, :milter, :reply]-%{duration},%{milter, stage, reply}, for each step the milter replied to.stageis the name of the function (:connect_info,:helo, ...,:end_of_message), andreplyareply/0, or:skipfor a body chunk. At:end_of_messagethe metadata also hasmodifications, 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.
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.
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
@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.
@type client() :: {:inet.ip_address(), :inet.port_number()} | {:unix, Path.t()} | :unknown
The SMTP client, as passed to connect_info/4.
@type error() :: :timeout | :closed | {:protocol, protocol_error()} | :inet.posix()
Why a call failed. The connection is closed.
@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 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.
@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 positionindexof the header (0 is the top).{:change_header, index, name, value}- replace theindexth (from 1) field calledname.{:delete_header, index, name}- delete theindexth field calledname.{: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 "".
@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.
@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.
@opaque 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.
@spec close(t()) :: :ok
Closes the connection.
@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.
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.
Modifications come only with a final :continue or :accept; the
caller should drop them with any other reply.
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.
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"}}
@spec quit(t()) :: :ok
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.