# JetsonHL Mail Server — Operations Manual

**Stalwart v0.16.17** · single container · arm64 · `/srv/stack/mail`
Last updated 2026-08-16

---

## 1. What it is

One Rust binary providing SMTP, submission, IMAP, JMAP, Sieve, spam filtering and mailbox storage. No Postfix, no Dovecot, no rspamd — one process, one config file, one data directory.

Chosen over docker-mailserver (5 processes, ~1.2 GB), Mailu (8 containers) and Mailcow (no arm64, needs ~6 GB). Idles at 150–300 MB and is capped at 1 GB so it can never starve the Supabase stack.

---

## 2. Where everything lives

| Thing | Path |
|---|---|
| Compose file | `/srv/stack/mail/docker-compose.yml` |
| Config (JSON) | `/srv/stack/mail/etc/config.json` → container `/etc/stalwart/config.json` |
| Mail store (RocksDB) | `/srv/stack/mail/store/` → container `/var/lib/stalwart/` |
| First-boot log | `/srv/stack/mail/firstrun.log` |

**Both mounts are mandatory.** The image runs `stalwart --config /etc/stalwart/config.json`. Before those mounts existed, every restart wiped the config and the store.

The container runs as **uid 2000 (`stalwart`)**, not root. Anything it must write has to be owned by 2000. This was the root cause of every early failure — the setup wizard appeared to work and silently saved nothing.

To fix ownership after touching files from the host:

```
docker run --rm -u 0 --entrypoint sh \
  -v /srv/stack/mail:/m stalwartlabs/stalwart:v0.16.17 \
  -c 'chown -R 2000:2000 /m/etc /m/store'
```

---

## 3. Ports

| Port | Bound to | Purpose |
|---|---|---|
| 2525 → 25 | `127.0.0.1` | SMTP. **Loopback only.** Never exposed. The Cloudflare inbound path delivers here. |
| 587 | `100.64.0.1` | Submission — your clients sending mail. Headscale mesh only. *Not yet enabled.* |
| 993 | `100.64.0.1` | IMAPS — your clients reading mail. Headscale mesh only. |
| 3090 → 8080 | `192.168.68.94` | Admin UI. LAN only. Move back to `127.0.0.1` when setup is finished. |

Port 25 is **not** open to the internet and never will be. The box is on a residential IP: Spamhaus PBL listed, no PTR control, ISP blocks 25 both ways.

---

## 4. Administration

**Admin UI:** `http://192.168.68.94:3090/` from the LAN.
**Account:** `admin@badsyntx.com`
Password is in the credentials store, not here.

If the LAN is unreachable, tunnel from any machine:

```
ssh -L 3090:127.0.0.1:3090 thomas@192.168.68.94
```

then browse `http://localhost:3090/`.

### Everyday commands

```
cd /srv/stack/mail

sudo docker compose up -d              # start / apply compose changes
sudo docker restart stalwart           # restart after a config edit
sudo docker logs stalwart --tail 50    # recent log
sudo docker logs stalwart -f           # follow
sudo docker compose down               # stop
```

### Health check

```
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.68.94:3090/healthz/live
```

`200` means the database opened and the server is serving.

---

## 5. Testing it

**SMTP banner:**

```
python3 -c "
import socket
s=socket.create_connection(('127.0.0.1',2525),5)
print(s.recv(200).decode().strip())
s.sendall(b'EHLO probe\r\n'); print(s.recv(900).decode())
s.close()"
```

Expect `220 mail.badsyntx.com Stalwart ESMTP at your service`.

**IMAP login:**

```
python3 -c "
import imaplib,ssl
c=ssl.create_default_context(); c.check_hostname=False; c.verify_mode=ssl.CERT_NONE
m=imaplib.IMAP4_SSL('100.64.0.1',993,ssl_context=c)
m.login('admin@badsyntx.com','<password>')
print(m.list()[1]); m.logout()"
```

**End-to-end delivery** — inject on 25, then confirm it appears in INBOX over IMAP. This is the test that matters; SMTP returning `250 OK` proves nothing on its own.

---

## 6. Known gaps (as of 2026-08-16)

1. **Delivery drops.** A message accepted on port 25 for `admin@badsyntx.com` returns `250 OK` at MAIL, RCPT and DATA, then never lands in INBOX and is not in Junk. `badsyntx.com` is not registered as a local domain, so there is no rule mapping it to the mailbox. **Fix first** — the inbound path is worthless until this works.
2. **Port 587 refused.** No submission listener configured. No mail client can send until it exists.
3. **Self-signed TLS.** IMAPS presents a self-signed certificate. Fine over the mesh; clients will warn.
4. **DNSSEC unavailable.** The resolver can't validate, so Stalwart disabled DANE to avoid deferring mail. Harmless, but it's why that warning appears at every boot.

---

## 7. The architecture (planned)

**Inbound — no open port 25:**

```
sender → Cloudflare Email Routing (MX)
       → Cloudflare Email Worker
       → HTTPS over the existing cloudflared tunnel
       → local injector
       → SMTP 127.0.0.1:2525 → mailbox
```

Limit: 25 MiB per message. Cloudflare sees plaintext. Every domain must be on Cloudflare DNS.

**Outbound — relay, never direct:**

Amazon SES, one account, all domains. ~$0.10 per thousand. Per-domain DKIM via three CNAMEs SES generates. SPF must name the provider, never this box's IP.

**Reachability:** mailbox access stays headscale-only. Machines that only send alerts should talk to SES directly with their own credentials rather than being given mesh access.

---

## 8. Domains

`badsyntx.com` is the first mail domain.

Hands off the MX on `arecusa.com` and `wickandember.co` — both carry live Microsoft 365 business mail. Neither can become a Jetson mailbox domain without breaking real company mail.

Each domain that does get mail needs: MX (auto-created by Email Routing), SPF naming the relay, DMARC starting at `p=none`, and DKIM CNAMEs from the relay provider.

---

## 9. Backup

Not yet configured. Plan: nightly `restic` snapshot of `/srv/stack/mail/store` to Cloudflare R2, retention 7 daily / 4 weekly / 6 monthly. Mail bodies are the one thing on this box that cannot be regenerated.
