Andrew Mercer
on this page

msmtp vs. a Postfix Null Client

The situation

A bare-metal host runs a containerized Postfix relay (published on host port 25). The host itself also needs to send mail: cron failures, unattended-upgrades, logwatch, and the occasional mail command. The host should not run a full MTA. It only needs to hand every message to the relay.

 host processes (cron, mail, scripts)
        |
        |  /usr/sbin/sendmail  (msmtp  OR  postfix null client)
        v
 +--------------------------+
 | containerized postfix    |   <- owns queue, retries, SASL auth,
 | relay (host port 25)     |      sender rewriting, TLS to provider
 +--------------------------+
        |
        v
 external SMTP provider (587, TLS + auth)

Two tools can fill the "hand it to the relay" role.


Part 1 - msmtp in general

What it is

msmtp is an SMTP client. It is not a mail server and it is not a daemon. Each time something calls sendmail, msmtp starts, reads the message from stdin, connects to a configured SMTP server, delivers, and exits.

Properties that follow from that design:

  • No listener. It never binds a port, so it cannot conflict with anything.
  • No queue. If the SMTP server is unreachable, the send fails and msmtp exits non-zero. (The optional msmtpq wrapper adds a simple on-disk queue.)
  • No local delivery. Mail to root or amercer is not delivered to a mailbox. It is forwarded to the relay with whatever domain you configure, unless an aliases file maps the name to a real address.
  • sendmail-compatible CLI. On Debian/Ubuntu the msmtp-mta package symlinks /usr/sbin/sendmail to msmtp, so tools that shell out to sendmail (including mail from mailutils) work unchanged.

Install (Ubuntu/Debian)

sudo apt install msmtp msmtp-mta
ls -l /usr/sbin/sendmail      # should point at msmtp

msmtp-mta conflicts with other MTAs. If a host-level postfix or sendmail package is installed, apt will remove it or refuse. Remove the old MTA first.

Configuration

System-wide config lives in /etc/msmtprc. Per-user config in ~/.msmtprc overrides it.

Relay-through-container config (no auth, no TLS on loopback):

# /etc/msmtprc
defaults
auth            off
tls             off
syslog          on
maildomain      yourdomain.tld

account         relay
host            127.0.0.1
port            25
from            [email protected]

account default : relay

Direct-to-provider config, for contrast (auth + TLS):

account         provider
host            smtp.yourprovider.tld
port            587
tls             on
tls_starttls    on
tls_trust_file  /etc/ssl/certs/ca-certificates.crt
auth            on
user            username
passwordeval    "pass show mail/relay"
from            [email protected]

Notes on options:

Option Purpose
host, port SMTP server to hand mail to
from Envelope sender. Providers that verify sender ownership reject anything else
tls, tls_starttls, tls_trust_file Transport security and certificate verification
auth, user, password, passwordeval Authentication. passwordeval runs a command (e.g. pass) so no password sits in the file
maildomain Domain appended to bare local names like root
aliases Path to an aliases file mapping local names to real addresses
syslog, logfile Where to log each send
allow_from_override Whether callers may override the sender with -f. Check man msmtp for your version

If a config file contains a plaintext password, msmtp refuses to use it unless the permissions are 0600.

Aliases so root mail goes somewhere real:

# /etc/aliases-msmtp  (referenced with: aliases /etc/aliases-msmtp)
root: [email protected]
default: [email protected]

Testing

# Ask the server what it supports (connects, does not send)
msmtp --serverinfo --host=127.0.0.1 --port=25 --tls=off

# Send with full protocol debug
echo "test body" | msmtp -d [email protected]

# Through the sendmail shim, as cron/mail would
echo "test body" | mail -s "msmtp test" [email protected]

Part 2 - Postfix null client

What it is

A "null client" is Postfix's own name for a machine that never delivers mail locally and forwards everything to a designated relay. It is still a real Postfix installation: a master process, a pickup service, a local queue, and the postfix systemd unit.

Configuration

On Ubuntu, choose "Satellite system" in the debconf wizard, or set these directly:

# /etc/postfix/main.cf  (null client)
myhostname          = host.yourdomain.tld
myorigin            = yourdomain.tld
mydestination       =
inet_interfaces     = loopback-only
mynetworks_style    = host
local_transport     = error:local delivery is disabled
relayhost           = [127.0.0.1]:25
append_dot_mydomain = no

The brackets in relayhost stop Postfix from doing an MX lookup on the address.


Part 3 - Comparison

msmtp Postfix null client
Runs as a daemon No. Runs per message Yes. postfix.service always running
Listens on a port Never loopback-only by default (see port section)
Local queue and retry No (optional msmtpq) Yes. Retries for days, mailq / postqueue -p to inspect
If the relay is down Send fails immediately, caller sees an error Message queues and is retried
Port 25 conflict with a container Impossible Possible, must be handled (below)
Config size ~10 lines ~10 lines, plus master.cf considerations
Memory at idle Zero Several Postfix processes
sendmail compatibility Good for submission. No mailq, newaliases, etc. Full, including mailq, postsuper
Local alias / .forward handling Basic aliases file only Full alias machinery
Attack surface No listener, no long-running process Loopback listener (removable) and a root-owned daemon
Maintenance One package, almost no state Another Postfix to patch and monitor
Fits when The relay is always up and you want minimum moving parts You want local queueing and standard MTA tooling

The key behavioural difference is the queue. With a containerized relay in front, the relay already owns durable queueing and retries to the provider. The only gap is the window when the container itself is down (restart, docker compose up recreate, host boot ordering). msmtp loses mail in that window unless you add msmtpq. A Postfix null client holds the mail and delivers once the container is back.


Part 4 - Running both together without port conflicts

Why the conflict happens

The relay container publishes ports: "25:25", which binds the host's wildcard address 0.0.0.0:25. A host Postfix, even a null client with inet_interfaces = loopback-only, normally runs smtpd on 127.0.0.1:25. A wildcard bind overlaps every specific address, so whichever starts second fails with "Address already in use".

msmtp has no such issue. It never listens. The rest of this section is about running a Postfix null client next to the container.

A null client does not need smtpd. Local submission goes through sendmail -> postdrop -> the maildrop queue -> pickup, none of which use a network port. Disable the listener:

sudo postconf -M smtp/inet          # shows the current master.cf entry
sudo postconf -MX smtp/inet         # removes it
sudo systemctl restart postfix
sudo ss -tlnp | grep :25            # only the container's bind should remain

The container keeps ports: "25:25" unchanged. The host Postfix relays to [127.0.0.1]:25, which reaches the container through Docker's published port.

Option B: bind the container to a specific address

Publish the container on the LAN address only, leaving 127.0.0.1:25 free:

    ports:
      - "192.0.2.10:25:25"     # your host's LAN IP, not 0.0.0.0

Then the host's null client can keep its loopback listener, and its relayhost becomes [192.0.2.10]:25. This keeps a second listener on the box that you do not need, so Option A is simpler.

The part people miss: what source address does the container see?

Mail from the host to the container's published port does not arrive from 127.0.0.1 or from the host's LAN IP. Through Docker's NAT it typically arrives from the bridge gateway address of the container's network (for example 172.18.0.1). The relay only accepts mail from mynetworks, so without that address in the list you get Relay access denied, the same symptom a LAN client would see.

Find out what the container sees. Send a test message, then:

docker logs postfix 2>&1 | grep "connect from" | tail -3

Or look up the gateway directly:

docker network inspect <network-name> \
  --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'

Add that address (as a /32, or the network's CIDR) to the relay's trusted networks, for example in the .env that feeds MYNETWORKS:

MYNETWORKS=192.168.2.0/24,172.18.0.1/32

Adjust the separator to match how your template splices the variable into mynetworks. Then recreate the container (docker compose up -d, not docker restart, so the new environment is picked up).

Sender identity

Providers that verify sender ownership reject envelope senders they do not recognise. Host-originated mail will claim something like user@hostname. Two ways to deal with it:

  • msmtp: set from in the account to a verified address. The envelope sender is then correct before the message ever leaves the host.
  • Postfix null client: let the relay fix it. Add the host's sender (e.g. [email protected]) to the relay's smtp_generic_maps so it is rewritten to a verified address on the way out.

Verify the whole chain

# 1. Only the container owns port 25 (Option A)
sudo ss -tlnp | grep :25

# 2. Host to relay
echo "hello" | mail -s "host test" [email protected]

# 3. Relay side
docker logs postfix -f
#   expect: connect from <gateway>  ->  relay=<provider>:587  ->  status=sent

Part 5 - Which one to use

Priority Pick
Fewest moving parts, nothing to patch msmtp
Cannot lose a message if the container is mid-restart Postfix null client (no listener)
Want mailq, postsuper, standard MTA tooling on the host Postfix null client
Host mail volume is trivial (a few cron mails a day) msmtp

For a homelab host whose relay container is always up, msmtp is the better default. The relay already owns durability. If host boot ordering means mail is sometimes sent while the container is down, use the Postfix null client with the listener removed (Option A), or add msmtpq.


Part 6 - How long to write an msmtp replacement in Rust?

This is an estimate for one experienced Rust developer working solo, not a measurement. The dominant factor is how much of msmtp you decide to replace.

Scope tiers

Tier What it covers Estimate
1. Relay shim sendmail-style CLI (-f, -t, -i, -oi, recipients, ignoring unknown -o*/-b* flags), read message from stdin, plain SMTP to a configured host/port (no TLS, no auth), add missing Date/Message-ID/From, aliases, syslog, correct exit codes 4-8 hours
2. Daily driver Tier 1 plus config file with accounts and inheritance, STARTTLS and implicit TLS with certificate verification, AUTH PLAIN/LOGIN, passwordeval, -t recipient extraction with Bcc stripping, --serverinfo, debug mode, integration tests 3-5 working days
3. Full parity OAuth2/XOAUTH2, SCRAM, GSSAPI/NTLM, fingerprint pinning, DSN, proxy support, an msmtpq-style queue 3-6+ weeks. Not worth it

Add roughly half a day to any tier for packaging and wiring it into your usual CLI conventions (subcommands, CI, release pipeline, a man page).

Why Tier 1 is so cheap

The container already does the hard work (TLS to the provider, SASL, retries, sender rewriting). A shim that talks to 127.0.0.1:25 needs none of it. The SMTP conversation is a handful of commands (EHLO, MAIL FROM, RCPT TO, DATA, QUIT), multi-line reply parsing, and dot-stuffing. That fits in std::net::TcpStream with zero dependencies.

Where the time goes at Tier 2

  • TLS and AUTH: with lettre (rustls feature, blocking transport) this is hours. Hand-rolling SMTP over rustls adds about 1.5 days, mostly STARTTLS upgrade handling and edge-case replies.
  • Config format: the msmtprc format is simple but has defaults, account name : parent inheritance, quoting, and ~ expansion. Allow a day if you parse it yourself.
  • Header handling: -t mode means parsing To/Cc/Bcc, including address lists with display names and quoted commas, then removing Bcc before sending. mail-parser or mailparse saves a day over hand-rolling.
  • Testing: an in-test SMTP sink, plus runs against the real container, is about a day and is where most bugs surface.

The real risk: the sendmail calling convention

Other programs invoke sendmail with an assortment of flags (-oi, -oem, -f addr, -t, -bs, -i, --) and expect specific exit codes (EX_TEMPFAIL 75, EX_UNAVAILABLE 69, and so on). cron, unattended-upgrades, and logwatch each lean on slightly different behaviour. Budget time to run each real caller against the shim, not just mail.

Suggested shape

  • One binary that behaves as sendmail when invoked through a symlink (check argv[0]), and has real subcommands otherwise: send, test (connectivity and --serverinfo-style probe), config check.
  • Start at Tier 1 against the container, use it on this host for a week, and only then decide whether Tier 2 features are worth building.

Should you?

There is no functional gain over msmtp. msmtp is mature, packaged, and already handles every edge above. The reasons to write it are consistency with your other Rust tooling and the learning value. If you do it, Tier 1 is a good first milestone, since it fully covers the "send to the local relay" case this document is about.


Quick reference

# msmtp path
sudo apt purge postfix sendmail
sudo apt install msmtp msmtp-mta
# write /etc/msmtprc (Part 1), then:
echo test | mail -s test [email protected]

# Postfix null client path (no port conflict with the container)
sudo apt install postfix                 # choose "Satellite system"
sudo postconf -e 'relayhost=[127.0.0.1]:25' \
                'inet_interfaces=loopback-only' \
                'mydestination=' \
                'local_transport=error:local delivery is disabled'
sudo postconf -MX smtp/inet              # drop the smtpd listener
sudo systemctl restart postfix

# either path: allow the host through the relay
docker network inspect <network-name> \
  --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
# add that address to MYNETWORKS, then: docker compose up -d