Skip to content

gertvdijk/purepythonmilter

Repository files navigation

A modern pure-Python Milter framework

Python 3.10+ Checked with mypy Code style: black Ruff Hadolint ShellCheck License: Apache 2.0 REUSE compliant

Mail servers (MTAs) like Postfix and Sendmail can connect to an external filter process, called a 'Milter', for actions to take during an incoming SMTP transaction. You may consider it like a plugin on the mail server software using callbacks over a TCP or UNIX socket.

A Milter can have any custom condition to reject/tempfail/discard a message, manipulate headers and/or body and more. This can be useful if you require custom validations or manupulative actions before mail is accepted and that is unavailable in your MTA's Postfix's built-in features. The use of a Milter would typically be the right choice when it comes to complex decision making on accepting mail 'before queue' with conditions on headers or the message body.

Purepythonmilter aims to be a modern, Postfix-first, high-quality, strictly typed framework and library. And then all of that with an easy to use API and a high-performance asynchronous embedded server.

Getting started 🚀

Install Purepythonmilter, e.g. using pip:

$ pip install purepythonmilter

Self-descriptive example Milter app:

import purepythonmilter as ppm


async def on_mail_from(cmd: ppm.MailFrom) -> ppm.VerdictOrContinue:
    if cmd.address.lower().endswith("@example.com"):
        return ppm.RejectWithCode(primary_code=(5, 7, 1), text="not allowed here!")
    return ppm.Continue()


mymilter = ppm.PurePythonMilter(name="mymilter", hook_on_mail_from=on_mail_from)
mymilter.run_server(host="127.0.0.1", port=9000)

Configuration with Postfix

  1. Start your Milter application or run one of the examples directly — see examples/.
  2. Start a Postfix instance with a configuration like smtpd_milters = inet:127.0.0.1:9000 (replace IP address and port number accordingly).

Run an example Milter app with Postfix in Docker

Described here 👉 examples/README.md.

Example use cases for a Milter app 💡

  • From-header and envelope sender (Return-Path) alignment validation, for compliance with DMARC (RFC7489 section 3.1) or reasons of preventing abuse (impersonation). Pevent sending such messages out by rejecting non-compliant messages on submission time already and incude a descriptive error message to the user.
  • Encrypt sensitive connection/account information and attach that in a custom header value for outbound mail. In case of abuse, the information can be decrypted by an operator from the raw mails concerned and eliminates the need to store this data centrally for all mail.
  • Cryptographically sign outgoing email or verify signatures of incoming email with some custom scheme. In case you don't like the existing commonly used OpenDKIM Milter and want to implement your own DKIM signer/verifier.

What about PyMilter?

Purepythonmilter was written as an alternative to, and, out of frustration with it. PyMilter is not type annotated (mypy), has signal handling issues (for me), the dependency on a hand-crafted Python-C-extension linking to Sendmail's libmilter and no offering of a binary package (wheel) to ease installation. 😥

By the way, did you know that Sendmail is — yes even in 2023 — written in K&R C (predating ANSI-C)?1 🙈

So, yeah, that's the short version of why I started this project. 🤓

Documentation 📖

Limitations

  • Any functionality requiring intermediary responses (such as 'progress') is not yet implemented in the API.
  • Any functionality that requires carrying state over phases is not yet supported in the API. (e.g. combining input from two different hooks)
  • Mail headers are not 'folded'/'unfolded', but given or written as-is.
  • UNIX domain sockets are not supported for the Milter server to listen on (TCP is).

Feedback 💬

This project is very new and feedback is very much welcome! Please don't hesitate to file an issue, drop an idea or ask a question in the discussions.

Ideas & Feature Requests are in there too. 💡

Alternatively, just drop me a message at github@gertvandijk.nl. 📬

When not to use a Milter

If you want to accomplish something that could be done using a custom dynamic lookup in Postfix, such as message routing or policy lookups. Postfix offers quite some built-in dynamic lookup types and a Milter is probably not what you're looking for. The Milter protocol is relatively complex and its use may not be required for your use case.

Be sure to also have a look at implementing your own custom dynamic lookup table in Postfix using the socketmap protocol or policy delegation with the much simpler policy delegation protocol. Most of the email's and connection's metadata is available there too. For example, the postfix-mta-sts-resolver uses the former and the SPF policy daemon pypolicyd-spf uses the latter. Sometimes the use of a Milter may still be considered; for example, the SPF verification filter spf-milter is implemented using the Milter protocol.

For content inspection, there's Postfix's Content filter, but beware that it's running 'after queue'. It takes quite some orchestration to avoid bounces and correctly feed the mail back into Postfix.

Another aspect to consider is MTA support. While the alternatives for Postfix listed above are still Postfix-specific, other more generic lookup methods also exist. For example, a dynamic DNS lookup could be much better adopted when migrating to another MTA than any of the above.

Example use cases which are possible to implement using a Milter, but what could also be accomplished using alternative — likely simpler — ways:

  • Inject custom headers to add information on which smtpd instance the email was received for routing/classifications later. This would typically be done using Postfix's policy delegation returning PREPEND headername: headertext as action.
  • Validate sender restrictions for a data backend type not supported by the Postfix, such as interacting with an HTTP REST API / webhooks. Again, policy delegation may be much simpler, but if conditions involve mail contents, then you may need a Milter still.
  • Custom centralized rate limiting and billing in an email hosting platform with several account tiers. And similarly for this one, policy delegation is probably much simpler.
  • A read-only Milter that logs in a structured way and perhaps with certain conditions. This would eliminate parsing Postfix's text log files, well, for incoming connections at least. Freeaqingme/ClueGetter is such an application using the Milter protocol for a part of the functionality.

Alternatives to Purepythonmilter

Most Python alternatives appear to be unmaintained and no longer actively supported for years.

Alternatives in other programming languages without a dependency on Sendmail's libmilter are:

Other relevant projects (not really reusable libraries): phalaaxx/ratemilter, phalaaxx/pf-milters, mschneider82/milterclient, andybalholm/grayland, Freeaqingme/ClueGetter.

License

The major part of the project is Apache 2.0 licensed.

Files deemed insignificant in terms of copyright such as configuration files are licensed under the public domain "no rights reserved" CC0 license.

The repositoy is REUSE compliant.

Footnotes

  1. Sendmail 8.71.1 Release notes:

    2021/08/17

    Deprecation notice: due to compatibility problems with some third party code, we plan to finally switch from K&R to ANSI C. If you are using sendmail on a system which does not have a compiler for ANSI C contact us with details as soon as possible so we can determine how to proceed.