Homelab
Proof

U4 / Apps under test

Self-hosted email: RAM of Stalwart and docker-mailserver

Tested on Stalwart 1.0.1

Test result

On this test box the mail server itself is cheap: with two mailboxes and no mail yet, Stalwart 1.0.1 used 103.9 MiB, and docker-mailserver 16.0.1 used 37.4 MiB with all spam filtering off and 273.1 MiB with Rspamd plus its legacy filters on. After ten small messages the range was 52.7 to 352.8 MiB for docker-mailserver and 115.4 MiB for Stalwart. Both created mailboxes, accepted local mail over SMTP and served it over IMAP. Whether Gmail or Outlook will accept your mail was not tested and cannot be tested from this lab.

Line drawing: a closed envelope sliding into a small server shaped like a mailbox with a raised flag.

What it costs to run

Each server got two mailboxes (alice and bob at lab.internal). The script sent five messages from alice to bob over SMTP port 25 with no login, the way another server would, and five over authenticated submission, then read them over IMAP. RAM is the sum of the mail containers, median of 12 docker stats samples taken 60 s after the server was ready (before mail) and right after the ten messages. Each configuration was run once, in a throwaway VM on a host that was busy with other lab VMs (load average 8.91 at the end of the docker-mailserver runs, 6.30 for Stalwart), so the delivery times below carry that noise.

RAM of the mail container(s), median of 12 docker stats samples, in MiB
Server and configurationBootstrap mode (no domain, no users)Configured, two mailboxes, before any mailAfter 10 messages sent and read
docker-mailserver 16.0.1, Rspamd on, legacy filters left on (as in the lab stack)n/a273.1352.8
docker-mailserver, Rspamd on, legacy filters off (as its docs advise)n/a232.2242.4
docker-mailserver, Rspamd off, legacy filters at defaultsn/a88.4165.5
docker-mailserver, Rspamd and legacy filters all offn/a37.452.7
Stalwart 1.0.1 (built-in spam filter, no Rspamd)14.1103.9115.4

docker-mailserver has no separate bootstrap state, so that column is n/a. ClamAV and Fail2ban were off in every configuration. VM 4 vCPU, 3 GB.

Which figure is which matters here, because the lab’s separate idle measurements were taken in other states. The lab measured Stalwart at 13.6 MiB, but that was its first-start bootstrap mode, where the server starts no mail services and serves only the setup wizard (14.1 MiB in this run); after setup, with a domain and two users, the same server used 103.9 MiB and 115.4 MiB after the ten messages. docker-mailserver refuses to keep running without an account (its docs give two minutes to add one on first start, after which it shuts down and restarts), so it has no meaningful idle state without one.

docker-mailserver’s four rows are the point. The lab stack enables Rspamd (the A row) but leaves the older filters (Amavis, OpenDKIM, OpenDMARC and policyd-spf) at their defaults, and the process list shows Rspamd and Amavis both running. The Rspamd docs advise running it with those legacy services disabled, which is row B: 232.2 MiB before mail against 273.1 MiB for the shipped stack. Turning Rspamd off while leaving the defaults (C) gave 88.4 MiB, and turning everything off (D) gave 37.4 MiB. Stalwart was run without Rspamd; its mail carried X-Spam-Status and X-Spam-Result headers from its own filter, so “with and without Rspamd” does not apply to it, and the test did not try to switch that filter off. These figures are before any mail. After ten messages the same containers measured 352.8 (A), 242.4 (B), 165.5 (C) and 52.7 MiB (D), so A and C grew by about 80 MiB and B and D by 10 to 15 MiB; the test did not look into why.

Does it deliver, locally

Delivery between two local users: SMTP accept time and time until the message is visible over IMAP (seconds, median and range of 5)
Server and configurationPathSMTP accept (s)Visible over IMAP (s)Folder the message landed in (of 5)Connections closed at once and retried (of 5 messages)
docker-mailserver 16.0.1, Rspamd on, legacy filters left on (as in the lab stack)port 25, no login0.16 (0.15-6.45)0.26 (0.26-6.70)INBOX: 50
docker-mailserver 16.0.1, Rspamd on, legacy filters left on (as in the lab stack)port 587, with login0.07 (0.07-0.09)0.17 (0.17-0.19)INBOX: 50
docker-mailserver, Rspamd on, legacy filters off (as its docs advise)port 25, no login0.10 (0.09-6.25)0.20 (0.20-6.42)INBOX: 50
docker-mailserver, Rspamd on, legacy filters off (as its docs advise)port 587, with login0.08 (0.07-0.15)0.18 (0.18-0.25)INBOX: 50
docker-mailserver, Rspamd off, legacy filters at defaultsport 25, no login0.12 (0.12-6.29)0.33 (0.32-6.47)INBOX: 50
docker-mailserver, Rspamd off, legacy filters at defaultsport 587, with login0.07 (0.07-0.08)0.28 (0.17-0.29)INBOX: 50
docker-mailserver, Rspamd and legacy filters all offport 25, no login0.03 (0.02-6.10)0.13 (0.13-6.24)INBOX: 50
docker-mailserver, Rspamd and legacy filters all offport 587, with login0.03 (0.02-0.05)0.13 (0.12-0.16)INBOX: 50
Stalwart 1.0.1 (built-in spam filter, no Rspamd)port 25, no login0.01 (0.01-0.36)0.01 (0.01-0.37)Junk Mail: 50
Stalwart 1.0.1 (built-in spam filter, no Rspamd)port 465 (TLS), with login0.03 (0.03-0.04)0.04 (0.04-0.04)INBOX: 51

Both mailboxes are on the same server and the same domain (lab.internal, no DNS, no SPF, DKIM or DMARC records), so this says nothing about delivery to or from Gmail, Outlook or any other provider. Each message has a Date and Message-ID header. The first message through each server includes any first-use cost.

Every message reached its mailbox in every docker-mailserver configuration. The first port-25 message in each of its four runs took about 6 s to be accepted, and later ones a median of 0.03 to 0.16 s, including with all filters off, so the delay was not the spam filtering; its cause was not investigated. Stalwart accepted and stored mail with a median of 0.01 s on port 25 and 0.03 s on submission (0.36 s for the very first message), but its two paths differ:

  • Mail sent to port 25 without a login landed in Junk Mail, 5 of 5, with X-Spam-Status: Yes. The excerpt below shows no SPF record found for the sender, and the lab domain has none.
  • Mail submitted with a login on port 465 landed in the inbox, 5 of 5, and carried two DKIM signatures from the server.
Filter-related headers on the first message of each path
Server and configurationPathHeaders (excerpt)
docker-mailserver 16.0.1, Rspamd on, legacy filters left on (as in the lab stack)smtp25Authentication-Results: mail.lab.internal; dmarc=none (p=none dis=none) header.from=lab.internal Received-SPF: None (mailfrom) identity=mailfrom; client-ip=172.18.0.1; helo=lab.internal; envelope-from=alice@lab.internal; receiver=lab.internal
docker-mailserver 16.0.1, Rspamd on, legacy filters left on (as in the lab stack)submission
docker-mailserver, Rspamd on, legacy filters off (as its docs advise)smtp25
docker-mailserver, Rspamd on, legacy filters off (as its docs advise)submission
docker-mailserver, Rspamd off, legacy filters at defaultssmtp25Authentication-Results: mail.lab.internal; dmarc=none (p=none dis=none) header.from=lab.internal Received-SPF: None (mailfrom) identity=mailfrom; client-ip=172.18.0.1; helo=lab.internal; envelope-from=alice@lab.internal; receiver=lab.internal
docker-mailserver, Rspamd off, legacy filters at defaultssubmission
docker-mailserver, Rspamd and legacy filters all offsmtp25
docker-mailserver, Rspamd and legacy filters all offsubmission
Stalwart 1.0.1 (built-in spam filter, no Rspamd)smtp25X-Spam-Status: Yes Authentication-Results: mail.lab.internal; Received-SPF: none (mail.lab.internal: no SPF records found for alice@lab.internal) X-Spam-Result: ARC_NA (0.00),
Stalwart 1.0.1 (built-in spam filter, no Rspamd)submissionX-Spam-Status: No DKIM-Signature: v=1; a=ed25519-sha256; s=v1-ed25519-20261102; d=lab.internal; c=relaxed/relaxed; r=y; DKIM-Signature: v=1; a=rsa-sha256; s=v1-rsa-20261102; d=lab.internal; c=relaxed/relaxed; r=y;

Only the lines that show which filters ran.

That is what a local test with no DNS can show: Stalwart scored unauthenticated mail from an unknown sender as spam. docker-mailserver put the same kind of message in the inbox, but the two are not a like-for-like comparison: docker-mailserver saw the sender as 172.18.0.1, a private Docker address, and the test did not check whether that exempted it from some filter checks. None of this is evidence about any provider.

One authenticated connection to Stalwart was closed immediately and succeeded when retried 5 s later (the last column of the delivery table). In earlier attempts without retries the script failed with “connection unexpectedly closed” on both port 25 bursts and port 465. The cause was not found: a search of the container log at the end of the run for the word “error” found nothing. Treat it as an open question about this version, and not as a rate limit, since no limit was identified.

What Google asks of senders

These are Google’s published requirements as read on the day of this test (the page recommends all three of SPF, DKIM and DMARC for every sender):

  • All senders: set up SPF or DKIM; use a DKIM key of at least 1024 bits (2048 recommended); have valid forward and reverse DNS (PTR) records for the sending domain or IP; send over TLS; keep the spam rate in Postmaster Tools below 0.3%.
  • Bulk senders (more than 5,000 messages a day to Gmail accounts): set up SPF, DKIM and DMARC (the DMARC policy can stay at none), align the From domain with SPF or DKIM, and support one-click unsubscribe for marketing and subscribed messages. The page gives February 1, 2024 as the start date for both groups.

None of those could be checked here. This test did create DKIM keys in Stalwart and sign the submitted mail, but with no DNS records published the signatures verify against nothing.

When it is not worth it (opinion)

This section is opinion, not a measurement. RAM is not the reason to avoid self-hosted mail: even the heaviest configuration here stayed at 352.8 MiB after ten small messages. The reasons are on the sending side. If you cannot set a reverse DNS record for the address you send from, if you will not monitor your spam rate and blocklists, or if getting a message to a Gmail address late or in spam would cost you more than the subscription to a hosted mailbox, hosted mail is the better trade. Receiving mail on your own server and sending through a relay service is a middle option that this test did not try. A server you only use between your own accounts or for a few local applications is a much easier case than one that must be trusted by strangers’ providers.

Steps

The Stalwart compose is the lab stack with the volumes on the paths it actually uses and the TLS submission port published. It starts in bootstrap mode with STALWART_RECOVERY_ADMIN set; stalwart-setup.py 1 finishes bootstrap through the JMAP API, the container is then recreated without that variable (with it set, the test’s mail connections were closed straight away), and stalwart-setup.py 2 creates the two users. docker-mailserver uses the lab’s compose with the ENABLE_* values changed per row by dms-cfg.py, and accounts added with setup email add.

lab/tests/mail/files/stalwart.yml

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
# lab/stacks/stalwart/compose.yml with the volumes moved to the paths Stalwart actually uses (/etc/stalwart, /var/lib/stalwart),
# and the IMAPS and implicit-TLS submission (465) ports published (587 is not exposed by the image). First start is bootstrap mode; stalwart-setup.py finishes it through the JMAP API.
services:
  stalwart:
    image: stalwartlabs/stalwart:v1.0.1
    restart: unless-stopped
    environment:
      STALWART_RECOVERY_ADMIN: admin:lab-only-not-a-secret
    ports:
      - "127.0.0.1:8080:8080"
      - "127.0.0.1:2525:25"
      - "127.0.0.1:1465:465"
      - "127.0.0.1:1993:993"
    volumes:
      - stalwart-etc:/etc/stalwart
      - stalwart-data:/var/lib/stalwart
volumes:
  stalwart-etc:
  stalwart-data:
lab/tests/mail/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# mail: docker-mailserver 16.0.1 (4 anti-spam configurations) and Stalwart 1.0.1, inside one VM each.
# Create two mailboxes, send alice -> bob over SMTP (port 25 and authenticated submission), read over IMAP, sample RAM before and after.
# Nothing leaves the VM: no DNS, no outbound mail, no real domain.
set -uo pipefail
source "$(dirname "$0")/../_dlib.sh"
T=mail; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; D="$ROOT/lab/tests/$T"
TOOLS=("$@"); [ ${#TOOLS[@]} -eq 0 ] && TOOLS=(dms stalwart)
[ $# -eq 0 ] && rm -rf "$OUT"; mkdir -p "$RAW"
dms() {
  start_vm d-$T-dms
  gx 'mkdir -p ~/d'; vm scp-to "$ROOT/lab/stacks/docker-mailserver/compose.yml" d/compose.yml >/dev/null
  vm scp-to "$D/mailtest.py" mailtest.py >/dev/null; vm scp-to "$D/dms-cfg.py" d/dms-cfg.py >/dev/null; vm scp-to "$ROOT/lab/tests/_dsample.py" sample.py >/dev/null
  cpull '~/d' dms
  # name | extra environment for the mailserver service (the lab stack already sets ENABLE_RSPAMD=1, CLAMAV=0, FAIL2BAN=0)
  local cfgs=("A-rspamd-on-legacy-on|" "B-rspamd-on-legacy-off|ENABLE_OPENDKIM=0 ENABLE_OPENDMARC=0 ENABLE_POLICYD_SPF=0 ENABLE_AMAVIS=0 ENABLE_SPAMASSASSIN=0" "C-rspamd-off|ENABLE_RSPAMD=0" "D-all-antispam-off|ENABLE_RSPAMD=0 ENABLE_OPENDKIM=0 ENABLE_OPENDMARC=0 ENABLE_POLICYD_SPF=0 ENABLE_AMAVIS=0 ENABLE_SPAMASSASSIN=0")
  for c in "${cfgs[@]}"; do
    local name="${c%%|*}"; local extra="${c#*|}"; local out="$RAW/dms-$name"; mkdir -p "$out"
    gx "cd ~/d && docker compose -p dms down -v >/dev/null 2>&1; python3 dms-cfg.py $extra && docker compose -p dms -f compose.cfg.yml up -d 2>&1 | tail -1; grep -E 'ENABLE_' compose.cfg.yml" > "$out/env-lines.txt" 2>&1
    local t0; t0=$(date +%s)
    until gx 'docker exec dms-mailserver-1 setup email add alice@lab.internal lab-only-not-a-secret' >> "$out/accounts.txt" 2>&1; do sleep 3; [ $(( $(date +%s) - t0 )) -gt 100 ] && break; done
    gx 'docker exec dms-mailserver-1 setup email add bob@lab.internal lab-only-not-a-secret' >> "$out/accounts.txt" 2>&1
    until gx 'curl -s --max-time 3 telnet://127.0.0.1:1143 </dev/null | head -c 20 | grep -q OK'; do sleep 3; [ $(( $(date +%s) - t0 )) -gt 240 ] && { echo "dovecot not answering in 240 s" > "$out/FAILED.txt"; break; }; done
    echo "ready_s_since_up=$(( $(date +%s) - t0 ))" > "$out/ready.txt"
    sleep 60
    gx 'python3 ~/sample.py dms-mailserver-1' > "$out/ram-before-mail.json"
    gx 'python3 ~/mailtest.py dms' > "$out/mailtest.json" 2> "$out/mailtest.err"
    gx 'python3 ~/sample.py dms-mailserver-1' > "$out/ram-after-mail.json"
    gx 'docker exec dms-mailserver-1 supervisorctl status | grep RUNNING | awk "{print \$1}" | tr "\n" " "; docker exec dms-mailserver-1 ps -eo rss,comm --sort=-rss | head -8; docker inspect -f "restarts={{.RestartCount}}" dms-mailserver-1' > "$out/processes.txt" 2>&1
  done
  mkdir -p "$RAW/dms-env"; collect_env "$RAW/dms-env"
}
stalwart() {
  start_vm d-$T-stalwart
  gx 'mkdir -p ~/s'; vm scp-to "$D/files/stalwart.yml" s/compose.yml >/dev/null
  vm scp-to "$D/mailtest.py" mailtest.py >/dev/null; vm scp-to "$D/stalwart-setup.py" stalwart-setup.py >/dev/null; vm scp-to "$ROOT/lab/tests/_dsample.py" sample.py >/dev/null
  cpull '~/s' stalwart
  local out="$RAW/stalwart"; mkdir -p "$out"
  gx 'cd ~/s && docker compose -p stalwart up -d 2>&1 | tail -1'
  until gx 'curl -s -o /dev/null http://127.0.0.1:8080/jmap/session'; do sleep 2; done; sleep 60
  gx 'python3 ~/sample.py stalwart-stalwart-1' > "$out/ram-bootstrap-mode.json"
  gx 'python3 ~/stalwart-setup.py 1' > "$out/setup1.json" 2> "$out/setup1.err"
  gx 'cd ~/s && sed -i "/environment:/d;/STALWART_RECOVERY_ADMIN/d" compose.yml && docker compose -p stalwart up -d 2>&1 | tail -1; grep -c RECOVERY compose.yml' > "$out/recreate.txt" 2>&1
  gx 'python3 ~/stalwart-setup.py 2' > "$out/setup2.json" 2> "$out/setup2.err"
  sleep 60
  gx 'python3 ~/sample.py stalwart-stalwart-1' > "$out/ram-before-mail.json"
  gx 'python3 ~/mailtest.py stalwart' > "$out/mailtest.json" 2> "$out/mailtest.err"
  gx 'python3 ~/sample.py stalwart-stalwart-1' > "$out/ram-after-mail.json"
  gx 'docker inspect -f "restarts={{.RestartCount}}" stalwart-stalwart-1; docker logs stalwart-stalwart-1 2>&1 | grep -ci "error"' > "$out/state.txt" 2>&1
  mkdir -p "$RAW/stalwart-env"; collect_env "$RAW/stalwart-env"
}
for t in "${TOOLS[@]}"; do ( $t ); done
cp "$D/files/stalwart.yml" "$OUT/"
python3 "$D/summarize.py" "$RAW" "$OUT"
lab/tests/mail/mailtest.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: mailtest.py dms|stalwart. Sends 5 messages alice -> bob over SMTP port 25 (no login, as another server would)
and 5 over submission (port 587, with login), then reads them over IMAP. Per message: SMTP time, seconds until it is visible over IMAP, and
the folder it landed in. Prints JSON."""
import email.utils, imaplib, json, smtplib, ssl, sys, time
kind = sys.argv[1]; PW = "lab-only-not-a-secret"; ctx = ssl._create_unverified_context()
def imap():
    if kind == "stalwart": return imaplib.IMAP4_SSL("127.0.0.1", 1993, ssl_context=ctx)
    return imaplib.IMAP4("127.0.0.1", 1143)
def counts(i):
    out = {}
    for line in i.list()[1]:
        name = line.decode().rsplit(' "', 1)[-1].strip('"') if line.decode().endswith('"') else line.decode().split()[-1]
        t, d = i.status(f'"{name}"', "(MESSAGES)")
        if t == "OK": out[name] = int(d[0].decode().split("MESSAGES")[1].strip(" )"))
    return out
def msg(n, path):
    from email.message import EmailMessage
    m = EmailMessage(); m["From"] = "alice@lab.internal"; m["To"] = "bob@lab.internal"; m["Subject"] = f"{path} test {n}"
    m["Date"] = email.utils.formatdate(); m["Message-ID"] = email.utils.make_msgid(domain="lab.internal"); m.set_content("A short generated message body for the lab test.\n" * 5); return m
res = []; excerpt = {}
for path in ("smtp25", "submission"):
    for n in range(1, 6):
        i = imap(); i.login("bob@lab.internal", PW); before = counts(i); attempts = 0; waited = 0.0
        while True:  # a connection that is closed straight away is retried every 5 s (Stalwart did this intermittently; see the guide)
            attempts += 1; t = time.perf_counter()
            try:
                if path == "smtp25":
                    s = smtplib.SMTP("127.0.0.1", 2525, timeout=60); s.ehlo("lab.internal")
                elif kind == "stalwart":  # port 465, TLS from the first byte (the image does not expose 587)
                    s = smtplib.SMTP_SSL("127.0.0.1", 1465, timeout=60, context=ctx); s.ehlo("lab.internal"); s.login("alice@lab.internal", PW)
                else:
                    s = smtplib.SMTP("127.0.0.1", 1587, timeout=60); s.ehlo("lab.internal")
                    if "starttls" in s.esmtp_features: s.starttls(context=ctx); s.ehlo("lab.internal")
                    s.login("alice@lab.internal", PW)
                break
            except smtplib.SMTPServerDisconnected:
                waited += 5; time.sleep(5)
                if attempts >= 30: raise
        s.send_message(msg(n, path)); smtp_s = time.perf_counter() - t; s.quit(); folder = None
        while time.perf_counter() - t < 60:
            now = counts(i); diff = [k for k in now if now[k] > before.get(k, 0)]
            if diff: folder = diff[0]; break
            time.sleep(0.1)
        vis = time.perf_counter() - t
        if folder and n == 1:
            i.select(f'"{folder}"'); typ, d = i.search(None, "ALL"); h = i.fetch(d[0].split()[-1], "(BODY.PEEK[HEADER])")[1][0][1].decode()
            excerpt[path] = "\n".join(l for l in h.splitlines() if l.split(":")[0] in ("X-Spam-Status", "X-Rspamd-Result", "X-Spamd-Result", "X-Rspamd-Server", "Authentication-Results", "Received-SPF", "DKIM-Signature", "X-Spam-Flag", "X-Spam-Result"))[:1200]
        i.logout(); res.append({"path": path, "n": n, "attempts": attempts, "waited_s": waited, "smtp_s": round(smtp_s, 3), "visible_s": round(vis, 3) if folder else None, "folder": folder})
print(json.dumps({"kind": kind, "messages": res, "header_excerpt": excerpt}, indent=1))
lab/tests/mail/stalwart-setup.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: stalwart-setup.py 1|2.
1: finishes Stalwart's bootstrap mode through JMAP (x:Bootstrap/set) and saves the generated permanent admin login to /tmp/stalwart-admin.txt.
   The caller must then recreate the container WITHOUT STALWART_RECOVERY_ADMIN (with it set, Stalwart stays in recovery mode and starts no mail listeners).
2: creates alice and bob at lab.internal. Prints a JSON log (the admin secret is not printed)."""
import json, subprocess, sys, time, urllib.request, base64
H = "http://127.0.0.1:8080/jmap/"; log = {}
def jmap(user, pw, calls):
    req = urllib.request.Request(H, data=json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"], "methodCalls": calls}).encode(),
        headers={"content-type": "application/json", "Authorization": "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()})
    return json.load(urllib.request.urlopen(req, timeout=30))["methodResponses"]
phase = sys.argv[1]
if phase == "1":
    for _ in range(60):
        try: jmap("admin", "lab-only-not-a-secret", [["x:Bootstrap/get", {"ids": None}, "c"]]); break
        except Exception: time.sleep(2)
    t = time.time()
    r = jmap("admin", "lab-only-not-a-secret", [["x:Bootstrap/set", {"update": {"singleton": {"defaultHostname": "mail.lab.internal", "defaultDomain": "lab.internal", "requestTlsCertificate": False, "generateDkimKeys": True}}}, "c"]])
    upd = r[0][1]["updated"]["singleton"]; open("/tmp/stalwart-admin.txt", "w").write(f"{upd['username']}:{upd['secret']}")
    log["bootstrap_set_s"] = round(time.time() - t, 2); log["admin_user"] = upd["username"]
else:
    admin_user, admin_pw = open("/tmp/stalwart-admin.txt").read().split(":", 1); t = time.time()
    for _ in range(90):
        try: dom = jmap(admin_user, admin_pw, [["x:Domain/get", {"ids": None, "properties": ["name"]}, "c"]])[0][1]["list"]; break
        except Exception: time.sleep(1)
    log["api_after_recreate_s"] = round(time.time() - t, 2); did = dom[0]["id"]; log["domain"] = dom[0]["name"]
    for u in ("alice", "bob"):
        r = jmap(admin_user, admin_pw, [["x:Account/set", {"create": {"a": {"@type": "User", "name": u, "domainId": did, "credentials": {"0": {"@type": "Password", "secret": "lab-only-not-a-secret"}}}}}, "c"]])
        log["create_" + u] = "created" if r[0][1].get("created") else r[0][1]
print(json.dumps(log, indent=1))
lab/tests/mail/dms-cfg.py
Show the full file
#!/usr/bin/env python3
"""Runs in the VM. Usage: dms-cfg.py KEY=VAL ... Writes compose.cfg.yml from compose.yml with those ENABLE_* values set."""
import re, sys
s = open("compose.yml").read()
for kv in sys.argv[1:]:
    k, v = kv.split("=")
    if re.search(rf"^\s+{k}:", s, re.M): s = re.sub(rf'({k}: )"\d"', rf'\1"{v}"', s)
    else: s = s.replace("      ENABLE_CLAMAV:", f'      {k}: "{v}"\n      ENABLE_CLAMAV:')
open("compose.cfg.yml", "w").write(s)

What went wrong

The Stalwart bootstrap needed three attempts. Leaving STALWART_RECOVERY_ADMIN in the compose after setup made the mail ports close every connection. The lab’s compose mounted /opt/stalwart, which is not where Stalwart keeps its data, so a restart would likely have lost the setup. And port 587 is not a listener in the image; submission is on 465 with TLS from the first byte. All rows come from a single complete run of run.sh (docker-mailserver 05:19 to 05:40 UTC, then Stalwart to 05:48, on 2026-11-02), with no page-cache dropping.

What this test did not cover

Sources and how this was made

How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.