Skip to content

Commit 8113b6d

Browse files
authored
Merge pull request #17 from profullstack/feat/mailu-stack
Self-host Mailu mail stack at mail.profullstack.com
2 parents 45c46db + cb09300 commit 8113b6d

8 files changed

Lines changed: 464 additions & 1 deletion

File tree

deploy/mailu/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mailu.env
2+
certs/
3+
data/

deploy/mailu/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# deploy/mailu — self-hosted mail for `mail.profullstack.com`
2+
3+
Mailu (Postfix + Dovecot + Roundcube + rspamd) as a Docker Compose stack,
4+
fronted by the host Caddy. Full setup, DNS, and architecture: [`docs/mail.md`](../../docs/mail.md).
5+
6+
## Files
7+
8+
| File | Purpose |
9+
|---|---|
10+
| `docker-compose.yml` | the Mailu services (mail ports on host, HTTP on loopback) |
11+
| `mailu.env.example` | config template → copy to `mailu.env` and fill secrets |
12+
| `refresh-certs.sh` | copy Caddy's `mail.$DOMAIN` cert into Mailu, reload (timer) |
13+
| `provision-mailbox.sh` | create a member mailbox / the gateway master user |
14+
15+
`mailu.env`, `certs/`, and `data/` are gitignored (secrets + state).
16+
17+
## Gateway master user
18+
19+
The agentbbs gateway opens any member's mailbox over IMAP with a single secret,
20+
using Dovecot's **master user** feature (login `<name>*<master>`). Enable it with
21+
a Dovecot override so Mailu accepts the `*` separator:
22+
23+
`data/overrides/dovecot/auth-master.conf`:
24+
25+
```
26+
auth_master_user_separator = *
27+
passdb {
28+
driver = static
29+
args = nopassword=y
30+
master = yes
31+
result_success = continue
32+
}
33+
```
34+
35+
Then create the master account and point agentbbs at it:
36+
37+
```bash
38+
./provision-mailbox.sh --master "$(openssl rand -hex 16)"
39+
# AGENTBBS_MAIL_MASTER_USER=gateway, AGENTBBS_MAIL_MASTER_PASS=<that secret>
40+
```
41+
42+
> The exact master-passdb wiring varies by Mailu version; verify against your
43+
> pinned image before relying on it in production. SMTP submission from the
44+
> gateway uses the trusted local relay (`127.0.0.1:25`), not the master user.
45+
46+
## Ops
47+
48+
```bash
49+
docker compose up -d # start
50+
docker compose logs -f smtp # tail Postfix
51+
docker compose exec admin flask mailu config-export # DKIM keys, etc.
52+
docker compose down # stop
53+
```

deploy/mailu/docker-compose.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Mailu stack for mail.profullstack.com — self-hosted Postfix + Dovecot +
2+
# Roundcube + rspamd. Coexists with the host Caddy: Mailu owns the mail ports
3+
# (25/465/587/993/995) and serves HTTP on loopback only; Caddy fronts the
4+
# webmail at https://mail.profullstack.com and supplies the TLS cert
5+
# (TLS_FLAVOR=mail, certs copied by refresh-certs.sh).
6+
#
7+
# Pinned to a Mailu release; bump deliberately. See docs/mail.md.
8+
x-environment: &default-environment
9+
env_file: mailu.env
10+
11+
services:
12+
redis:
13+
image: redis:alpine
14+
restart: always
15+
volumes:
16+
- "./data/redis:/data"
17+
18+
front:
19+
image: ghcr.io/mailu/nginx:2024.06
20+
restart: always
21+
env_file: mailu.env
22+
ports:
23+
# Mail ports bound on the host; HTTP only on loopback for Caddy.
24+
- "25:25"
25+
- "465:465"
26+
- "587:587"
27+
- "993:993"
28+
- "995:995"
29+
- "127.0.0.1:8080:80"
30+
volumes:
31+
- "./certs:/certs"
32+
- "./data/overrides/nginx:/overrides:ro"
33+
depends_on:
34+
- redis
35+
36+
admin:
37+
image: ghcr.io/mailu/admin:2024.06
38+
restart: always
39+
env_file: mailu.env
40+
volumes:
41+
- "./data/data:/data"
42+
- "./data/dkim:/dkim"
43+
depends_on:
44+
- redis
45+
46+
imap:
47+
image: ghcr.io/mailu/dovecot:2024.06
48+
restart: always
49+
env_file: mailu.env
50+
volumes:
51+
- "./data/mail:/mail"
52+
- "./data/overrides/dovecot:/overrides:ro"
53+
depends_on:
54+
- front
55+
56+
smtp:
57+
image: ghcr.io/mailu/postfix:2024.06
58+
restart: always
59+
env_file: mailu.env
60+
volumes:
61+
- "./data/mailqueue:/queue"
62+
- "./data/overrides/postfix:/overrides:ro"
63+
depends_on:
64+
- front
65+
66+
antispam:
67+
image: ghcr.io/mailu/rspamd:2024.06
68+
restart: always
69+
env_file: mailu.env
70+
volumes:
71+
- "./data/filter:/var/lib/rspamd"
72+
- "./data/overrides/rspamd:/overrides:ro"
73+
depends_on:
74+
- front
75+
76+
webmail:
77+
image: ghcr.io/mailu/roundcube:2024.06
78+
restart: always
79+
env_file: mailu.env
80+
volumes:
81+
- "./data/webmail:/data"
82+
depends_on:
83+
- front

deploy/mailu/mailu.env.example

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env
2+
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway).
3+
#
4+
# Generate secrets with: openssl rand -hex 16
5+
6+
# --- General -----------------------------------------------------------------
7+
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
8+
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com
9+
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
10+
POSTMASTER=postmaster
11+
# Apex profullstack.com is reserved for corporate mail and is NOT served here.
12+
13+
# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed
14+
# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
15+
TLS_FLAVOR=mail
16+
17+
# --- Features ----------------------------------------------------------------
18+
ADMIN=true # the admin UI (fronted at /admin via Caddy, internal only)
19+
WEBMAIL=roundcube # the only member-facing surface (https://mail.profullstack.com)
20+
WEBDAV=none
21+
ANTIVIRUS=none # set to clamav on a 4GB+ host
22+
ANTISPAM=true
23+
24+
# --- Networking --------------------------------------------------------------
25+
# Mailu's front binds the mail ports on the host and HTTP on loopback only;
26+
# Caddy reverse-proxies https://mail.profullstack.com to BIND_ADDRESS4:80.
27+
BIND_ADDRESS4=127.0.0.1
28+
SUBNET=192.168.203.0/24
29+
MESSAGE_SIZE_LIMIT=52428800 # 50 MB
30+
31+
# --- Gateway (the BBS reads/sends on behalf of members) ----------------------
32+
# A Dovecot master user lets the agentbbs gateway open any member's mailbox with
33+
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
34+
# Mirror these into the agentbbs service env:
35+
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
36+
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
37+
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
38+
# AGENTBBS_MAIL_MASTER_USER=gateway
39+
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set>
40+
41+
# --- Admin bootstrap ---------------------------------------------------------
42+
INITIAL_ADMIN_ACCOUNT=admin
43+
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
44+
INITIAL_ADMIN_PW=CHANGEME_admin_password

deploy/mailu/provision-mailbox.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env bash
2+
#
3+
# provision-mailbox.sh — create or update a member mailbox on the Mailu stack.
4+
# Run on the mail host. The agentbbs gateway opens any member's mailbox via the
5+
# Dovecot master user, so members never need an individual IMAP password — but
6+
# the mailbox must exist, which is what this creates.
7+
#
8+
# Usage:
9+
# provision-mailbox.sh <name> # create <name>@$DOMAIN (random pw)
10+
# provision-mailbox.sh --master <pass> # (re)create the gateway master user
11+
#
12+
# Idempotent: re-running for an existing user is a no-op (or a password reset
13+
# with --password). Wraps Mailu's admin CLI (flask mailu ...).
14+
set -euo pipefail
15+
16+
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
17+
DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}"
18+
MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
19+
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB
20+
21+
cli() { ( cd "$MAILU_DIR" && docker compose exec -T admin flask mailu "$@" ); }
22+
23+
if [ "${1:-}" = "--master" ]; then
24+
pass="${2:?usage: provision-mailbox.sh --master <password>}"
25+
# A Dovecot master user can authenticate as any mailbox: login "<name>*gateway".
26+
# Implemented in Mailu as a normal user flagged for master access via an
27+
# override (see docs/mail.md); here we ensure the account + password exist.
28+
cli user "$MASTER_USER" "$DOMAIN" "$pass" 2>/dev/null \
29+
|| cli password "$MASTER_USER" "$DOMAIN" "$pass"
30+
echo "gateway master user ${MASTER_USER}@${DOMAIN} set"
31+
exit 0
32+
fi
33+
34+
name="${1:?usage: provision-mailbox.sh <name>}"
35+
pass="${2:-$(openssl rand -hex 16)}"
36+
37+
if cli user-import "$name" "$DOMAIN" "$(openssl passwd -6 "$pass")" 2>/dev/null; then
38+
:
39+
else
40+
# already exists or older CLI: fall back to `user` (no-op if present)
41+
cli user "$name" "$DOMAIN" "$pass" 2>/dev/null || true
42+
fi
43+
# Enforce a per-mailbox quota.
44+
cli config-update <<EOF 2>/dev/null || true
45+
users:
46+
- email: ${name}@${DOMAIN}
47+
quota_bytes: ${QUOTA_BYTES}
48+
EOF
49+
50+
echo "mailbox ${name}@${DOMAIN} provisioned"

deploy/mailu/refresh-certs.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env bash
2+
#
3+
# refresh-certs.sh — copy Caddy's Let's Encrypt cert for mail.$DOMAIN into the
4+
# Mailu certs dir (TLS_FLAVOR=mail), so Postfix/Dovecot TLS on 465/587/993 track
5+
# Caddy's auto-renewals. Mirrors deploy/news-refresh-certs.sh: Caddy is the only
6+
# ACME client on the box (it serves the mail.$DOMAIN site block), and we reuse
7+
# that cert rather than running a second ACME client inside Mailu.
8+
#
9+
# Install to /usr/local/bin/agentbbs-mailu-certs and run from a timer. Reloads
10+
# the Mailu front/smtp/imap so the new cert is picked up. Exits non-zero
11+
# (touching nothing) until Caddy has issued the cert.
12+
set -euo pipefail
13+
14+
DOMAIN="${DOMAIN:?set DOMAIN}"
15+
MAIL_HOST="${MAIL_HOST:-mail.${DOMAIN}}"
16+
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
17+
CERT_DIR="${CERT_DIR:-$MAILU_DIR/certs}"
18+
CADDY_DATA="${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}"
19+
20+
# Caddy stores certs under certificates/<acme-dir>/<host>/<host>.{crt,key};
21+
# the ACME directory segment varies (prod vs staging), so glob for it.
22+
crt="$(ls "$CADDY_DATA"/certificates/*/"$MAIL_HOST"/"$MAIL_HOST".crt 2>/dev/null | head -1 || true)"
23+
key="$(ls "$CADDY_DATA"/certificates/*/"$MAIL_HOST"/"$MAIL_HOST".key 2>/dev/null | head -1 || true)"
24+
if [ -z "$crt" ] || [ -z "$key" ]; then
25+
echo "no Caddy cert for $MAIL_HOST yet (looked under $CADDY_DATA/certificates)"
26+
exit 1
27+
fi
28+
29+
install -d -m 0750 "$CERT_DIR"
30+
31+
changed=0
32+
# Mailu (TLS_FLAVOR=mail) reads cert.pem / key.pem from its /certs mount.
33+
if ! cmp -s "$crt" "$CERT_DIR/cert.pem"; then install -m 0644 "$crt" "$CERT_DIR/cert.pem"; changed=1; fi
34+
if ! cmp -s "$key" "$CERT_DIR/key.pem"; then install -m 0640 "$key" "$CERT_DIR/key.pem"; changed=1; fi
35+
36+
if [ "$changed" = 1 ]; then
37+
echo "updated Mailu TLS cert for $MAIL_HOST; reloading Mailu"
38+
( cd "$MAILU_DIR" && docker compose restart front smtp imap >/dev/null 2>&1 || true )
39+
else
40+
echo "Mailu TLS cert for $MAIL_HOST already current"
41+
fi

docs/mail.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Mail — self-hosted Mailu at `mail.profullstack.com`
2+
3+
AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
4+
`<name>@mail.profullstack.com`, reached two ways:
5+
6+
- **Webmail**`https://mail.profullstack.com` (Roundcube), the only
7+
member-facing mail surface.
8+
- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry
9+
or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for
10+
agents). It connects to this stack.
11+
12+
The apex `profullstack.com` is **reserved for corporate mail** and is not served
13+
here — member mail lives only on the `mail.` subdomain.
14+
15+
## Architecture
16+
17+
The host already runs **Caddy** (owns `:80`/`:443`) and the **agentbbs** process.
18+
Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack:
19+
20+
- Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`.
21+
- Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy**
22+
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin).
23+
- **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the
24+
only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site
25+
block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh)
26+
copies it into Mailu and reloads it on renewal — the same pattern as the
27+
Ergo/IRC and NNTP cert refreshers.
28+
- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot
29+
**master user** (one secret opens any mailbox), SMTP via the co-located relay
30+
on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password.
31+
32+
```
33+
┌─────────── Caddy (:443) ───────────┐
34+
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP)
35+
└───────────────┬─────────────────────┘
36+
│ copies LE cert (refresh-certs.sh)
37+
clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd
38+
39+
agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶
40+
```
41+
42+
## DNS
43+
44+
`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set:
45+
46+
| Type | Host | Value |
47+
|---|---|---|
48+
| A | `mail.profullstack.com` | host IP |
49+
| A | `smtp.profullstack.com` | host IP |
50+
| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` |
51+
| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` |
52+
| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` |
53+
| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot |
54+
| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) |
55+
56+
> **Port 25 / deliverability:** many cloud providers block outbound `:25` by
57+
> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay
58+
> outbound through a smarthost. Inbound MX and the gateway's local submission
59+
> work regardless.
60+
61+
## Install
62+
63+
```bash
64+
cd /opt/agentbbs/deploy/mailu
65+
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc.
66+
docker compose up -d
67+
# seed the gateway master user + (optionally) backfill member mailboxes:
68+
AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)"
69+
```
70+
71+
Add the Caddy site (setup.sh writes this when `MAIL=1`):
72+
73+
```
74+
mail.profullstack.com {
75+
encode zstd gzip
76+
reverse_proxy 127.0.0.1:8080
77+
}
78+
```
79+
80+
Then install the cert refresher on a timer (setup.sh does this too):
81+
82+
```bash
83+
install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs
84+
# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it.
85+
```
86+
87+
## agentbbs gateway env
88+
89+
Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work:
90+
91+
| Var | Value |
92+
|---|---|
93+
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
94+
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
95+
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
96+
| `AGENTBBS_MAIL_MASTER_USER` | `gateway` |
97+
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above |
98+
99+
## Provisioning member mailboxes
100+
101+
A mailbox must exist before the gateway can open it. Provision when a member
102+
becomes paid (or backfill):
103+
104+
```bash
105+
deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com
106+
```
107+
108+
The Dovecot **master user** (`gateway`) then authenticates as any member with
109+
the login form `alice*gateway` + the master password — which is exactly what
110+
`internal/mailbox`'s IMAP adapter sends. See
111+
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user
112+
override and operational details.
113+
114+
## Webmail only for members
115+
116+
Members are pointed at `https://mail.profullstack.com` (Roundcube) and the BBS
117+
`Mail` client — they are not given the Mailu admin UI or alias management. Admin
118+
is operator-only.

0 commit comments

Comments
 (0)