MICHAEL WINTERS

Techblah - Sane email self-hosting with Stalwart


Sidenote: I wanted this blog to mainly be my higher-level thoughts, but I have lots of little tech tips to share. Hence: techblah.

Stalwart

I started self-hosting my email again in 2024 thanks to the amazing Stalwart mail server.

It:

I don’t fanboy about software very often, but Stalwart is great. I haven’t had an appetite for running a dozen different services written in C which are configured with arcane languages in a very long time, so Stalwart has enabled me to self-host again.

If you decide to check it out, here are a few tips:

2nd IP = Host network

In order to avoid having other email servers think you’re a spammer, your server needs to send email from the IP address that matches your forward DNS, e.g. mail.mydomain.com. That’s normally not a problem, but at Hetzner if you want a static IP address it shows up as the 2nd IP on your interface: eth0:1. This means that any traffic coming from inside a container network will be sent out on the wrong IP address: eth0’s IP.

Changing this would require choosing which ugly hack I wanted to use. I could tapdance around DHCP so that the intended IP ended up as eth0, or I could create and configure my own bridge instead of relying on my container manager to do it, and then try to convince said container manager to use said bridge. I actually pursued that, but at the time this was not a supported configuration.

Stuff like this has a way of breaking over time and at the worst possible moment, so the simple solution was to run Stalwart on the host network and configure it to bind / send on the secondary IP. Note that you can run Stalwart as an ordinary user if you grant the container CAP_NET_BIND_SERVICE.

Disable IPv6

This one took me a while to figure out. Some of my outbound email was fine but some of it was erroring as unable to connect to the destination server. In particular, gmail addresses worked fine but G-suite addresses were bouncing.

It turns out that this is a well-known issue in some mail circles, and it has to do with IPv6 DNS. The easy answer is to simply disable IPv6, which you can do via sysctl:

/etc/sysctl.d/noeth0ipv6.conf:

net.ipv6.conf.eth0.disable_ipv6=1

This allows you to still have IPv6 on other interfaces such as VPNs.

Read the upgrade notes carefully

I’ve only been bitten by an upgrade once, but it’s because I disregarded the upgrade notes.

When you run Stalwart in the default Docker configuration, the binary downloads the HTML UI from Github and stores it in your (encrypted) local datastore … not what I was expecting! And I didn’t know this until it was too late. If the backing API changes in the next version then you need to upgrade the UI before upgrading the binary.

A few upgrades ago I assumed that the UI was packaged in the container like every other app on the planet, so I did not upgrade the UI first as per the instructions. The upgrade performed a non-reversible migration of the datastore, so I found myself unable to access my admin interface because the old UI didn’t understand the new API. I couldn’t downgrade the API because of the datastore migration, and I couldn’t manually update the UI because of the encryption!

RTFM and make backups, everyone.

Sieve

Sieve scripting has apparently been around a long time, but I hadn’t heard of it until skimming Stalwart’s list of features. (Stalwart supports lots of interesting standards that aren’t yet widely supported but ought to be!) Sieve lets you write small filtering scripts that run on the server, so that you don’t have to figure out how to keep your filters in sync across your 27 different instances of Thunderbird.

Several mail clients support creating and managing server-side Sieve scripts, but sadly Thunderbird is not yet one of them. You can install an extension to achieve this, but the Thunderbird ecosystem is somewhat shaky ground and I don’t want to rely on an extension which may or may not keep pace with the overhauls on Thunderbird’s roadmap.

Fortunately, after a bit of hunting I found an excellent standalone sieve client. The latest official release is ancient, but you can download a dev build and I’ve had zero issues with that dice roll. I might even take the time to get this up on Flathub. (I find myself saying that at least once a week…)

Code

The syntax for Sieve is a bit odd, but a spin through the RFC and this tutorial (which is one of the very few available on the web!) was enough to get me moving.

The basic format for filing email into the correct folder according to recipient address is pretty straightforward:

require ["fileinto"];
if address :all ["to", "cc"] ["[email protected]"] {
  fileinto "INBOX/nerd";
}

Labels / tags / flags

I also want to apply the Thunderbird labels “Important”, “Work”, etc, as these allow me to visually sort my email very quickly. However, getting this to work was a bit of a trip down a rabbit hole.

IMAP supports the concept of applying arbitrary “flags” to messages (which I think most people today would call “tags”). A handful of these are predefined and supported by every IMAP server, and those are prefixed with \. The RFC makes it clear that you can “star” a message by applying the standard flag \Flagged, but I don’t actually use stars except for extremely high-priority messages, which is almost never.

After quite a bit of fumbling, I eventually figured out that when Thunderbird applies the “Work” label (for example), the flag that is applied on the server is the stunningly opaque $label2. You can see the list of actual labels by opening up Thunderbird’s Settings > Config Editor and searching for label:

Thus, my final Sieve script resembles this:

require "copy";
require "mailbox";
require ["fileinto", "imap4flags"];
if address :all ["to", "cc"] ["[email protected]"] {
  fileinto "INBOX/nerd";
}
elsif address :all ["to", "cc"] ["[email protected]"] {
  addflag "$label2"; # thunderbird "Work"
}

If you read the IMAP RFC, it mentions that the flag prefix $ is to be used for flags that are common across email clients. So apparently this is something that is supposed to be portable. However, I wasn’t able to find any other clients that recognize these specific Thunderbird flags, and in fact found other clients such as MacMail which use their own $MailFlagBit0, $MailFlagBit1, schema. So you may need to dig to find the flags that your client accepts. The good news is that you can just add these flags alongside the other client flags in your Sieve script and achieve some cross-client harmony!

#techblah #selfhosting