A mailing list as unikernels
2026-04-09Some of you might already know about my (slight?) obsession with emails. For a while now, we've been working on building a proper mailing list service in OCaml, and we're happy to say: it's alive! You can already reach us at ptt@mailingl.st and find subscription info on mailingl.st.
This article will walk you through what we built and how it works, without drowning you in configuration details. Let's focus on the two unikernels at the heart of the system: nec and lipap.
The email authentication problem
If you've ever tried to self-host email, you know it's not just about running an SMTP server. The real challenge is convincing the rest of the world that your emails are legitimate. Sender policy Framework (SPF), DomainKeys Identified Mail (DKIM), Domain-based Message Authentication, Reporting and Conformance (DMARC), Authenticated Received Chain (ARC)... there's a whole alphabet soup of mechanisms that exist to prove you're not a spammer.
Among these, DKIM is particularly annoying to manage. You need a private key to sign your outgoing emails, the corresponding public key published in your DNS zone, and you should rotate that key regularly. That's three moving parts that need to stay in sync. Most people handle this with a cron job and a prayer.
Furthermore, our cooperative already has unikernels related to the DNS protocol, and it is thanks to this expertise that we can now offer a so-called stateless solution.
nec: the signing unikernel
nec is the unikernel responsible for DKIM and ARC signing. It lives on a
private network (never exposed to the Internet directly) and its only job is to
sign emails that pass through it.
The interesting part is how it handles keys. Instead of storing a private key
somewhere, nec derives its key from two things: a password you provide at
boot time, and the current state of your DNS zone. Using PBKDF2, it
can regenerate the same private key deterministically. If the DNS already has a
valid public key that hasn't expired, nec derives the matching private key
from your password. If the key has expired (detected through a timestamp
encoded in the DKIM selector), it generates a fresh one and publishes the new
public key to your primary DNS server via TSIG.
This means nec is completely stateless. You can shut it down, restart it, and
it will figure out the right key on its own. During development, we restarted
these unikernels constantly and never lost the ability to sign emails. The only
things you need to trust are your password and your DNS server.
In addition, the mailing list may need to create and send emails. For example, for any subscriber to the mailing list, you can send ptt-subscribers@mailingl.st and the server will reply with a list of all subscribers specifically for you. The reply email includes the following:
ARC-Seal: i=1; a=rsa-sha256; t=1776174813; cv=none;
d=google.com; s=arc-20240605; b=...
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605;
h=to:sender:from:subject:date:mime-version:message-id:dkim-signature;
bh=mn0f6j4Yyph/TW+6Ol1toDyYKlZ7JI0fcvOdlYCwDTQ=;
fh=+hrCBYV0G+QpSw81HXUofdNwsOFsaholimJVBiEFUko=; b=...
dara=google.com
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@mailingl.st header.s=dkim-1775138006 header.b=lLGqkNIC;
spf=pass (google.com: domain of ptt@mailingl.st designates 192.99.35.121 as permitted sender) smtp.mailfrom=ptt@mailingl.st;
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=mailingl.st
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=mailingl.st;
s=dkim-1775138006; x=1777730006; q=dns/txt; bh=...; b=...;
We can see that the email has indeed been signed with a DKIM signature and that all the checks (SPF, DKIM and DMARC) have passed, according to Google! We can even download the email and use our Blaze tool to verify the email's integrity ourselves:
$ blaze dkim verify --newline=crlf -n tcp:9.9.9.9 < new.eml
[OK]: mailingl.st
$ blaze dmarc verify --newline=crlf -n tcp:9.9.9.9 < new.eml > stamp.eml
$ diff new.eml stamp.eml
0a1,5
> Authentication-Results: omelet;
> spf=pass (omelet: domain of ptt@mailingl.st designates 192.99.35.121 as
> permitted sender);
> dkim=pass header.i=@mailingl.st header.s=dkim-1775138006 header.b=lLGqkNIC;
> dmarc=pass (p=QUARANTINE sp=QUARANTINE) header.from=mailingl.st
nec also handles ARC (Authenticated Received Chain) signing, which is
important for mailing lists. When a mailing list relays an email, it often
modifies headers (like the From field), which breaks the original DKIM
signature. ARC creates a chain of trust that says "yes, we verified the
original signature before modifying the email." nec adds the ARC-Seal and
ARC-Message-Signature to complete this chain.
So, when someone joins the mailing list, you can find the following information:
ARC-Seal: i=1; a=rsa-sha256; d=mailingl.st; s=arc-1775138029; cv=none; b=...;
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=mailingl.st;
s=arc-1775138029; x=1777730029; q=dns/txt; bh=...;
h=message-id:list-id:reply-to:from:mime-version:date:subject:to; b=...;
ARC-Authentication-Results: i=1; mailingl.st; spf=pass (mailingl.st: domain of
reynir@data.coop designates 85.209.118.131 as permitted sender)
smtp.mailfrom=@data.coop;
dkim=pass header.i=@data.coop header.s=dkim header.b=Ys39tJag;
dmarc=pass (p=REJECT sp=REJECT) header.from=data.coop
An email address that you can also verify using our Blaze tool!
$ blaze arc verify -n tcp:9.9.9.9 --newline crlf < new.eml
ptt@mailingl.st -✓-> 01:mailingl.st -✓-> 02:google.com
$ blaze dkim verify -n tcp:9.9.9.9 --newline crlf < new.eml
[ER]: data.coop
blaze: Invalid DKIM signature.
The ARC check worked correctly and the chain of trust is clearly visible. It
should be noted that our mailing list system, in order to be recognised as
legitimate by other SMTP servers, modifies the From: field. This is why the
DKIM signature (which usually also signs this field) no longer works: this is
where ARC comes in; mailingl.st checks that SPF, DKIM and DMARC are correct (if
your server is misconfigured and you wish to join the mailing list, we will
ignore your emails!) and adds a signature (a new ARC-Set) which stamps the
email.
It reminds me of the days when I worked at La Poste (yes, really!) and the most valuable item was that humble stamp: a stamp that legally proves your letter was indeed accepted by La Poste on the given date (which can have legal significance).
lipap: the mailing list
lipap is the public-facing unikernel. It's the SMTP server that receives
emails from the Internet and manages the mailing lists themselves.
When an email arrives, lipap runs through the full verification pipeline: SPF
(is this IP allowed to send for this domain?), DKIM (is the email signature
valid?), and DMARC (does the sender's policy say we should accept this?). It
adds the Authentication-Results header with its findings and then decides
what to do with the email based on the target mailing list.
If everything checks out, lipap forwards the email to nec on the private
network. nec signs it (DKIM + ARC) and sends it back to lipap, which then
broadcasts the signed copy to all subscribers of the list. The actual delivery
to the outside world is always handled by lipap.
lipap stores its data on a FAT32 block device (thanks to mfat):
subscriber lists, moderator info, bounce tracking, and a queue for temporary
failures. It's minimal but enough. When an email can't be delivered
(greylisting, temporary server errors), lipap keeps it around and retries
later. If a destination keeps bouncing, it gets tracked so we don't keep
hammering a dead address.
You can prepare the image using a tiny little tool called ptt:
$ ptt create lipap.img
$ ptt add lipap.img --domain mailingl.st ptt --moderator din@osau.re
$ ptt add-moderator lipap.img --domain mailingl.st team@robur.coop
...
How it all fits together
The overall architecture looks like this: an email from the Internet hits
lipap, which verifies it and adds authentication results. If it's destined
for a mailing list, lipap forwards it to nec on the private network for
DKIM and ARC signing. nec sends the signed email back to lipap, which then
broadcasts it to all the subscribers.
Both unikernels configure themselves from DNS. The DKIM keys, the ARC keys, the
domain configuration... it all comes from querying (and updating) the primary
DNS server. No config files to sync, no secrets on disk. The DNS zone is the
single source of truth, and a TSIG key is all you need to keep things secure.
The lipap server only needs an image that it treats as a FAT32 file system:
adding new subscribers and making certain configurations is done by sending
emails as an administrator to specific email addresses (which can be thought of
as HTTP routes). This is what we mean when we say the system is stateless:
you can redeploy from scratch and everything just works.
A proof for mnet and mkernel
As you are probably aware, we are beginning to shift our strategy regarding the
development of unikernels and are no longer using the mirage tool. In
addition, we have introduced mnet and mkernel, which are now the two core
software components for handling networking and scheduling respectively. This
also marks our migration from OCaml 4 to OCaml 5 using Miou.
In short, ptt (just like blame, which we presented earlier
here and here) brings this ambition to
fruition. Furthermore, a user can now opam pin our projects and obtain
lipap.hvt and nec.hvt (without the hassle of tools or a dependency
resolution phase, since we let opam handle the latter).
Beyond reaping the benefits of a good scheduler such as Miou (yes!) and escaping
the hell of functors and cstruct, we have finally reached the
point where it can be quite enjoyable to build an unikernel in OCaml and deploy
it. This isn't covered in this article, but Albatross and
Mollymawk are improving and offer a better experience within the
unikernel workflow: namely, developing the unikernel but also (and above all)
deploying and using it!
We’re also now spending quite a bit of time ensuring everything is well documented, explaining things clearly, and even occasionally offering tutorials for those most curious about unikernel development. You, too, can embark on this adventure with this tutorial on mnet.
Try it out!
We now have a running instance at mailingl.st. The mailing list ptt@mailingl.st is open and we'd love to hear from you. Whether you have questions about the project, want to report a bug, or just want to say hi, send us an email. You can find subscription info on the website.
The source code is available on our repository. If you're curious about deploying your own instance, feel free to reach out on the mailing list. We're also happy to help if you want to run unikernels on our infrastructure (we have an Albatross server ready to go).
This has been a long road. From implementing DKIM in pure OCaml, to dealing with ARC edge cases, to discovering that some email providers happily break signature chains... it's been quite the adventure (see this article). But the result is a mailing list service that's small, self-configuring, and built on principles we care about: minimal attack surface, no secrets at rest, and reproducible deployments.
Happy emailing!