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
msmtpqwrapper adds a simple on-disk queue.) - No local delivery. Mail to
rootoramerceris 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-mtapackage symlinks/usr/sbin/sendmailto msmtp, so tools that shell out tosendmail(includingmailfrommailutils) 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.
Option A (recommended): a null client with no listener¶
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
fromin 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'ssmtp_generic_mapsso 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 overrustlsadds about 1.5 days, mostly STARTTLS upgrade handling and edge-case replies. - Config format: the msmtprc format is simple but has
defaults,account name : parentinheritance, quoting, and~expansion. Allow a day if you parse it yourself. - Header handling:
-tmode means parsingTo/Cc/Bcc, including address lists with display names and quoted commas, then removingBccbefore sending.mail-parserormailparsesaves 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
sendmailwhen invoked through a symlink (checkargv[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