Mailu is a container based mail service software stack.

At it’s core, it comprises of Postfix (SMTP), Dovecot (IMAP/POP) and Nginx (proxy) along with some optional extras. It’s all linked into a central container called admin which acts as a central authority. Officially it runs on Docker and Kubernetes (via Helm).

I don’t currently use Docker or Kubernetes for my own services but I have been using Mailu to host some mail services. Instead, I use Podman which is more or less interchangable with Docker but has some differences. This means I’d been running Mailu with some custom setup steps and it was working quite happily.

At various points, I documented my attempts at running it on Podman on a Github Issue. However, elsewhere on the Mailu issue tracker, something bad was happening. A user running on an unsupported setup raised a ticket and sniped at the developers, which results in the developers pushing this commit, which states it’s updating Roundcube but also includes the following:

def test_unsupported():
    import codecs
    if os.path.isfile(codecs.decode('/.qbpxrerai', 'rot13')) or os.environ.get(codecs.decode('V_XABJ_ZL_FRGHC_QBRFAG_SVG_ERDHVERZRAGF_NAQ_JBAG_SVYR_VFFHRF_JVGUBHG_CNGPURF', 'rot13'), None):
        return
    print('Your system is not supported. Please start by reading the documentation and then http://www.catb.org/~esr/faqs/smart-questions.html')
    while True:
        time.sleep(5)

For your convenience I’ll decode it for you, the rot13 encoded text is checking for the file /.dockerenv to check if it’s running in docker, and if it’s not it looks for the environment variable I_KNOW_MY_SETUP_DOESNT_FIT_REQUIREMENTS_AND_WONT_FILE_ISSUES_WITHOUT_PATCHES. If it’s not found, it prints an error (to stdout) and loops infinitely in a call to sleep().

The intent is that unsupported setups need to set an environment variable to acknowledge that they’re not running a supported setup. However if you recall, I mentioned that one of their supported platforms was Kubernetes. Kubernetes may use containerd under the hood but it’s not going to create a .dockerenv file in the container. As such, some of their kubernetes users found that when they upgraded to 2.0.29 their admin containers silently hung. It was silent, because the error was printed rather than written like other log lines.

This broke a few users setups (including mine) and left them confused as no error appeared to be reported. Infact, brining this to their attention led to them hurridly patching their Helm charts after initially denying it was an issue. However, while I disagree with their approach I do think they have a point. I decided since I was re-tooling my own setup anyway, to look at a better solution to running it on Podman. My previous attempts had always been just manually creating a 1:1 mapping from the services/volumes/networks in docker-compose.yml and making their equivalent in Pods and later Quadlets. These were specific to my own setup and could only really serve as a base for others to crib from.

I started by finding a tool that would let me parse a docker-compose.yml file in Go, and I found the compose-spec.io library in golang which did exactly what I needed. I then used the Mailu docker-compose.yml template which is used to generate Mailu setups and create Go text templates to programmatically create a set of quadlet units to match an arbitrary docker-compose.yml for Mailu. After ironing out some issues, I’m currently using mail setup generated from it and I’ve tested initialising a few others with success.

You can review my mailu-quadlet tool code here, there’s also a container image for it available. In terms of operation, it’s relatively simple. Just generate your setup on the Mailu site then download your provided docker-compose.yml and mailu.env to a directory on your host.

wget https://[...]/docker-compose.yml
wget https://[...]/mailu.env

Now run the container, mounting the directory containing the docker-compose.yml and mailu.env file to /data inside the container (which it uses by default).

podman run -v $(pwd):/data:z ghcr.io/cyberworm-uk/mailu-quadlet --uuid example

It should now list a series of filenames ending in .container, .volume and .network which are the associated unit files for the resources it needs. You’ll notice they’re all prefixed with the --uuid you provided. If one isn’t provided a random one will be generated.

The .env file will need to be copied to /etc/mailu as this is currently where the generated files expect to find it and these values are required for the services to operate correctly.

sudo mkdir -p /etc/mailu
sudo cp example.env /etc/mailu

Next the unit files will need to be moved to where the Quadlet service expects to find them. Assuming they’re going to be rootful containers (the front container running Nginx requires binding to privileged ports) this would be /etc/containers/systemd/.

sudo cp *.network *.container *.volume /etc/containers/systemd
sudo systemctl daemon-reload
# by default these will start on next boot
# alternatively we can start them manually, e.g.
for CONT in *.container; do sudo systemctl start ${CONT::-10}; done

You should now see your Mailu containers running under podman.

$ podman run ghcr.io/cyberworm-uk/mailu-quadlet:latest -help
Usage of /cli:
  -compose string
        docker-compose.yml file for mailu (default "docker-compose.yml")
  -envfile string
        mailu.env file for mailu (default "mailu.env")
  -uuid string
        optional custom uuid to use for generated

$ go doc github.com/cyberworm-uk/mailuquadlet.Mailu
package mailuquadlet // import "github.com/cyberworm-uk/mailuquadlet"

type Mailu struct {
        // Has unexported fields.
}

func NewMailu(compose, env string) *Mailu
func (m *Mailu) Export()
func (m *Mailu) Init(compose, env string)
func (m *Mailu) Uuid(id string)