Feat/mail all members (#55)

* feat(mail): give every verified member a free @bbs.profullstack.com mailbox

Email was built but paid-only (Founding Lifetime gate) and never wired to a
running backend. Make it a free benefit of membership and split the address
domain from the mail-server host.

- internal/mailu: Mailu admin-API client; EnsureUser idempotently provisions a
  mailbox via the loopback admin REST API (token = mailu.env API_TOKEN).
- main.go: auto-provision <name>@<mailDomain> at join@ verification and on first
  Mail open; un-gate the Mail hub entry + mail@ (membership/email-verified, not
  Premium); address domain (AGENTBBS_MAIL_ADDR_DOMAIN, default the BBS host) is
  now distinct from the mail server host (AGENTBBS_MAIL_DOMAIN) and the webmail
  URL. Drop the forwardemail alias path (Mailu now owns delivery for everyone).
- mailbox: gate on membership (a registered handle) instead of Paid;
  ErrNotPaid -> ErrNotMember.
- join@ copy: list email under free membership; premium now pitches custom
  domains + Tor only.
- setup.sh / docs/mail.md / deploy/mailu: address-domain vs server-host split,
  Mailu API token, MX for the address domain, local-relay SMTP for verify codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(mailu): pin Docker network subnet to match SUBNET; ignore runtime state

The base compose declares no network, so Docker assigns the default bridge an
arbitrary subnet that won't match mailu.env SUBNET — breaking Mailu's internal
service auth/relay. Add a docker-compose.override.yml.example that pins the
default network to 192.168.203.0/24, and gitignore the live override + Mailu
runtime state (mailu.env, certs/, data/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(mail): plaintext loopback IMAP so the gateway bypasses Mailu's front

Mailu's front (nginx mail proxy) pre-authenticates against Mailu's user DB before
proxying to Dovecot, which rejects the Dovecot master-user login <addr>*gateway.
The gateway must reach Dovecot directly. The imap container has no TLS cert (only
the front does), so the bypass is plaintext over loopback — the master password
never leaves the host.

- mailbox: IMAPConfig.Plaintext dials with DialInsecure (loopback only).
- main.go: mailClientFor sets Plaintext from AGENTBBS_MAIL_IMAP_PLAINTEXT.
- override.example: add the unbound resolver (admin needs DNSSEC), webmail image
  fix (2024.06 uses mailu/webmail), and publish Dovecot 143 on 127.0.0.1:14143.
- docs/mail.md: document the front-bypass, the dovecot.conf master passdb (Mailu
  includes that exact filename), and the 644 master-users perms (640 = temp_fail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* deploy(mailu): wire gateway IMAP to the loopback Dovecot path in setup.sh

setup.sh §9e set AGENTBBS_MAIL_IMAP_ADDR to the front's :993, which the front's
auth proxy rejects for the master-user login (and would clobber the working
loopback wiring on every self-update). Point it at 127.0.0.1:14143 +
AGENTBBS_MAIL_IMAP_PLAINTEXT=1 instead, matching the override + docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(mail): give free members a webmail password at join@

The gateway opens mailboxes via the Dovecot master user (no member password),
but webmail (Roundcube) needs the member to have a password. join@ now sets a
fresh, readable webmail password via the Mailu API and shows it with the webmail
URL + login, so free members can use webmail at mail.profullstack.com.

- mailu: SetPassword (PATCH /user/<email> raw_password) + test.
- main.go: setWebmailPassword + readablePassword; join@ displays url/login/password.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-23 03:38:31 -07:00 committed by GitHub
parent de5517c000
commit 006235ce92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 725 additions and 164 deletions

View file

@ -1,16 +1,22 @@
# Mail — self-hosted Mailu at `mail.profullstack.com`
# Mail — self-hosted Mailu
AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
`<name>@mail.profullstack.com`, reached two ways:
AgentBBS gives **every verified member** (free and paid alike) a real mailbox at
`<name>@bbs.profullstack.com`, reached two ways:
- **Webmail**`https://mail.profullstack.com` (Roundcube), the only
member-facing mail surface.
- **Webmail**`https://mail.profullstack.com` (Roundcube).
- **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.
Two distinct names are involved — don't conflate them:
| | value | role |
|---|---|---|
| **Address domain** | `bbs.profullstack.com` | the `@`-part of member addresses (`AGENTBBS_MAIL_ADDR_DOMAIN`) |
| **Mail server host** | `mail.profullstack.com` | where IMAP/SMTP/webmail actually run (`AGENTBBS_MAIL_DOMAIN`) |
The apex `profullstack.com` is **reserved for corporate mail** and is not served
here — member mail lives only on the `mail.` subdomain.
here.
## Architecture
@ -19,97 +25,139 @@ 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).
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin + API).
- **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.
only ACME client). Caddy obtains the `mail.profullstack.com` cert; the cert
refresher copies it into Mailu and reloads on renewal.
- 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.
- **Provisioning** is automatic: when a member verifies their email at `join@`
(or opens `Mail`), agentbbs ensures `<name>@bbs.profullstack.com` exists via
Mailu's **admin REST API** (`internal/mailu`, token = `API_TOKEN`). The manual
`deploy/mailu/provision-mailbox.sh` is only for the gateway master user and
backfills.
```
┌─────────── Caddy (:443) ───────────┐
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP)
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front: webmail/admin/API)
└───────────────┬─────────────────────┘
│ 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)──▶
agentbbs ──admin API (token) http://127.0.0.1:8080/api/v1──▶ (auto-provision)
```
## DNS
`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set:
Mail is delivered to the **address domain** (`bbs.profullstack.com`), so its MX
must point at the **server host** (`mail.profullstack.com`):
| 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 |
| MX | `bbs.profullstack.com` | `10 mail.profullstack.com.` |
| TXT (SPF) | `bbs.profullstack.com` | `v=spf1 mx -all` |
| TXT (DMARC) | `_dmarc.bbs.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@bbs.profullstack.com` |
| TXT (DKIM) | `dkim._domainkey.bbs.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.
> **Port 25 / deliverability:** many cloud providers (incl. DigitalOcean) 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.
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, API_TOKEN, DOMAIN=bbs.profullstack.com, HOSTNAMES=mail.profullstack.com
docker compose up -d
# seed the gateway master user + (optionally) backfill member mailboxes:
# add the address domain + the gateway master user:
docker compose exec admin flask mailu domain bbs.profullstack.com
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.
```
setup.sh writes the Caddy `mail.profullstack.com` site and the cert-refresh
timer when `MAIL=1`, and brings the stack up once `mailu.env` exists.
## agentbbs gateway env
Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work:
Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones):
| Var | Value |
|---|---|
| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` |
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
| `AGENTBBS_MAIL_IMAP_ADDR` | `127.0.0.1:14143` (Dovecot direct, loopback) |
| `AGENTBBS_MAIL_IMAP_PLAINTEXT` | `1` (the loopback path is plaintext) |
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
| `AGENTBBS_MAIL_ADMIN_URL` | `http://127.0.0.1:8080` |
| `AGENTBBS_MAIL_API_TOKEN` | the Mailu `API_TOKEN` (secret) |
| `AGENTBBS_MAIL_MASTER_USER` | `gateway` |
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above |
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above (secret) |
| `AGENTBBS_WEBMAIL_URL` | `https://mail.profullstack.com` (default = mail host) |
Without `AGENTBBS_MAIL_API_TOKEN` auto-provisioning is skipped (the address is
still shown); without `AGENTBBS_MAIL_MASTER_PASS` the gateway can't open
mailboxes.
### Why the gateway talks to Dovecot directly (plaintext loopback)
Mailu's **front** (nginx mail proxy) pre-authenticates every IMAP/SMTP login
against Mailu's user DB before proxying to Dovecot — and it rejects the Dovecot
master-user login form `<addr>*gateway`. So the gateway must reach **Dovecot
directly**, bypassing the front. The `imap` container has no TLS cert (only the
front does), so the bypass is plaintext over loopback — safe because the
connection (and the master password) never leave the host. Wiring:
- Publish Dovecot's IMAP on loopback (docker-compose.override.yml):
`imap.ports: ["127.0.0.1:14143:143"]`.
- The Dovecot master user is defined in `data/overrides/dovecot/dovecot.conf`
(Mailu includes exactly that filename — *not* `*.conf`):
```
auth_master_user_separator = *
passdb { driver = passwd-file; master = yes; args = /overrides/master-users }
```
with `data/overrides/dovecot/master-users` holding `gateway:{SHA512-CRYPT}$6$…`
(the hash of `AGENTBBS_MAIL_MASTER_PASS`). The file must be **world-readable
(644)** — Dovecot reads it as a non-root user, and 640 root:root yields a
`temp_fail`. Do **not** add `result_success = continue` (that would also
require the target user's own password); the target mailbox comes from userdb.
- Point the gateway at it: `AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143` +
`AGENTBBS_MAIL_IMAP_PLAINTEXT=1`.
## Sending mail from the BBS (verify codes + notifications)
The join@ verification code and signup notifications use `internal/mail` (the
`AGENTBBS_SMTP_*` knobs), separate from the per-member mailbox client. Point
them at the local Mailu relay so codes actually send:
```
AGENTBBS_SMTP_HOST=127.0.0.1
AGENTBBS_SMTP_PORT=25
AGENTBBS_SMTP_FROM=bbs@bbs.profullstack.com
# user/pass omitted: the co-located relay accepts local submission unauthenticated
```
## Provisioning member mailboxes
A mailbox must exist before the gateway can open it. Provision when a member
becomes paid (or backfill):
Provisioning is automatic at `join@` verification. To create or backfill by hand:
```bash
deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com
deploy/mailu/provision-mailbox.sh alice # creates alice@bbs.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
(Set `MAIL_DOMAIN=bbs.profullstack.com` for the script, since the address domain
differs from the server host.)
The Dovecot **master user** (`gateway`) authenticates as any member with the
login form `alice*gateway` + the master password — 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.
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for details.
## Webmail only for members