An ode to Mailpit, Elixir edition
The project I was working on a few years ago deployed an upgrade from OTP 25 to OTP 26, only to revert it a couple of hours later upon receiving reports from customers that they aren't receiving emails.
Turns out, emails could not be sent because attempting to establish a connection to an SMTP server was failing on the certificate check. We were not the only ones seeing an issue, more people reported that swoosh, a de-facto standard package for sending emails in Elixir ecosystem, stopped working when configured to use SMTP adapter: https://github.com/swoosh/swoosh/issues/785.
The reason was that in OTP 26, the default value for the verify
TLS option was
changed from verify_none
to verify_peer
.
Knowing that there was a change in this regard, I didn't quite read deeper into it and went for a full peer verification
in the configuration of our app. In hindsight, setting verify_none
for the gen_smtp's TLS configuration explicitly
would be enough at the time, and saved us from all the trouble (and would also result in this post not existing).
What was interesting is that this OTP upgrade managed to make it to our production environment despite our team's efforts to run a comprehensive end-to-end test suite ahead of each deployment. How come the e2e test suite failed to show the sign of the issue? The answer is: sadly, the way the e2e environment is configured sidestepped interaction with SMTP altogether.
See, it just so happens that swoosh can be configured to not actually send any emails, but merely pretend it has done so while instead collecting the emails in the memory of application, an equivalent of an "inbox". In this mode of operation swoosh would also expose a set of endpoints to interact with emails, e.g. get a list of all emails or their contents. Our e2e suite setup exploited both these swoosh capabilities. Because this proved to be convenient and simple to set up, there was no real reason to look for more (until the above fire hit production, of course).
And fairly so: one must choose their battles wisely, and should not attempt to configure and run a fully fledged email server just for the sake of running an e2e suite. Or should one? Well, of surely an answers to this would depend on the of business the app is in, and so on and so forth. But let's think for a moment what does "fully fledged" means in this context, e.g. try to list requirements for a successful "mail server for development" contender. Three key requirements immediately stem from our e2e test suite's needs:
- first and foremost, from an application perspective, interacting with such as server should be indistinguishable from interacting with Sendgrid, Mailchimp, Mailgun or the like - e.g. happen by means of the same SMTP configuration (while the config values would of course be different depending on the environement),
- preferably have a well-documented REST API to get a list emails and view contents of individual emails,
- it should be dockerized (our time is limited, and we're not in a business of dockerizing someone else's software).
Ideally, such server would also offer:
- a UI to interact with the email inbox,
- simple, well-document set of configuration options,
- be a lightweight, simple program (though this is optional),
A mail server application called mailpit just happens to tick all the boxes.
Crucially, if we are to try something new - we want to see the results as quickly as possible and my instinct here is not to bother with Elixir or Docker just yet. Instead, see if this new tool even works in the simplest way possible, then gradually increase the usage complexity by trying more options, integrating with the language-specific library, etc. So here's what I am going to do:
- install and run the server, then try sending an email,
- generate the certificate, configure the server to use the certificate and try sending an email with certificate verification,
- make an SMTP call from Elixir and confirm it works.
Let's install mailpit and run it:
brew install mailpit
mailpit
Yes, there's no mistake here - the simplest way to run mailpit is to just call the binary with no arguments. Above command will output the following:
INFO[2025/07/26 09:33:43] [smtpd] starting on [::]:1025 (no encryption)
INFO[2025/07/26 09:33:43] [http] starting on [::]:8025
INFO[2025/07/26 09:33:43] [http] accessible via http://localhost:8025/
Now we know our SMTP server is running on port is 1025
and exposes a UI at http://localhost:8025
.
Let's move on and install mailsend-go, a tiny but capable utility for interacting with SMTP servers:
brew install muquit/mailsend-go/mailsend-go
And run it:
mailsend-go -port 1025 -smtp localhost -f sender@example.com -t recipient@example.com -sub 'First email!' body -msg '<body><p>Well, hello there!</p></body>'
# Mail Sent Successfully
Let's check what's up at http://localhost:8025
:

Look, there's our email! Alright, so this works. But the process so far did not involve any authentication whatsoever, our typical Sendgrid-like service back in swoosh SMTP configuration wants one. So let's try configuring that:
MP_SMTP_AUTH="my-user:my-password" mailpit --smtp-auth-allow-insecure
Note, I had to specify --smtp-auth-allow-insecure
since mailpit will complain.
mailsend-go auth -user my-user -pass my-password -port 1025 -smtp localhost -f sender@example.com -t recipient@example.com -sub 'Second email!' body -msg '<body><p>Well, hello there!</p></body>'
# Mail Sent Successfully
Great, works too. Now let's try with SSL certificates. Let's generate a custom certificate authority:
generate-certificates.bash
#!/usr/bin/env bash
# generate a key for CA
openssl genpkey -algorithm RSA -out ca-key.pem -pkeyopt rsa_keygen_bits:2048
# use CA key to generate the CA certificate
openssl req -x509 -new -key ca-key.pem -days 3650 -out ca-cert.pem -subj "/C=CH/ST=Zurich/L=Zurich/O=My Organization/OU=My Unit/CN=CA/emailAddress=ca@example.com"
# generate a key for mailpit
openssl genpkey -algorithm RSA -out mailpit-key.pem -pkeyopt rsa_keygen_bits:2048
# use mailpit key to generate the certificate signing request
openssl req -new -key mailpit-key.pem \
-out mailpit-csr.pem \
-subj "/C=CH/ST=Zurich/L=Zurich/O=My Organization/OU=My Unit/CN=CA/emailAddress=mailpit@example.com" \
-config <(cat /etc/ssl/openssl.cnf <(printf "\n[SAN]\nsubjectAltName=DNS:localhost"))
# use CA certificate and key, and a CSR to finally generate a certificate for mailpit
openssl x509 \
-req \
-in mailpit-csr.pem \
-CA ca-cert.pem \
-CAkey ca-key.pem \
-CAcreateserial \
-out mailpit-cert.pem \
-days 36500 \
-extfile <(printf "subjectAltName=DNS:localhost")
Note
A side note on the above series of not-so-nice-looking openssl
commands: the amount of commands and options
above looks... tragic, I know. For testing purposes, they could potentially be reduced to just two commands, as
recommended in mailpit documentation, or even to
something like a single command via mkcert. However, having ca-cert.pem
generated explicitly proved to be quite practical for testing scenarios, allowing you to install this custom CA to
several services at once, to let them communicate with the mail server.
This can be helpful in more involved test scenarios too. For example, imagine a case where services driven by a docker compose must communicate to each other via DNS names over HTTPS. Having a single CA installed on each container, and all service certs generated from it is just convenient and brings the whole test environment setup a little closer to how services would interact with each other in production environment over public internet, where everything is using HTTPS today.
Let's start the mail server again and specify the certificate and key this time:
MP_SMTP_TLS_CERT=mailpit-cert.pem MP_SMTP_TLS_KEY=mailpit-key.pem MP_SMTP_AUTH="any-user:any-password" mailpit
Try sending an email again:
mailsend-go auth -user my-user -pass my-password -port 1025 -smtp localhost -f sender@server.com -t recipient@server.com -sub 'Third email!' body -msg '<body><p>Well, hello there!</p></body>'
# Mail Sent Successfully
And it works! But there's a catch. mailsend-go will use encryption when interacting with the mail server, yes, but
will not attempt to verify the server's certificate. Just like our OTP 25 era gen_smtp
configuration. Sadly,
specifying -verifyCert
doesn't appear to work as expected right
now. I will update this post if I figure out what I was doing wrong, or when mailsend-go implements it.
Finally, let's send the email using Elixir:
Mix.install [:gen_smtp]
require Logger
:ok = :public_key.cacerts_load(~c"ca-cert.pem")
server_options = [
relay: ~c"localhost",
username: "my-user",
password: "my-password",
port: 1025,
tls: :always,
tls_options: [
depth: 5,
cacerts: :public_key.cacerts_get(),
server_name_indication: ~c"localhost",
],
trace_fun: fn format, args ->
Logger.info("#{format} #{inspect(args)}")
end
]
mail =
"""
To: recipient@example.com
Subject: Fourth email!
From: sender@example.com
Content-Type: text/html
<body><p>Well, hello there!</p></body>
"""
:gen_smtp_client.send_blocking({"", ["sender@example.com"], mail}, server_options)
A couple of notes regarding the script above:
-
First,
:public_key.cacerts_load(~c"ca-cert.pem")
- it needs to be added in my example script here, so my Elixir program knows about this CA certificate, making it able to verify mailpit's certificate. Typically, one won't need to do this in production environment, where system-level CA certificates are usually enough to verify the authenticity of popular email services like Sendgrid, etc; those certificates are stored in a well-known per-distro locations on the disk, and ERTS knows exactly where to look for them, -
trace_fun
is a gen_smtp's specific configuration option used here only to reduce the degree of discomfort that comes with zero default verbosity of gen_smtp; this option is not really needed in production either.
But otherwise that's it for confirming that our Elixir client is now correctly configured to send an email over a secure connection with certificate verification.
Let's do one more things and try the REST API provided by mailpit:
curl --silent localhost:8025/api/v1/messages | jq '.messages | map({subject: .Subject, to: .To[0].Address})'
Above command will output:
[
{
"subject": "Fourth message!",
"to": "recipient@example.com",
"snippet": "Well, hello there!"
},
{
"subject": "Third message!",
"to": "recipient@example.com",
"snippet": "Well, hello there!"
},
{
"subject": "Second message!",
"to": "recipient@example.com",
"snippet": "Well, hello there!"
},
{
"subject": "First message!",
"to": "recipient@example.com",
"snippet": "Well, hello there!"
}
]
The REST API is convenient be used in end-to-end tests to find the email, parse email contents for links, actually click that link, etc.
Just a side note that in some scenarios a better substitute for polling the API could be listening on new messages arriving to a websocket, exposed by mailpit. The servers author was kind enough to accept a proposal by yours truly and advertised the websocket option on the mailpit website.
Finally, a sample compose file:
services:
email-service:
image: ghcr.io/axllent/mailpit:v1.27.3
environment:
MP_SMTP_TLS_CERT: /cert.pem
MP_SMTP_TLS_KEY: /key.pem
MP_SMTP_AUTH: any-user:any-password
command: --verbose
ports:
- 1025:1025
- 8025:8025
volumes:
- ./mailpit-key.pem:/key.pem
- ./mailpit-cert.pem:/cert.pem
networks:
- e2e
# ...other services comprising the e2e setup
A casual note: in order to access the web UI through HTTPS too, two different ENV variables will need to be set, namely
MP_UI_TLS_CERT
and MP_UI_TLS_KEY
. A full set of options can be seen at "Runtime
options".
The above was more or less a narrow look at mailpit: we checked that it can be configured to use TLS, as well as that has a REST API and how it can be ran in a container. In practice mailpit has much more to offer (most of these things I haven't even gotten to try out yet):

Overall, whenever using mailpit I am reminded just how incredibly lucky we are to have someone take their time to make software of this kind happen. Thank you so much, Ralph Slooten!