From 8604ef3f78a5d11e2fe29a44ac93a7a748ffa730 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:02:01 +0000 Subject: [PATCH 1/5] 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 @ 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 --- cmd/agentbbs/main.go | 181 ++++++++++++++++++------------ deploy/mailu/mailu.env.example | 20 +++- deploy/mailu/provision-mailbox.sh | 3 +- docs/mail.md | 116 +++++++++++-------- internal/mailbox/client.go | 23 ++-- internal/mailbox/mailbox_test.go | 16 ++- internal/mailbox/types.go | 11 +- internal/mailu/mailu.go | 177 +++++++++++++++++++++++++++++ internal/mailu/mailu_test.go | 101 +++++++++++++++++ setup.sh | 38 ++++--- 10 files changed, 529 insertions(+), 157 deletions(-) create mode 100644 internal/mailu/mailu.go create mode 100644 internal/mailu/mailu_test.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 42f5354..26de9c4 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -59,11 +59,11 @@ import ( "github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/forgejo" - "github.com/profullstack/agentbbs/internal/forwardemail" "github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mailbox" + "github.com/profullstack/agentbbs/internal/mailu" "github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -98,21 +98,24 @@ func envInt(k string, def int) int { } type app struct { - st store.Store - pods *pods.Manager // nil when no container engine on host - sites *sites.Manager - registry []plugin.Plugin - sandbox *sandbox.Runner - mail mail.Config - fe forwardemail.Config // premium @bbs email provisioning - forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning - live *liveReg // in-memory live-session registry (admin console) - gamesReg *games.Registry // AgentGames catalog - mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) - dataDir string - assets string - host string // public hostname used in user-facing messages - newsAddr string // loopback NNTP address the news@ reader dials + st store.Store + pods *pods.Manager // nil when no container engine on host + sites *sites.Manager + registry []plugin.Plugin + sandbox *sandbox.Runner + mail mail.Config + mailu *mailu.Client // member mailbox provisioning (nil when unconfigured) + mailDomain string // email address domain, e.g. bbs.profullstack.com + mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com + webmailURL string // webmail (Roundcube) URL shown to members + forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning + live *liveReg // in-memory live-session registry (admin console) + gamesReg *games.Registry // AgentGames catalog + mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) + dataDir string + assets string + host string // public hostname used in user-facing messages + newsAddr string // loopback NNTP address the news@ reader dials } // Version is the agentbbs stack release, surfaced via `agentbbs version` and @@ -155,22 +158,28 @@ func main() { } host := env("AGENTBBS_HOST", "bbs.profullstack.com") - fe := forwardemail.ConfigFromEnv() - if fe.Domain == "" { - // Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com), - // not the BBS host and not the apex (which is reserved for corporate mail). - fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") + // Member email addresses are @ (e.g. bbs.profullstack.com). + // The mail server (IMAP/SMTP/webmail) lives on a dedicated host + // (mail.profullstack.com); the apex is reserved for corporate mail. + mailHost := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") + mailDomain := env("AGENTBBS_MAIL_ADDR_DOMAIN", host) + mailuClient := mailu.NewFromEnv() + if !mailuClient.Configured() { + mailuClient = nil } a := &app{ - st: st, - sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), - mail: mail.ConfigFromEnv(), - fe: fe, - forgejo: forgejo.ConfigFromEnv(), - live: newLiveReg(), - dataDir: dataDir, - assets: env("AGENTBBS_ASSETS", "./assets"), - host: host, + st: st, + sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), + mail: mail.ConfigFromEnv(), + mailu: mailuClient, + mailDomain: mailDomain, + mailHost: mailHost, + webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost), + forgejo: forgejo.ConfigFromEnv(), + live: newLiveReg(), + dataDir: dataDir, + assets: env("AGENTBBS_ASSETS", "./assets"), + host: host, } a.gamesReg = games.Catalog() a.mm = games.NewMatchmaker(a.gamesReg, a.st, @@ -485,19 +494,22 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }}, }) - // Mail — a Founding Lifetime Member perk: the AgentMail TUI. + // Mail — a free benefit of membership: the AgentMail TUI for your + // @ mailbox. mailLock := "" switch { case guest: mailLock = membersOnly - case !su.Premium: - mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host + case !a.mailEnabled(): + mailLock = "mail is temporarily unavailable on this host" } apps = append(apps, hub.SessionApp{ Title: "Mail", - Description: "your " + a.fe.Domain + " mailbox", + Description: "your " + a.mailAddress(su.Name) + " mailbox", Locked: mailLock, Cmd: sessionExec{run: func() error { + // Make sure the mailbox exists before opening it. + _ = a.ensureMailbox(su) c, err := a.mailClientFor(su) if err != nil { return err @@ -612,7 +624,7 @@ func (a *app) handleJoin(s ssh.Session) { }, "\n")) // 1) email -> emailed code -> enter code. A verified account is a free - // member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub. + // member: it gets a Docker pod, a mailbox, IRC/news, and a /~name homepage. if !u.EmailVerified { if !a.verifyEmailInteractive(s, in, &u) { _ = s.Exit(1) @@ -621,22 +633,29 @@ func (a *app) handleJoin(s ssh.Session) { a.notifySignup(u) } - // Every verified member gets a homepage at https:///~. + // Every verified member gets a homepage at https:///~ and a + // mailbox at @ (best-effort; mail is a bonus, never a gate). seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) + _ = a.ensureMailbox(u) - wish.Println(s, "\n"+strings.Join([]string{ + includes := []string{ " You're in. One login gets you everything — no other servers to ssh into:", "", " ssh " + u.Name + "@" + a.host, "", " Inside, free membership includes:", " • your own Linux pod (a full shell)", + " • email " + a.mailAddress(u.Name) + " (pick “Mail” in the hub)", " • IRC chat + Usenet/news (members-only)", " • the arcade & games", " • your homepage https://" + a.host + "/~" + u.Name, - }, "\n")) + } + if a.webmailURL != "" { + includes = append(includes, " • webmail "+a.webmailURL) + } + wish.Println(s, "\n"+strings.Join(includes, "\n")) - // 2) Founding Lifetime ($99 one-time): personal @host email + custom domains. + // 2) Founding Lifetime ($99 one-time): custom domains + Tor shell. a.offerPremium(s, &u) _ = s.Exit(0) } @@ -796,10 +815,11 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U return false } -// ensurePremium upgrades *u to premium if its CoinPay charge has settled, -// provisioning the member's @host email alias on the transition. It is silent -// (no session output) so it is safe to call from the hub. Returns the current -// premium state. +// ensurePremium upgrades *u to premium if its CoinPay charge has settled. It is +// silent (no session output) so it is safe to call from the hub. Returns the +// current premium state. Email is no longer a premium perk — every verified +// member gets a mailbox (see ensureMailbox) — so this only unlocks custom +// domains and the Tor shell. func (a *app) ensurePremium(u *store.User) bool { if u.Premium { return true @@ -816,33 +836,25 @@ func (a *app) ensurePremium(u *store.User) bool { return false } u.Premium = true - // Create their @host alias forwarding to the email they verified. - if a.fe.Configured() && u.Email != "" { - if err := a.fe.CreateAlias(u.Name, u.Email); err != nil { - log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name)) - } - } return true } -// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail -// URL, the in-hub Mail/Tor entries, and custom domains. +// showPremiumWelcome prints a premium member's perks: custom domains and the +// in-hub Tor shell. (Email is free for all members — see the join@ summary.) func (a *app) showPremiumWelcome(s ssh.Session, u store.User) { lines := []string{ "", - " ★ Founding Lifetime Member — thanks! Your perks:", + " ★ Founding Lifetime Member — thanks! Your bonus perks:", "", - " mailbox " + a.fe.Address(u.Name), - " webmail https://" + a.fe.Domain, - " mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host, " domains ssh domain@" + a.host + " add ", + " tor pick “Tor shell” in the hub: ssh " + u.Name + "@" + a.host, "", } wish.Println(s, strings.Join(lines, "\n")) } -// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and -// custom domains. When CoinPay can mint a charge in-session it shows the exact +// offerPremium pitches the $99 Founding Lifetime membership — custom domains and +// the Tor shell. When CoinPay can mint a charge in-session it shows the exact // amount and deposit address; otherwise it falls back to a pay command. // Non-blocking: the member pays out of band and perks unlock on their next // connect (or re-running join@). @@ -859,9 +871,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) { " ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time", " Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.", "", - " Everything in your free membership stays free — founding adds these", - " bonus features, forever:", - " • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")", + " Everything in your free membership stays free — including your", + " " + a.mailAddress(u.Name) + " mailbox. Founding adds these bonus", + " features, forever:", " • custom domains point yourdomain.com at your homepage", " • Tor a “Tor shell” in your pod — everything over Tor", " • locked-in price founding rate is yours for life — never renew, never pay again", @@ -1301,19 +1313,43 @@ func (a *app) runNews(s ssh.Session, name string) error { return news.RunReader(s, addr, name) } -// mailClientFor builds a paid-gated AgentMail client for a member, connecting to -// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login +// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com. +func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain } + +// mailEnabled reports whether member mailboxes can be provisioned (Mailu admin +// API configured). When false the address is still shown but not created. +func (a *app) mailEnabled() bool { return a.mailu.Configured() } + +// ensureMailbox provisions the member's @ mailbox on Mailu if +// it doesn't already exist. Idempotent and best-effort: it logs and returns the +// error but callers treat mail as a bonus that shouldn't block onboarding. A +// no-op when Mailu isn't configured. +func (a *app) ensureMailbox(u store.User) error { + if !a.mailEnabled() || u.Name == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := a.mailu.EnsureUser(ctx, u.Name, a.mailDomain); err != nil { + log.Error("provision mailbox", "err", err, "address", a.mailAddress(u.Name)) + return err + } + return nil +} + +// mailClientFor builds an AgentMail client for a member, connecting to the +// self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login // "*") so the BBS gateway can open any member's mailbox with one -// secret; SMTP defaults to the co-located relay (no auth). Returns an error if -// the IMAP connection/login fails. +// secret; SMTP defaults to the co-located relay (no auth). The client stamps +// outgoing mail with the member's @ address. Returns an error +// if the IMAP connection/login fails. func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { - domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com") login := su.Name if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" { login = su.Name + "*" + master } cfg := mailbox.IMAPConfig{ - IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"), + IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"), SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), Username: login, Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"), @@ -1323,7 +1359,7 @@ func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { if err != nil { return nil, err } - return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil + return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil } // handleMail routes a Founding Lifetime member into AgentMail: an interactive @@ -1347,11 +1383,18 @@ func (a *app) handleMail(s ssh.Session) { _ = s.Exit(1) return } - if !a.ensurePremium(&u) { - wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host) + if !u.EmailVerified { + wish.Println(s, " verify your email first: ssh -t join@"+a.host) + _ = s.Exit(1) + return + } + if !a.mailEnabled() { + wish.Println(s, " mail is temporarily unavailable on this host.") _ = s.Exit(1) return } + // Mail is a free benefit of membership — make sure the mailbox exists. + _ = a.ensureMailbox(u) sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail") defer func() { _ = a.st.EndSession(sessID) }() diff --git a/deploy/mailu/mailu.env.example b/deploy/mailu/mailu.env.example index 273de92..15f4836 100644 --- a/deploy/mailu/mailu.env.example +++ b/deploy/mailu/mailu.env.example @@ -1,15 +1,24 @@ -# 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). +# Mailu configuration — 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 +# +# NOTE: DOMAIN is the member ADDRESS domain (the @-part); HOSTNAMES is the mail +# SERVER host (TLS/HELO + webmail/admin/API). These deliberately differ: +# members get @bbs.profullstack.com, served from mail.profullstack.com. # --- General ----------------------------------------------------------------- SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16 -DOMAIN=mail.profullstack.com # member addresses are @mail.profullstack.com +DOMAIN=bbs.profullstack.com # member addresses are @bbs.profullstack.com HOSTNAMES=mail.profullstack.com,smtp.profullstack.com POSTMASTER=postmaster # Apex profullstack.com is reserved for corporate mail and is NOT served here. +# Admin REST API: agentbbs auto-provisions member mailboxes through it. Mirror +# this value into the agentbbs service as AGENTBBS_MAIL_API_TOKEN. +API=true +API_TOKEN=CHANGEME_api_token # openssl rand -hex 24 + # 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 @@ -32,13 +41,16 @@ MESSAGE_SIZE_LIMIT=52428800 # 50 MB # A Dovecot master user lets the agentbbs gateway open any member's mailbox with # one secret (login "*"). Created by deploy/mailu/provision-mailbox.sh. # Mirror these into the agentbbs service env: +# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com # 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_ADMIN_URL=http://127.0.0.1:8080 +# AGENTBBS_MAIL_API_TOKEN= # AGENTBBS_MAIL_MASTER_USER=gateway # AGENTBBS_MAIL_MASTER_PASS= # --- Admin bootstrap --------------------------------------------------------- INITIAL_ADMIN_ACCOUNT=admin -INITIAL_ADMIN_DOMAIN=mail.profullstack.com +INITIAL_ADMIN_DOMAIN=bbs.profullstack.com INITIAL_ADMIN_PW=CHANGEME_admin_password diff --git a/deploy/mailu/provision-mailbox.sh b/deploy/mailu/provision-mailbox.sh index 106e3f5..c2ad34d 100755 --- a/deploy/mailu/provision-mailbox.sh +++ b/deploy/mailu/provision-mailbox.sh @@ -14,7 +14,8 @@ set -euo pipefail MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}" -DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}" +# The address domain (the @-part), which may differ from the mail server host. +DOMAIN="${MAIL_ADDR_DOMAIN:-${MAIL_DOMAIN:-bbs.profullstack.com}}" MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}" QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB diff --git a/docs/mail.md b/docs/mail.md index 5920fd6..9275063 100644 --- a/docs/mail.md +++ b/docs/mail.md @@ -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 -`@mail.profullstack.com`, reached two ways: +AgentBBS gives **every verified member** (free and paid alike) a real mailbox at +`@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,111 @@ 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 `@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_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. + +## 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 diff --git a/internal/mailbox/client.go b/internal/mailbox/client.go index aba1928..1f61868 100644 --- a/internal/mailbox/client.go +++ b/internal/mailbox/client.go @@ -7,16 +7,19 @@ import ( "strings" ) -// Identity is the acting member and whether they hold the paid membership. +// Identity is the acting member. AgentMail is a free benefit of membership, so +// having a registered handle is the only requirement; Paid is retained for +// tier-aware features (e.g. quotas) but no longer gates access. type Identity struct { Name string // local-part / handle, e.g. "alice" - Paid bool // Founding Lifetime Member; mail is gated on this + Paid bool // Founding Lifetime Member (informational; does not gate mail) } -// ErrNotPaid is returned to a non-paid member attempting a mail action. -var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com") +// ErrNotMember is returned when a caller without a registered handle attempts a +// mail action. AgentMail is open to every verified member. +var ErrNotMember = errors.New("AgentMail is a member feature — register first: ssh join@bbs.profullstack.com") -// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every +// Client is the ergonomic, member-gated facade the TUI and bot mode use. Every // method returns plain structs, so the same calls serve humans and agents. type Client struct { t Transport @@ -25,8 +28,8 @@ type Client struct { pageSize int } -// NewClient builds a paid-gated client. domain is the mail domain (e.g. -// mail.profullstack.com); pageSize defaults to 50 when <= 0. +// NewClient builds a member-gated client. domain is the email address domain +// (e.g. bbs.profullstack.com); pageSize defaults to 50 when <= 0. func NewClient(t Transport, id Identity, domain string, pageSize int) *Client { if pageSize <= 0 { pageSize = 50 @@ -34,12 +37,12 @@ func NewClient(t Transport, id Identity, domain string, pageSize int) *Client { return &Client{t: t, id: id, domain: domain, pageSize: pageSize} } -// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com. +// Address is the member's own mailbox address, e.g. alice@bbs.profullstack.com. func (c *Client) Address() string { return c.id.Name + "@" + c.domain } func (c *Client) gate() error { - if c.id.Name == "" || !c.id.Paid { - return ErrNotPaid + if c.id.Name == "" { + return ErrNotMember } return nil } diff --git a/internal/mailbox/mailbox_test.go b/internal/mailbox/mailbox_test.go index 09da9f1..986dac7 100644 --- a/internal/mailbox/mailbox_test.go +++ b/internal/mailbox/mailbox_test.go @@ -15,7 +15,7 @@ func seeded() *MemoryTransport { } func paidClient(t Transport) *Client { - return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50) + return NewClient(t, Identity{Name: "alice", Paid: true}, "bbs.profullstack.com", 50) } func TestParseFormatAddress(t *testing.T) { @@ -45,9 +45,15 @@ func TestValidEmailAndDraft(t *testing.T) { } func TestGate(t *testing.T) { - c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0) - if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) { - t.Fatalf("expected ErrNotPaid, got %v", err) + // A free member (Paid: false) now has full mail access. + c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "bbs.profullstack.com", 0) + if _, err := c.Inbox(context.Background(), 0); err != nil { + t.Fatalf("free member should have mail access, got %v", err) + } + // Only a caller without a registered handle is rejected. + anon := NewClient(seeded(), Identity{Name: "", Paid: true}, "bbs.profullstack.com", 0) + if _, err := anon.Inbox(context.Background(), 0); !errors.Is(err, ErrNotMember) { + t.Fatalf("expected ErrNotMember, got %v", err) } } @@ -106,7 +112,7 @@ func TestSendAndReply(t *testing.T) { t.Fatalf("send: %v", err) } sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent}) - if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" { + if len(sent) != 1 || sent[0].From.Address != "alice@bbs.profullstack.com" || sent[0].Subject != "Hi" { t.Fatalf("sent: %+v", sent) } diff --git a/internal/mailbox/types.go b/internal/mailbox/types.go index a952772..33a706b 100644 --- a/internal/mailbox/types.go +++ b/internal/mailbox/types.go @@ -1,8 +1,9 @@ -// Package mailbox is the BBS-side mail client for Founding Lifetime members: a -// transport-agnostic core (read, search, compose, send, flag, delete) with a -// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It -// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at -// mail.profullstack.com / smtp.profullstack.com. +// Package mailbox is the BBS-side mail client for members (a free benefit of +// membership): a transport-agnostic core (read, search, compose, send, flag, +// delete) with a Bubble Tea TUI for humans and a line-oriented JSON mode for +// agents/bots. Addresses are @bbs.profullstack.com; it talks to the +// self-hosted Mailu stack (Dovecot IMAP + Postfix submission) hosted on +// mail.profullstack.com. // // The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are // deliberately the same so tooling can move between them. diff --git a/internal/mailu/mailu.go b/internal/mailu/mailu.go new file mode 100644 index 0000000..dc394e3 --- /dev/null +++ b/internal/mailu/mailu.go @@ -0,0 +1,177 @@ +// Package mailu provisions member mailboxes on the self-hosted Mailu stack via +// its admin REST API. Every verified AgentBBS member gets a real mailbox at +// @ (e.g. alice@bbs.profullstack.com); the agentbbs gateway then +// opens it over IMAP with the Dovecot master user, so members never manage an +// IMAP/SMTP password. Mailbox creation is the one thing that must happen up +// front, which is what EnsureUser does (idempotently). +// +// The Mailu admin API listens on the loopback HTTP front (default +// http://127.0.0.1:8080) and authenticates with the token set as API_TOKEN in +// mailu.env. When no token is configured Configured() reports false and callers +// skip provisioning (the address is still shown). +// +// Config (env): +// +// AGENTBBS_MAIL_ADMIN_URL Mailu admin base URL (default http://127.0.0.1:8080) +// AGENTBBS_MAIL_API_TOKEN Mailu API token (from mailu.env API_TOKEN) +// AGENTBBS_MAIL_QUOTA_BYTES per-mailbox quota in bytes (default 1 GiB) +package mailu + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// DefaultQuotaBytes is the per-mailbox storage quota when unset (1 GiB). +const DefaultQuotaBytes = 1 << 30 + +// Config holds the Mailu admin-API endpoint and credentials. +type Config struct { + BaseURL string + Token string + QuotaBytes int64 + HTTP *http.Client +} + +// ConfigFromEnv reads the Mailu admin settings from the environment. +func ConfigFromEnv() Config { + q, _ := strconv.ParseInt(os.Getenv("AGENTBBS_MAIL_QUOTA_BYTES"), 10, 64) + if q <= 0 { + q = DefaultQuotaBytes + } + base := os.Getenv("AGENTBBS_MAIL_ADMIN_URL") + if base == "" { + base = "http://127.0.0.1:8080" + } + return Config{ + BaseURL: strings.TrimRight(base, "/"), + Token: os.Getenv("AGENTBBS_MAIL_API_TOKEN"), + QuotaBytes: q, + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +// Client talks to the Mailu admin REST API. +type Client struct { + cfg Config +} + +// New builds a client. NewFromEnv is the usual entry point. +func New(cfg Config) *Client { + if cfg.HTTP == nil { + cfg.HTTP = &http.Client{Timeout: 15 * time.Second} + } + if cfg.QuotaBytes <= 0 { + cfg.QuotaBytes = DefaultQuotaBytes + } + return &Client{cfg: cfg} +} + +// NewFromEnv builds a client from the environment. +func NewFromEnv() *Client { return New(ConfigFromEnv()) } + +// Configured reports whether mailboxes can actually be provisioned. +func (c *Client) Configured() bool { + return c != nil && c.cfg.Token != "" && c.cfg.BaseURL != "" +} + +func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+"/api/v1"+path, rdr) + if err != nil { + return nil, err + } + // Mailu authenticates the admin API with the raw token in Authorization. + req.Header.Set("Authorization", c.cfg.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return c.cfg.HTTP.Do(req) +} + +// UserExists reports whether email already has a mailbox. +func (c *Client) UserExists(ctx context.Context, email string) (bool, error) { + resp, err := c.do(ctx, http.MethodGet, "/user/"+email, nil) + if err != nil { + return false, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + switch { + case resp.StatusCode == http.StatusOK: + return true, nil + case resp.StatusCode == http.StatusNotFound: + return false, nil + default: + return false, fmt.Errorf("mailu user lookup %s: %s", email, resp.Status) + } +} + +// EnsureUser creates email@... if it doesn't exist. It is idempotent: an +// existing mailbox (or an "already exists" create response) is success. The +// generated password is unused by members — the gateway master user opens every +// mailbox — but Mailu requires one at creation time. +func (c *Client) EnsureUser(ctx context.Context, localPart, domain string) error { + if !c.Configured() { + return fmt.Errorf("mailu not configured") + } + email := localPart + "@" + domain + exists, err := c.UserExists(ctx, email) + if err != nil { + return err + } + if exists { + return nil + } + pw, err := randomPassword() + if err != nil { + return err + } + payload := map[string]any{ + "email": email, + "raw_password": pw, + "comment": "agentbbs member", + "quota_bytes": c.cfg.QuotaBytes, + "enabled": true, + } + resp, err := c.do(ctx, http.MethodPost, "/user", payload) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + // A concurrent create / pre-existing mailbox is fine. + if resp.StatusCode == http.StatusConflict || + strings.Contains(strings.ToLower(string(b)), "already exists") { + return nil + } + return fmt.Errorf("mailu create user %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b))) +} + +func randomPassword() (string, error) { + var b [24]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/mailu/mailu_test.go b/internal/mailu/mailu_test.go new file mode 100644 index 0000000..55049c5 --- /dev/null +++ b/internal/mailu/mailu_test.go @@ -0,0 +1,101 @@ +package mailu + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestConfigured(t *testing.T) { + if New(Config{BaseURL: "http://x"}).Configured() { + t.Fatal("no token should be unconfigured") + } + if !New(Config{BaseURL: "http://x", Token: "tok"}).Configured() { + t.Fatal("token should be configured") + } + var nilc *Client + if nilc.Configured() { + t.Fatal("nil client must be unconfigured") + } +} + +func TestEnsureUserCreatesWhenMissing(t *testing.T) { + var created map[string]any + var sawToken string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawToken = r.Header.Get("Authorization") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/user/"): + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/user": + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &created) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "secret-tok"}) + if err := c.EnsureUser(context.Background(), "alice", "bbs.profullstack.com"); err != nil { + t.Fatal(err) + } + if sawToken != "secret-tok" { + t.Fatalf("token header = %q", sawToken) + } + if created["email"] != "alice@bbs.profullstack.com" { + t.Fatalf("created email = %v", created["email"]) + } + if created["raw_password"] == nil || created["raw_password"] == "" { + t.Fatal("expected a generated password") + } +} + +func TestEnsureUserIdempotentWhenExists(t *testing.T) { + posted := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + return + } + posted = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "t"}) + if err := c.EnsureUser(context.Background(), "bob", "bbs.profullstack.com"); err != nil { + t.Fatal(err) + } + if posted { + t.Fatal("should not POST when the mailbox already exists") + } +} + +func TestEnsureUserConflictIsSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"message":"already exists"}`) + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "t"}) + if err := c.EnsureUser(context.Background(), "carol", "bbs.profullstack.com"); err != nil { + t.Fatalf("conflict should be treated as success, got %v", err) + } +} + +func TestEnsureUserUnconfigured(t *testing.T) { + if err := New(Config{}).EnsureUser(context.Background(), "x", "y"); err == nil { + t.Fatal("expected error when unconfigured") + } +} diff --git a/setup.sh b/setup.sh index 7ac7327..be895d6 100755 --- a/setup.sh +++ b/setup.sh @@ -368,10 +368,11 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com # Membership model: -# Free verified members get their own Docker pod (ssh pod@) and a homepage -# at https://${DOMAIN}/~. -# Premium \$10 one-time, lifetime — a personal @${DOMAIN} email -# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@. +# Free verified members get their own Docker pod (ssh pod@), a homepage at +# https://${DOMAIN}/~, AND a real mailbox @${DOMAIN} on the +# self-hosted Mailu stack (read it in the hub's "Mail" or via webmail). +# Premium \$10 one-time, lifetime — custom domains (ssh domain@) + a Tor shell. +# Offered at join@. # Premium payments hit the CoinPay REST API directly (no coinpay CLI needed): # join@ creates a charge and shows the amount + deposit address; a later connect @@ -385,11 +386,16 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_PREMIUM_CURRENCY=USD # AGENTBBS_PREMIUM_BLOCKCHAIN=eth -# Premium email aliases (@${DOMAIN}) auto-created on forwardemail.net. -# Without an API key the address is shown but not created (add it manually). -# AGENTBBS_FORWARDEMAIL_API_KEY= -# AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN} -# AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN} +# Member email (free for every verified member). Addresses are @${DOMAIN} +# (the address domain), while the Mailu server lives on the mail host below. +# Mailboxes are auto-provisioned at join@ via the Mailu admin REST API: set the +# API token (API_TOKEN in deploy/mailu/mailu.env). Without it the address is +# shown but not created. See docs/mail.md. +# AGENTBBS_MAIL_ADDR_DOMAIN=${DOMAIN} # the @-part of member addresses +# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080 # Mailu admin (loopback) +# AGENTBBS_MAIL_API_TOKEN= +# AGENTBBS_MAIL_QUOTA_BYTES=1073741824 # 1 GiB per mailbox +# AGENTBBS_WEBMAIL_URL=https://${MAIL_DOMAIN} # Roundcube (defaults to mail host) # AgentGit (git.profullstack.com): every verified member — free and paid alike — # is provisioned a Forgejo account when they confirm their email. The admin token @@ -1059,17 +1065,19 @@ else systemctl disable --now forgejo >/dev/null 2>&1 || true fi -# ---- 9e. Mailu mail stack (co-located mail.${DOMAIN#*.}) -------------------- +# ---- 9e. Mailu mail stack (server on ${MAIL_DOMAIN}) ------------------------ # Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns # the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert -# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of paid members. Full setup, -# DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. +# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of EVERY verified member +# (free + paid) — addresses are @${DOMAIN}, the server is ${MAIL_DOMAIN}. +# Full setup, DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. MAILU_DIR="${SRC_DIR}/deploy/mailu" if [ "$MAIL" = "1" ]; then - log "configuring Mailu mail stack (${MAIL_DOMAIN})" - # Tell agentbbs how to reach the mailbox backend (master user/pass are secrets - # the operator sets; see docs/mail.md). + log "configuring Mailu mail stack (server ${MAIL_DOMAIN}, addresses @${DOMAIN})" + # Tell agentbbs how to reach the mailbox backend (master user/pass + the Mailu + # API token are secrets the operator sets; see docs/mail.md). upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}" + upsert_env AGENTBBS_MAIL_ADDR_DOMAIN "${DOMAIN}" upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993" upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25" From ed0ccba64ae8c5f596aa168f6279772397962cd3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:11:28 +0000 Subject: [PATCH 2/5] chore(mailu): pin Docker network subnet to match SUBNET; ignore runtime state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 6 ++++++ cmd/agentbbs/main.go | 10 ++++++---- deploy/mailu/docker-compose.override.yml.example | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 deploy/mailu/docker-compose.override.yml.example diff --git a/.gitignore b/.gitignore index c4b10e9..ea725d2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,11 @@ *.log .env +# Mailu runtime: secrets, local network override, and state +deploy/mailu/mailu.env +deploy/mailu/docker-compose.override.yml +deploy/mailu/data/ +deploy/mailu/certs/ + # Claude Code local worktrees/state .claude/ diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 26de9c4..e874767 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -73,9 +73,9 @@ import ( "github.com/profullstack/agentbbs/internal/store" "github.com/profullstack/agentbbs/internal/tor" "github.com/profullstack/agentbbs/plugins/about" - "github.com/profullstack/agentbbs/plugins/hello" "github.com/profullstack/agentbbs/plugins/agentgames" "github.com/profullstack/agentbbs/plugins/arcade" + "github.com/profullstack/agentbbs/plugins/hello" "github.com/profullstack/agentbbs/plugins/members" qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite" ) @@ -1339,14 +1339,16 @@ func (a *app) ensureMailbox(u store.User) error { // mailClientFor builds an AgentMail client for a member, connecting to the // self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login -// "*") so the BBS gateway can open any member's mailbox with one +// "*") so the BBS gateway can open any member's mailbox with one // secret; SMTP defaults to the co-located relay (no auth). The client stamps // outgoing mail with the member's @ address. Returns an error // if the IMAP connection/login fails. func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { - login := su.Name + // Mailu keys mailboxes by full address, so the IMAP login (and the master + // login "*") must use the address, not the bare handle. + login := a.mailAddress(su.Name) if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" { - login = su.Name + "*" + master + login = a.mailAddress(su.Name) + "*" + master } cfg := mailbox.IMAPConfig{ IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"), diff --git a/deploy/mailu/docker-compose.override.yml.example b/deploy/mailu/docker-compose.override.yml.example new file mode 100644 index 0000000..5f24ee9 --- /dev/null +++ b/deploy/mailu/docker-compose.override.yml.example @@ -0,0 +1,14 @@ +# docker-compose.override.yml — copy to docker-compose.override.yml (gitignored). +# +# The base docker-compose.yml does not declare a network, so Docker would assign +# the project's default bridge an arbitrary subnet. Mailu trusts SUBNET (from +# mailu.env) as its internal network for service-to-service auth and relaying, so +# the real network subnet MUST equal SUBNET or internal auth/relay breaks. This +# override pins the default network to the same subnet you set as SUBNET in +# mailu.env (default 192.168.203.0/24). Compose loads this file automatically. +networks: + default: + driver: bridge + ipam: + config: + - subnet: 192.168.203.0/24 From 6b7bf011f81ce42ee35f776013d0aebb880465f3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:45:47 +0000 Subject: [PATCH 3/5] feat(mail): plaintext loopback IMAP so the gateway bypasses Mailu's front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mailu's front (nginx mail proxy) pre-authenticates against Mailu's user DB before proxying to Dovecot, which rejects the Dovecot master-user login *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 --- cmd/agentbbs/main.go | 5 +++ .../mailu/docker-compose.override.yml.example | 36 +++++++++++++++---- docs/mail.md | 30 +++++++++++++++- internal/mailbox/imap.go | 11 +++++- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index e874767..252faa2 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -1355,6 +1355,11 @@ func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) { SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"), Username: login, Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"), + // Mailu's front nginx pre-authenticates against its user DB before + // proxying, which rejects the "*master" master login. The gateway + // therefore talks to Dovecot directly over loopback (plaintext, on-host) + // when AGENTBBS_MAIL_IMAP_PLAINTEXT=1. See docs/mail.md. + Plaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1", // SMTPUser/SMTPPass left empty: submit via the trusted local relay. } tr, err := mailbox.NewIMAPTransport(cfg) diff --git a/deploy/mailu/docker-compose.override.yml.example b/deploy/mailu/docker-compose.override.yml.example index 5f24ee9..074fd1d 100644 --- a/deploy/mailu/docker-compose.override.yml.example +++ b/deploy/mailu/docker-compose.override.yml.example @@ -1,14 +1,36 @@ # docker-compose.override.yml — copy to docker-compose.override.yml (gitignored). -# -# The base docker-compose.yml does not declare a network, so Docker would assign -# the project's default bridge an arbitrary subnet. Mailu trusts SUBNET (from -# mailu.env) as its internal network for service-to-service auth and relaying, so -# the real network subnet MUST equal SUBNET or internal auth/relay breaks. This -# override pins the default network to the same subnet you set as SUBNET in -# mailu.env (default 192.168.203.0/24). Compose loads this file automatically. +# Compose loads this file automatically. It carries three fixes the trimmed base +# compose needs; see docs/mail.md for the full rationale. networks: default: driver: bridge ipam: config: + # Mailu trusts SUBNET (mailu.env) as its internal network for + # service-to-service auth/relay; the real network MUST match it. - subnet: 192.168.203.0/24 +services: + # Mailu requires a DNSSEC-validating resolver or admin won't start. + resolver: + image: ghcr.io/mailu/unbound:2024.06 + env_file: mailu.env + restart: always + networks: + default: + ipv4_address: 192.168.203.254 + front: { dns: [192.168.203.254], depends_on: [resolver] } + admin: { dns: [192.168.203.254], depends_on: [resolver] } + imap: + dns: [192.168.203.254] + depends_on: [resolver] + # Publish Dovecot directly on loopback so the agentbbs gateway can use the + # master-user login (the front's nginx auth proxy rejects "*master"). + # Plaintext is fine: the connection never leaves the host. + ports: ["127.0.0.1:14143:143"] + smtp: { dns: [192.168.203.254], depends_on: [resolver] } + antispam: { dns: [192.168.203.254], depends_on: [resolver] } + # In Mailu 2024.06 the webmail image is "webmail" (not "roundcube:2024.06"). + webmail: + image: ghcr.io/mailu/webmail:2024.06 + dns: [192.168.203.254] + depends_on: [resolver] diff --git a/docs/mail.md b/docs/mail.md index 9275063..60cd0b4 100644 --- a/docs/mail.md +++ b/docs/mail.md @@ -90,7 +90,8 @@ Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones): |---|---| | `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) | @@ -102,6 +103,33 @@ 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 `*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 diff --git a/internal/mailbox/imap.go b/internal/mailbox/imap.go index 44ab7f3..36711f0 100644 --- a/internal/mailbox/imap.go +++ b/internal/mailbox/imap.go @@ -24,6 +24,11 @@ type IMAPConfig struct { // SMTPUser/SMTPPass default to Username/Password when empty. SMTPUser string SMTPPass string + // Plaintext dials IMAP without TLS. Used only for a co-located backend over + // loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing + // the front's auth proxy so master-user login works) — the password never + // leaves the host. Never enable it for a remote server. + Plaintext bool } // imapTransport is a Transport backed by a single authenticated IMAP connection @@ -38,7 +43,11 @@ type imapTransport struct { // NewIMAPTransport dials the IMAP server, logs in, and returns a Transport. func NewIMAPTransport(cfg IMAPConfig) (Transport, error) { - c, err := imapclient.DialTLS(cfg.IMAPAddr, nil) + dial := imapclient.DialTLS + if cfg.Plaintext { + dial = imapclient.DialInsecure + } + c, err := dial(cfg.IMAPAddr, nil) if err != nil { return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err) } From 1cc6a9c89ef58c88e41b7a038ce2c1b6ac22cff9 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:47:47 +0000 Subject: [PATCH 4/5] deploy(mailu): wire gateway IMAP to the loopback Dovecot path in setup.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- setup.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/setup.sh b/setup.sh index be895d6..ea5847f 100755 --- a/setup.sh +++ b/setup.sh @@ -396,6 +396,13 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR} # AGENTBBS_MAIL_API_TOKEN= # AGENTBBS_MAIL_QUOTA_BYTES=1073741824 # 1 GiB per mailbox # AGENTBBS_WEBMAIL_URL=https://${MAIL_DOMAIN} # Roundcube (defaults to mail host) +# The in-BBS mail reader opens mailboxes via a Dovecot master user, reaching +# Dovecot directly over loopback (plaintext, on-host) to bypass Mailu's front +# auth proxy. §9e sets these; the master pass is a secret (see docs/mail.md): +# AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143 +# AGENTBBS_MAIL_IMAP_PLAINTEXT=1 +# AGENTBBS_MAIL_MASTER_USER=gateway +# AGENTBBS_MAIL_MASTER_PASS= # AgentGit (git.profullstack.com): every verified member — free and paid alike — # is provisioned a Forgejo account when they confirm their email. The admin token @@ -1078,7 +1085,12 @@ if [ "$MAIL" = "1" ]; then # API token are secrets the operator sets; see docs/mail.md). upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}" upsert_env AGENTBBS_MAIL_ADDR_DOMAIN "${DOMAIN}" - upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993" + # The gateway reads Dovecot DIRECTLY over loopback (docker-compose.override.yml + # publishes it on 127.0.0.1:14143), bypassing Mailu's front nginx auth proxy so + # the master-user login works. Plaintext is safe — it never leaves the host. + # See docs/mail.md ("Why the gateway talks to Dovecot directly"). + upsert_env AGENTBBS_MAIL_IMAP_ADDR "127.0.0.1:14143" + upsert_env AGENTBBS_MAIL_IMAP_PLAINTEXT "1" upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25" # Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC). From 7a6e466977d1792046cedcfde68db13fb3f3cef2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:57:46 +0000 Subject: [PATCH 5/5] 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/ raw_password) + test. - main.go: setWebmailPassword + readablePassword; join@ displays url/login/password. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 50 +++++++++++++++++++++++++++++++++++- internal/mailu/mailu.go | 20 +++++++++++++++ internal/mailu/mailu_test.go | 23 +++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 252faa2..5da1bf0 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -31,6 +31,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "encoding/hex" "errors" "fmt" "io" @@ -637,6 +638,10 @@ func (a *app) handleJoin(s ssh.Session) { // mailbox at @ (best-effort; mail is a bonus, never a gate). seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host) _ = a.ensureMailbox(u) + // Give them a webmail password so free members can log into webmail. The + // in-BBS reader uses the gateway master user and needs no password, but + // Roundcube does. (Re)set on each join@; they can change it in webmail. + webmailPW := a.setWebmailPassword(u) includes := []string{ " You're in. One login gets you everything — no other servers to ssh into:", @@ -650,7 +655,15 @@ func (a *app) handleJoin(s ssh.Session) { " • the arcade & games", " • your homepage https://" + a.host + "/~" + u.Name, } - if a.webmailURL != "" { + if a.webmailURL != "" && webmailPW != "" { + includes = append(includes, + "", + " Webmail (read your mail in a browser):", + " • url "+a.webmailURL, + " • login "+a.mailAddress(u.Name), + " • password "+webmailPW+" (change it in webmail Settings)", + ) + } else if a.webmailURL != "" { includes = append(includes, " • webmail "+a.webmailURL) } wish.Println(s, "\n"+strings.Join(includes, "\n")) @@ -1337,6 +1350,41 @@ func (a *app) ensureMailbox(u store.User) error { return nil } +// setWebmailPassword sets (and returns) a fresh webmail password for the member +// so free members can log into webmail. Best-effort: returns "" when Mailu isn't +// configured or the API call fails. The in-BBS reader doesn't use this (it goes +// through the gateway master user); only webmail needs a member password. +func (a *app) setWebmailPassword(u store.User) string { + if !a.mailEnabled() || u.Name == "" { + return "" + } + pw := readablePassword() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw); err != nil { + log.Error("set webmail password", "err", err, "address", a.mailAddress(u.Name)) + return "" + } + return pw +} + +// readablePassword returns a 16-char password from an unambiguous alphabet (no +// 0/O/1/l/I) — easy to read off a terminal once and type into webmail. +func readablePassword() string { + const alphabet = "abcdefghijkmnpqrstuvwxyzACDEFGHJKLMNPQRSTUVWXYZ23456789" + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // Fall back to a hex token; correctness over readability. + var f [12]byte + _, _ = rand.Read(f[:]) + return hex.EncodeToString(f[:]) + } + for i := range b { + b[i] = alphabet[int(b[i])%len(alphabet)] + } + return string(b[:]) +} + // mailClientFor builds an AgentMail client for a member, connecting to the // self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login // "*") so the BBS gateway can open any member's mailbox with one diff --git a/internal/mailu/mailu.go b/internal/mailu/mailu.go index dc394e3..32a066d 100644 --- a/internal/mailu/mailu.go +++ b/internal/mailu/mailu.go @@ -168,6 +168,26 @@ func (c *Client) EnsureUser(ctx context.Context, localPart, domain string) error return fmt.Errorf("mailu create user %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b))) } +// SetPassword sets the mailbox password (so the member can log into webmail). +// The gateway opens mailboxes via the Dovecot master user and never needs this, +// but webmail (Roundcube) requires the member to have a known password. +func (c *Client) SetPassword(ctx context.Context, localPart, domain, password string) error { + if !c.Configured() { + return fmt.Errorf("mailu not configured") + } + email := localPart + "@" + domain + resp, err := c.do(ctx, http.MethodPatch, "/user/"+email, map[string]any{"raw_password": password}) + if err != nil { + return err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + return fmt.Errorf("mailu set password %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b))) +} + func randomPassword() (string, error) { var b [24]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/internal/mailu/mailu_test.go b/internal/mailu/mailu_test.go index 55049c5..912119e 100644 --- a/internal/mailu/mailu_test.go +++ b/internal/mailu/mailu_test.go @@ -99,3 +99,26 @@ func TestEnsureUserUnconfigured(t *testing.T) { t.Fatal("expected error when unconfigured") } } + +func TestSetPassword(t *testing.T) { + var method, path string + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{BaseURL: srv.URL, Token: "t"}) + if err := c.SetPassword(context.Background(), "alice", "bbs.profullstack.com", "hunter2"); err != nil { + t.Fatal(err) + } + if method != http.MethodPatch || path != "/api/v1/user/alice@bbs.profullstack.com" { + t.Fatalf("got %s %s", method, path) + } + if body["raw_password"] != "hunter2" { + t.Fatalf("raw_password = %v", body["raw_password"]) + } +}