Skip to content

Milter filters

Many established mail filters are not written for any one MTA. Instead, they speak a common protocol that lets an MTA consult them while it receives a message. A milter (mail filter) is such an external program, and the protocol it speaks was introduced by Sendmail and is supported by most MTAs.

Many filters are available as milters. Among them are virus scanners such as ClamAV, spam filters such as Rspamd and SpamAssassin, and DKIM and DMARC tools.

A milter receives the SMTP envelope and the message. It answers with a decision, which is one of accept, reject, discard, quarantine or a temporary failure. Optionally, it also returns changes to the envelope, the headers or the body.

Each milter is an MtaMilter object (found in the WebUI under Settings › MTA › Filters › Milters). When several milters are defined, they are consulted in the order in which they were created. Every milter receives the same envelope and message, because Stalwart applies the requested changes only after all milters and MTA hooks have run. A milter therefore does not see the changes requested by another one, and a recipient change that the milters must see belongs in the rewrite expression of the RCPT stage. The changes requested by all of them are applied to the message, with one exception: only the last body replacement is kept. Filtering describes where milters run relative to the other filters.

A milter is reached at its host and port. Milters usually run on the same host or network and are reached without TLS. For a milter elsewhere, useTls connects over TLS instead, and allowInvalidCerts accepts an invalid certificate from it.

Not every session needs every milter. The enable expression decides, for each session, whether the milter is consulted at all. This lets a milter be limited to one listener, as in the example below.

The next decision is at which points of the session the milter is asked. The stages field lists the SMTP stages at which the milter is consulted: connect, ehlo, mail, rcpt and data. At each of them, Stalwart opens a connection to the milter and sends it what is known at that point:

  • At connect, the client’s address.
  • At ehlo, its EHLO name.
  • At mail, the sender.
  • At rcpt, the recipients.
  • At data, the headers and body as well.

A milter can reject the session at any of these stages, but changes to the message are applied only at data.

A new milter is consulted at data only. That is sufficient for content filters such as virus scanners, which need the message itself. The earlier stages are useful for a different purpose: they let a milter reject a client before it transfers the message. The value auth is accepted in stages, but no milter is consulted at the AUTH stage.

The following objects consult Rspamd at every stage and ClamAV at DATA, both for mail received on the listener named smtp:

[
{
"host": "127.0.0.1",
"port": 11332,
"stages": {"connect": true, "ehlo": true, "mail": true, "rcpt": true, "data": true},
"enable": {"else": "listener == 'smtp'"}
},
{
"host": "127.0.0.1",
"port": 7357,
"stages": {"data": true},
"enable": {"else": "listener == 'smtp'"}
}
]

While a milter is working, the SMTP client waits for its reply. Four limits therefore protect the session from a milter that is slow or misbehaving:

FieldLimits
connectTimeoutConnecting to the milter.
commandTimeoutThe milter’s answer to each command.
dataTimeoutThe milter’s answer after the message body.
maxResponseSizeThe size of one response frame of the milter protocol. A replaced body sent in several frames is limited per frame.

A milter can also be unreachable, time out or fail. In that case tempFailOnError decides the outcome, and it is enabled for a new milter. The setting is a choice between two risks:

  • When it is enabled, the command is answered with 451 4.3.5 Unable to accept message at this time. The sender retries later, so no message passes unfiltered.
  • When it is disabled, the milter is skipped and the message is accepted without it. This keeps mail flowing during a milter outage, at the cost of letting unfiltered mail through.

The milter protocol exists in several versions. protocolVersion selects version v6 or version v2. A new milter uses v6, and v2 is needed only for old milters.

At the start of each connection, the MTA and the milter negotiate what the milter may do. During this option negotiation, Stalwart offers the milter two bit masks defined by the milter protocol:

  • flagsAction: the SMFIF_* actions the milter may perform.
  • flagsProtocol: the SMFIP_* protocol steps the milter may ask to skip.

When the masks are not set, Stalwart offers all supported actions (255) and the protocol mask 66, with which a milter can ask to skip the HELO and end-of-headers steps. Set them only when a milter requires a different negotiation.

The milter’s decision determines the reply that the SMTP client receives:

DecisionReply
Reject503 5.5.3 Message rejected.
Temporary failure451 4.3.5 Unable to accept message at this time.
Discard250 2.0.0 Message queued for delivery.
Reply code (SMFIR_REPLYCODE)The reply returned by the milter.

A discard answers the client as if the command had succeeded, but drops what the command applies to. At DATA, that is the message. At MAIL FROM and RCPT TO, it is the sender or the recipient, which is dropped as if it had been refused.

The stage also decides whether the session goes on. At the connect stage, any decision other than accept or continue closes the connection after the reply. At the later stages, the session continues.

A quarantine request does not stop delivery. It adds an X-Quarantine header with the milter’s reason, and the message is delivered.

The following sections are intended for developers of milters. They list the responses Stalwart accepts from a milter and the macros it sends.

One detail of the body step affects milters that parse the body. At the body step, Stalwart sends the complete message, header fields included, where a milter might expect only the body that follows the end of the headers. Such a milter therefore receives the header fields twice: once at the header step and again at the start of the body.

A milter answers each protocol step with a response code. Stalwart accepts the following codes, which request actions and modifications:

CodeNameEffect
SMFIR_ADDRCPT (+)Add RecipientAdds a recipient to the message.
SMFIR_DELRCPT (-)Delete RecipientRemoves a recipient from the message.
SMFIR_ADDRCPT_PAR (2)Add Recipient (with ESMTP args)Adds a recipient to the message, with ESMTP arguments.
SMFIR_SHUTDOWN (4)ShutdownReplies 421 4.3.0 Server shutting down., and closes the connection only at the connect stage.
SMFIR_ACCEPT (a)AcceptAccepts the message.
SMFIR_REPLBODY (b)Replace BodyReplaces the body of the message.
SMFIR_CONTINUE (c)ContinueContinues with the message.
SMFIR_DISCARD (d)DiscardDiscards the message.
SMFIR_CHGFROM (e)Change FromChanges the envelope sender of the message.
SMFIR_CONN_FAIL (f)Connection FailureCloses the connection at the connect stage. At the later stages, no reply is sent and the session continues.
SMFIR_ADDHEADER (h)Add HeaderAdds a header field to the message.
SMFIR_INSHEADER (i)Insert HeaderInserts a header field into the message.
SMFIR_CHGHEADER (m)Change HeaderChanges a header field of the message.
SMFIR_PROGRESS (p)ProgressReports that the milter is still working.
SMFIR_QUARANTINE (q)QuarantineQuarantines the message.
SMFIR_REJECT (r)RejectRejects the message.
SMFIR_SKIP (s)SkipSkips the rest of the body and proceeds to the end of the body.
SMFIR_TEMPFAIL (t)Temporary FailureReturns a temporary failure.
SMFIR_REPLYCODE (y)Reply CodeReplies with the given SMTP reply code.

Macros are named values that the MTA passes to the milter alongside each protocol step. Stalwart sends the following macros, each at the step at which its value becomes known:

StepMacros
Connect{daemon_name}, j, {client_addr}, {client_port}, {client_ptr}
HELO{cipher}, {tls_version}
MAIL{mail_addr}, {auth_authen}
RCPT{rcpt_addr}

The j macro carries the server’s host name. A milter consulted at the connect stage is the exception and receives it empty, because the host name is determined after the connect-stage filters have run.

No macros are sent at the DATA step. The queue identifier macro i is therefore not available.