Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deploy/mailu/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mailu.env
certs/
data/
53 changes: 53 additions & 0 deletions deploy/mailu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# deploy/mailu — self-hosted mail for `mail.profullstack.com`

Mailu (Postfix + Dovecot + Roundcube + rspamd) as a Docker Compose stack,
fronted by the host Caddy. Full setup, DNS, and architecture: [`docs/mail.md`](../../docs/mail.md).

## Files

| File | Purpose |
|---|---|
| `docker-compose.yml` | the Mailu services (mail ports on host, HTTP on loopback) |
| `mailu.env.example` | config template → copy to `mailu.env` and fill secrets |
| `refresh-certs.sh` | copy Caddy's `mail.$DOMAIN` cert into Mailu, reload (timer) |
| `provision-mailbox.sh` | create a member mailbox / the gateway master user |

`mailu.env`, `certs/`, and `data/` are gitignored (secrets + state).

## Gateway master user

The agentbbs gateway opens any member's mailbox over IMAP with a single secret,
using Dovecot's **master user** feature (login `<name>*<master>`). Enable it with
a Dovecot override so Mailu accepts the `*` separator:

`data/overrides/dovecot/auth-master.conf`:

```
auth_master_user_separator = *
passdb {
driver = static
args = nopassword=y
master = yes
result_success = continue
}
```

Then create the master account and point agentbbs at it:

```bash
./provision-mailbox.sh --master "$(openssl rand -hex 16)"
# AGENTBBS_MAIL_MASTER_USER=gateway, AGENTBBS_MAIL_MASTER_PASS=<that secret>
```

> The exact master-passdb wiring varies by Mailu version; verify against your
> pinned image before relying on it in production. SMTP submission from the
> gateway uses the trusted local relay (`127.0.0.1:25`), not the master user.

## Ops

```bash
docker compose up -d # start
docker compose logs -f smtp # tail Postfix
docker compose exec admin flask mailu config-export # DKIM keys, etc.
docker compose down # stop
```
83 changes: 83 additions & 0 deletions deploy/mailu/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Mailu stack for mail.profullstack.com — self-hosted Postfix + Dovecot +
# Roundcube + rspamd. Coexists with the host Caddy: Mailu owns the mail ports
# (25/465/587/993/995) and serves HTTP on loopback only; Caddy fronts the
# webmail at https://mail.profullstack.com and supplies the TLS cert
# (TLS_FLAVOR=mail, certs copied by refresh-certs.sh).
#
# Pinned to a Mailu release; bump deliberately. See docs/mail.md.
x-environment: &default-environment
env_file: mailu.env

services:
redis:
image: redis:alpine
restart: always
volumes:
- "./data/redis:/data"

front:
image: ghcr.io/mailu/nginx:2024.06
restart: always
env_file: mailu.env
ports:
# Mail ports bound on the host; HTTP only on loopback for Caddy.
- "25:25"
- "465:465"
- "587:587"
- "993:993"
- "995:995"
- "127.0.0.1:8080:80"
volumes:
- "./certs:/certs"
- "./data/overrides/nginx:/overrides:ro"
depends_on:
- redis

admin:
image: ghcr.io/mailu/admin:2024.06
restart: always
env_file: mailu.env
volumes:
- "./data/data:/data"
- "./data/dkim:/dkim"
depends_on:
- redis

imap:
image: ghcr.io/mailu/dovecot:2024.06
restart: always
env_file: mailu.env
volumes:
- "./data/mail:/mail"
- "./data/overrides/dovecot:/overrides:ro"
depends_on:
- front

smtp:
image: ghcr.io/mailu/postfix:2024.06
restart: always
env_file: mailu.env
volumes:
- "./data/mailqueue:/queue"
- "./data/overrides/postfix:/overrides:ro"
depends_on:
- front

antispam:
image: ghcr.io/mailu/rspamd:2024.06
restart: always
env_file: mailu.env
volumes:
- "./data/filter:/var/lib/rspamd"
- "./data/overrides/rspamd:/overrides:ro"
depends_on:
- front

webmail:
image: ghcr.io/mailu/roundcube:2024.06
restart: always
env_file: mailu.env
volumes:
- "./data/webmail:/data"
depends_on:
- front
44 changes: 44 additions & 0 deletions deploy/mailu/mailu.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway).
#
# Generate secrets with: openssl rand -hex 16

# --- General -----------------------------------------------------------------
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
POSTMASTER=postmaster
# Apex profullstack.com is reserved for corporate mail and is NOT served here.

# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed
# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
TLS_FLAVOR=mail

# --- Features ----------------------------------------------------------------
ADMIN=true # the admin UI (fronted at /admin via Caddy, internal only)
WEBMAIL=roundcube # the only member-facing surface (https://mail.profullstack.com)
WEBDAV=none
ANTIVIRUS=none # set to clamav on a 4GB+ host
ANTISPAM=true

# --- Networking --------------------------------------------------------------
# Mailu's front binds the mail ports on the host and HTTP on loopback only;
# Caddy reverse-proxies https://mail.profullstack.com to BIND_ADDRESS4:80.
BIND_ADDRESS4=127.0.0.1
SUBNET=192.168.203.0/24
MESSAGE_SIZE_LIMIT=52428800 # 50 MB

# --- Gateway (the BBS reads/sends on behalf of members) ----------------------
# A Dovecot master user lets the agentbbs gateway open any member's mailbox with
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
# Mirror these into the agentbbs service env:
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
# AGENTBBS_MAIL_MASTER_USER=gateway
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set>

# --- Admin bootstrap ---------------------------------------------------------
INITIAL_ADMIN_ACCOUNT=admin
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
INITIAL_ADMIN_PW=CHANGEME_admin_password
50 changes: 50 additions & 0 deletions deploy/mailu/provision-mailbox.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# provision-mailbox.sh — create or update a member mailbox on the Mailu stack.
# Run on the mail host. The agentbbs gateway opens any member's mailbox via the
# Dovecot master user, so members never need an individual IMAP password — but
# the mailbox must exist, which is what this creates.
#
# Usage:
# provision-mailbox.sh <name> # create <name>@$DOMAIN (random pw)
# provision-mailbox.sh --master <pass> # (re)create the gateway master user
#
# Idempotent: re-running for an existing user is a no-op (or a password reset
# with --password). Wraps Mailu's admin CLI (flask mailu ...).
set -euo pipefail

MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}"
MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB

cli() { ( cd "$MAILU_DIR" && docker compose exec -T admin flask mailu "$@" ); }

if [ "${1:-}" = "--master" ]; then
pass="${2:?usage: provision-mailbox.sh --master <password>}"
# A Dovecot master user can authenticate as any mailbox: login "<name>*gateway".
# Implemented in Mailu as a normal user flagged for master access via an
# override (see docs/mail.md); here we ensure the account + password exist.
cli user "$MASTER_USER" "$DOMAIN" "$pass" 2>/dev/null \
|| cli password "$MASTER_USER" "$DOMAIN" "$pass"
echo "gateway master user ${MASTER_USER}@${DOMAIN} set"
exit 0
fi

name="${1:?usage: provision-mailbox.sh <name>}"
pass="${2:-$(openssl rand -hex 16)}"

if cli user-import "$name" "$DOMAIN" "$(openssl passwd -6 "$pass")" 2>/dev/null; then
:
else
# already exists or older CLI: fall back to `user` (no-op if present)
cli user "$name" "$DOMAIN" "$pass" 2>/dev/null || true
fi
# Enforce a per-mailbox quota.
cli config-update <<EOF 2>/dev/null || true
users:
- email: ${name}@${DOMAIN}
quota_bytes: ${QUOTA_BYTES}
EOF

echo "mailbox ${name}@${DOMAIN} provisioned"
41 changes: 41 additions & 0 deletions deploy/mailu/refresh-certs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# refresh-certs.sh — copy Caddy's Let's Encrypt cert for mail.$DOMAIN into the
# Mailu certs dir (TLS_FLAVOR=mail), so Postfix/Dovecot TLS on 465/587/993 track
# Caddy's auto-renewals. Mirrors deploy/news-refresh-certs.sh: Caddy is the only
# ACME client on the box (it serves the mail.$DOMAIN site block), and we reuse
# that cert rather than running a second ACME client inside Mailu.
#
# Install to /usr/local/bin/agentbbs-mailu-certs and run from a timer. Reloads
# the Mailu front/smtp/imap so the new cert is picked up. Exits non-zero
# (touching nothing) until Caddy has issued the cert.
set -euo pipefail

DOMAIN="${DOMAIN:?set DOMAIN}"
MAIL_HOST="${MAIL_HOST:-mail.${DOMAIN}}"
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
CERT_DIR="${CERT_DIR:-$MAILU_DIR/certs}"
CADDY_DATA="${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}"

# Caddy stores certs under certificates/<acme-dir>/<host>/<host>.{crt,key};
# the ACME directory segment varies (prod vs staging), so glob for it.
crt="$(ls "$CADDY_DATA"/certificates/*/"$MAIL_HOST"/"$MAIL_HOST".crt 2>/dev/null | head -1 || true)"
key="$(ls "$CADDY_DATA"/certificates/*/"$MAIL_HOST"/"$MAIL_HOST".key 2>/dev/null | head -1 || true)"
if [ -z "$crt" ] || [ -z "$key" ]; then
echo "no Caddy cert for $MAIL_HOST yet (looked under $CADDY_DATA/certificates)"
exit 1
fi

install -d -m 0750 "$CERT_DIR"

changed=0
# Mailu (TLS_FLAVOR=mail) reads cert.pem / key.pem from its /certs mount.
if ! cmp -s "$crt" "$CERT_DIR/cert.pem"; then install -m 0644 "$crt" "$CERT_DIR/cert.pem"; changed=1; fi
if ! cmp -s "$key" "$CERT_DIR/key.pem"; then install -m 0640 "$key" "$CERT_DIR/key.pem"; changed=1; fi

if [ "$changed" = 1 ]; then
echo "updated Mailu TLS cert for $MAIL_HOST; reloading Mailu"
( cd "$MAILU_DIR" && docker compose restart front smtp imap >/dev/null 2>&1 || true )
else
echo "Mailu TLS cert for $MAIL_HOST already current"
fi
118 changes: 118 additions & 0 deletions docs/mail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Mail — self-hosted Mailu at `mail.profullstack.com`

AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
`<name>@mail.profullstack.com`, reached two ways:

- **Webmail** — `https://mail.profullstack.com` (Roundcube), the only
member-facing mail surface.
- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry
or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for
agents). It connects to this stack.

The apex `profullstack.com` is **reserved for corporate mail** and is not served
here — member mail lives only on the `mail.` subdomain.

## Architecture

The host already runs **Caddy** (owns `:80`/`:443`) and the **agentbbs** process.
Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack:

- Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`.
- Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy**
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin).
- **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the
only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site
block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh)
copies it into Mailu and reloads it on renewal — the same pattern as the
Ergo/IRC and NNTP cert refreshers.
- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot
**master user** (one secret opens any mailbox), SMTP via the co-located relay
on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password.

```
┌─────────── Caddy (:443) ───────────┐
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP)
└───────────────┬─────────────────────┘
│ copies LE cert (refresh-certs.sh)
clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd
agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶
```

## DNS

`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set:

| Type | Host | Value |
|---|---|---|
| A | `mail.profullstack.com` | host IP |
| A | `smtp.profullstack.com` | host IP |
| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` |
| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` |
| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` |
| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot |
| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) |

> **Port 25 / deliverability:** many cloud providers block outbound `:25` by
> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay
> outbound through a smarthost. Inbound MX and the gateway's local submission
> work regardless.

## Install

```bash
cd /opt/agentbbs/deploy/mailu
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc.
docker compose up -d
# seed the gateway master user + (optionally) backfill member mailboxes:
AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)"
```

Add the Caddy site (setup.sh writes this when `MAIL=1`):

```
mail.profullstack.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
```

Then install the cert refresher on a timer (setup.sh does this too):

```bash
install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs
# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it.
```

## agentbbs gateway env

Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work:

| Var | Value |
|---|---|
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
| `AGENTBBS_MAIL_MASTER_USER` | `gateway` |
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above |

## Provisioning member mailboxes

A mailbox must exist before the gateway can open it. Provision when a member
becomes paid (or backfill):

```bash
deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com
```

The Dovecot **master user** (`gateway`) then authenticates as any member with
the login form `alice*gateway` + the master password — which is exactly what
`internal/mailbox`'s IMAP adapter sends. See
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user
override and operational details.

## Webmail only for members

Members are pointed at `https://mail.profullstack.com` (Roundcube) and the BBS
`Mail` client — they are not given the Mailu admin UI or alias management. Admin
is operator-only.
Loading
Loading