Skip to content

Commit 32ba55e

Browse files
ralyodioclaude
andcommitted
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>
1 parent 5a17dc4 commit 32ba55e

10 files changed

Lines changed: 529 additions & 157 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 112 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ import (
5959
"github.com/profullstack/agentbbs/internal/calls"
6060
"github.com/profullstack/agentbbs/internal/chat"
6161
"github.com/profullstack/agentbbs/internal/forgejo"
62-
"github.com/profullstack/agentbbs/internal/forwardemail"
6362
"github.com/profullstack/agentbbs/internal/games"
6463
"github.com/profullstack/agentbbs/internal/hub"
6564
"github.com/profullstack/agentbbs/internal/mail"
6665
"github.com/profullstack/agentbbs/internal/mailbox"
66+
"github.com/profullstack/agentbbs/internal/mailu"
6767
"github.com/profullstack/agentbbs/internal/news"
6868
"github.com/profullstack/agentbbs/internal/payments"
6969
"github.com/profullstack/agentbbs/internal/plugin"
@@ -97,21 +97,24 @@ func envInt(k string, def int) int {
9797
}
9898

9999
type app struct {
100-
st store.Store
101-
pods *pods.Manager // nil when no container engine on host
102-
sites *sites.Manager
103-
registry []plugin.Plugin
104-
sandbox *sandbox.Runner
105-
mail mail.Config
106-
fe forwardemail.Config // premium @bbs email provisioning
107-
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
108-
live *liveReg // in-memory live-session registry (admin console)
109-
gamesReg *games.Registry // AgentGames catalog
110-
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
111-
dataDir string
112-
assets string
113-
host string // public hostname used in user-facing messages
114-
newsAddr string // loopback NNTP address the news@ reader dials
100+
st store.Store
101+
pods *pods.Manager // nil when no container engine on host
102+
sites *sites.Manager
103+
registry []plugin.Plugin
104+
sandbox *sandbox.Runner
105+
mail mail.Config
106+
mailu *mailu.Client // member mailbox provisioning (nil when unconfigured)
107+
mailDomain string // email address domain, e.g. bbs.profullstack.com
108+
mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com
109+
webmailURL string // webmail (Roundcube) URL shown to members
110+
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
111+
live *liveReg // in-memory live-session registry (admin console)
112+
gamesReg *games.Registry // AgentGames catalog
113+
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
114+
dataDir string
115+
assets string
116+
host string // public hostname used in user-facing messages
117+
newsAddr string // loopback NNTP address the news@ reader dials
115118
}
116119

117120
// Version is the agentbbs stack release, surfaced via `agentbbs version` and
@@ -154,22 +157,28 @@ func main() {
154157
}
155158

156159
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
157-
fe := forwardemail.ConfigFromEnv()
158-
if fe.Domain == "" {
159-
// Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com),
160-
// not the BBS host and not the apex (which is reserved for corporate mail).
161-
fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
160+
// Member email addresses are <name>@<addr-domain> (e.g. bbs.profullstack.com).
161+
// The mail server (IMAP/SMTP/webmail) lives on a dedicated host
162+
// (mail.profullstack.com); the apex is reserved for corporate mail.
163+
mailHost := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
164+
mailDomain := env("AGENTBBS_MAIL_ADDR_DOMAIN", host)
165+
mailuClient := mailu.NewFromEnv()
166+
if !mailuClient.Configured() {
167+
mailuClient = nil
162168
}
163169
a := &app{
164-
st: st,
165-
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
166-
mail: mail.ConfigFromEnv(),
167-
fe: fe,
168-
forgejo: forgejo.ConfigFromEnv(),
169-
live: newLiveReg(),
170-
dataDir: dataDir,
171-
assets: env("AGENTBBS_ASSETS", "./assets"),
172-
host: host,
170+
st: st,
171+
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
172+
mail: mail.ConfigFromEnv(),
173+
mailu: mailuClient,
174+
mailDomain: mailDomain,
175+
mailHost: mailHost,
176+
webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
177+
forgejo: forgejo.ConfigFromEnv(),
178+
live: newLiveReg(),
179+
dataDir: dataDir,
180+
assets: env("AGENTBBS_ASSETS", "./assets"),
181+
host: host,
173182
}
174183
a.gamesReg = games.Catalog()
175184
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
@@ -474,19 +483,22 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
474483
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
475484
})
476485

477-
// Mail — a Founding Lifetime Member perk: the AgentMail TUI.
486+
// Mail — a free benefit of membership: the AgentMail TUI for your
487+
// <name>@<mailDomain> mailbox.
478488
mailLock := ""
479489
switch {
480490
case guest:
481491
mailLock = membersOnly
482-
case !su.Premium:
483-
mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
492+
case !a.mailEnabled():
493+
mailLock = "mail is temporarily unavailable on this host"
484494
}
485495
apps = append(apps, hub.SessionApp{
486496
Title: "Mail",
487-
Description: "your " + a.fe.Domain + " mailbox",
497+
Description: "your " + a.mailAddress(su.Name) + " mailbox",
488498
Locked: mailLock,
489499
Cmd: sessionExec{run: func() error {
500+
// Make sure the mailbox exists before opening it.
501+
_ = a.ensureMailbox(su)
490502
c, err := a.mailClientFor(su)
491503
if err != nil {
492504
return err
@@ -601,7 +613,7 @@ func (a *app) handleJoin(s ssh.Session) {
601613
}, "\n"))
602614

603615
// 1) email -> emailed code -> enter code. A verified account is a free
604-
// member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub.
616+
// member: it gets a Docker pod, a mailbox, IRC/news, and a /~name homepage.
605617
if !u.EmailVerified {
606618
if !a.verifyEmailInteractive(s, in, &u) {
607619
_ = s.Exit(1)
@@ -610,22 +622,29 @@ func (a *app) handleJoin(s ssh.Session) {
610622
a.notifySignup(u)
611623
}
612624

613-
// Every verified member gets a homepage at https://<host>/~<name>.
625+
// Every verified member gets a homepage at https://<host>/~<name> and a
626+
// mailbox at <name>@<mailDomain> (best-effort; mail is a bonus, never a gate).
614627
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)
628+
_ = a.ensureMailbox(u)
615629

616-
wish.Println(s, "\n"+strings.Join([]string{
630+
includes := []string{
617631
" You're in. One login gets you everything — no other servers to ssh into:",
618632
"",
619633
" ssh " + u.Name + "@" + a.host,
620634
"",
621635
" Inside, free membership includes:",
622636
" • your own Linux pod (a full shell)",
637+
" • email " + a.mailAddress(u.Name) + " (pick “Mail” in the hub)",
623638
" • IRC chat + Usenet/news (members-only)",
624639
" • the arcade & games",
625640
" • your homepage https://" + a.host + "/~" + u.Name,
626-
}, "\n"))
641+
}
642+
if a.webmailURL != "" {
643+
includes = append(includes, " • webmail "+a.webmailURL)
644+
}
645+
wish.Println(s, "\n"+strings.Join(includes, "\n"))
627646

628-
// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
647+
// 2) Founding Lifetime ($99 one-time): custom domains + Tor shell.
629648
a.offerPremium(s, &u)
630649
_ = s.Exit(0)
631650
}
@@ -785,10 +804,11 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
785804
return false
786805
}
787806

788-
// ensurePremium upgrades *u to premium if its CoinPay charge has settled,
789-
// provisioning the member's @host email alias on the transition. It is silent
790-
// (no session output) so it is safe to call from the hub. Returns the current
791-
// premium state.
807+
// ensurePremium upgrades *u to premium if its CoinPay charge has settled. It is
808+
// silent (no session output) so it is safe to call from the hub. Returns the
809+
// current premium state. Email is no longer a premium perk — every verified
810+
// member gets a mailbox (see ensureMailbox) — so this only unlocks custom
811+
// domains and the Tor shell.
792812
func (a *app) ensurePremium(u *store.User) bool {
793813
if u.Premium {
794814
return true
@@ -805,33 +825,25 @@ func (a *app) ensurePremium(u *store.User) bool {
805825
return false
806826
}
807827
u.Premium = true
808-
// Create their <name>@host alias forwarding to the email they verified.
809-
if a.fe.Configured() && u.Email != "" {
810-
if err := a.fe.CreateAlias(u.Name, u.Email); err != nil {
811-
log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name))
812-
}
813-
}
814828
return true
815829
}
816830

817-
// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
818-
// URL, the in-hub Mail/Tor entries, and custom domains.
831+
// showPremiumWelcome prints a premium member's perks: custom domains and the
832+
// in-hub Tor shell. (Email is free for all members — see the join@ summary.)
819833
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
820834
lines := []string{
821835
"",
822-
" ★ Founding Lifetime Member — thanks! Your perks:",
836+
" ★ Founding Lifetime Member — thanks! Your bonus perks:",
823837
"",
824-
" mailbox " + a.fe.Address(u.Name),
825-
" webmail https://" + a.fe.Domain,
826-
" mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
827838
" domains ssh domain@" + a.host + " add <yourdomain.com>",
839+
" tor pick “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
828840
"",
829841
}
830842
wish.Println(s, strings.Join(lines, "\n"))
831843
}
832844

833-
// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and
834-
// custom domains. When CoinPay can mint a charge in-session it shows the exact
845+
// offerPremium pitches the $99 Founding Lifetime membership — custom domains and
846+
// the Tor shell. When CoinPay can mint a charge in-session it shows the exact
835847
// amount and deposit address; otherwise it falls back to a pay command.
836848
// Non-blocking: the member pays out of band and perks unlock on their next
837849
// connect (or re-running join@).
@@ -848,9 +860,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
848860
" ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time",
849861
" Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.",
850862
"",
851-
" Everything in your free membership stays free — founding adds these",
852-
" bonus features, forever:",
853-
" • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")",
863+
" Everything in your free membership stays free — including your",
864+
" " + a.mailAddress(u.Name) + " mailbox. Founding adds these bonus",
865+
" features, forever:",
854866
" • custom domains point yourdomain.com at your homepage",
855867
" • Tor a “Tor shell” in your pod — everything over Tor",
856868
" • locked-in price founding rate is yours for life — never renew, never pay again",
@@ -1271,19 +1283,43 @@ func (a *app) runNews(s ssh.Session, name string) error {
12711283
return news.RunReader(s, addr, name)
12721284
}
12731285

1274-
// mailClientFor builds a paid-gated AgentMail client for a member, connecting to
1275-
// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
1286+
// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com.
1287+
func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain }
1288+
1289+
// mailEnabled reports whether member mailboxes can be provisioned (Mailu admin
1290+
// API configured). When false the address is still shown but not created.
1291+
func (a *app) mailEnabled() bool { return a.mailu.Configured() }
1292+
1293+
// ensureMailbox provisions the member's <name>@<mailDomain> mailbox on Mailu if
1294+
// it doesn't already exist. Idempotent and best-effort: it logs and returns the
1295+
// error but callers treat mail as a bonus that shouldn't block onboarding. A
1296+
// no-op when Mailu isn't configured.
1297+
func (a *app) ensureMailbox(u store.User) error {
1298+
if !a.mailEnabled() || u.Name == "" {
1299+
return nil
1300+
}
1301+
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
1302+
defer cancel()
1303+
if err := a.mailu.EnsureUser(ctx, u.Name, a.mailDomain); err != nil {
1304+
log.Error("provision mailbox", "err", err, "address", a.mailAddress(u.Name))
1305+
return err
1306+
}
1307+
return nil
1308+
}
1309+
1310+
// mailClientFor builds an AgentMail client for a member, connecting to the
1311+
// self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
12761312
// "<name>*<master>") so the BBS gateway can open any member's mailbox with one
1277-
// secret; SMTP defaults to the co-located relay (no auth). Returns an error if
1278-
// the IMAP connection/login fails.
1313+
// secret; SMTP defaults to the co-located relay (no auth). The client stamps
1314+
// outgoing mail with the member's <name>@<mailDomain> address. Returns an error
1315+
// if the IMAP connection/login fails.
12791316
func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
1280-
domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
12811317
login := su.Name
12821318
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
12831319
login = su.Name + "*" + master
12841320
}
12851321
cfg := mailbox.IMAPConfig{
1286-
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"),
1322+
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"),
12871323
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
12881324
Username: login,
12891325
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
@@ -1293,7 +1329,7 @@ func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
12931329
if err != nil {
12941330
return nil, err
12951331
}
1296-
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil
1332+
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil
12971333
}
12981334

12991335
// handleMail routes a Founding Lifetime member into AgentMail: an interactive
@@ -1317,11 +1353,18 @@ func (a *app) handleMail(s ssh.Session) {
13171353
_ = s.Exit(1)
13181354
return
13191355
}
1320-
if !a.ensurePremium(&u) {
1321-
wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host)
1356+
if !u.EmailVerified {
1357+
wish.Println(s, " verify your email first: ssh -t join@"+a.host)
1358+
_ = s.Exit(1)
1359+
return
1360+
}
1361+
if !a.mailEnabled() {
1362+
wish.Println(s, " mail is temporarily unavailable on this host.")
13221363
_ = s.Exit(1)
13231364
return
13241365
}
1366+
// Mail is a free benefit of membership — make sure the mailbox exists.
1367+
_ = a.ensureMailbox(u)
13251368
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
13261369
defer func() { _ = a.st.EndSession(sessID) }()
13271370

deploy/mailu/mailu.env.example

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env
2-
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway).
1+
# Mailu configuration — copy to deploy/mailu/mailu.env and fill the secrets.
2+
# See docs/mail.md for the full setup (DNS, certs, gateway).
33
#
44
# Generate secrets with: openssl rand -hex 16
5+
#
6+
# NOTE: DOMAIN is the member ADDRESS domain (the @-part); HOSTNAMES is the mail
7+
# SERVER host (TLS/HELO + webmail/admin/API). These deliberately differ:
8+
# members get <name>@bbs.profullstack.com, served from mail.profullstack.com.
59

610
# --- General -----------------------------------------------------------------
711
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
8-
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com
12+
DOMAIN=bbs.profullstack.com # member addresses are <name>@bbs.profullstack.com
913
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
1014
POSTMASTER=postmaster
1115
# Apex profullstack.com is reserved for corporate mail and is NOT served here.
1216

17+
# Admin REST API: agentbbs auto-provisions member mailboxes through it. Mirror
18+
# this value into the agentbbs service as AGENTBBS_MAIL_API_TOKEN.
19+
API=true
20+
API_TOKEN=CHANGEME_api_token # openssl rand -hex 24
21+
1322
# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed
1423
# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
1524
TLS_FLAVOR=mail
@@ -32,13 +41,16 @@ MESSAGE_SIZE_LIMIT=52428800 # 50 MB
3241
# A Dovecot master user lets the agentbbs gateway open any member's mailbox with
3342
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
3443
# Mirror these into the agentbbs service env:
44+
# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com
3545
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
3646
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
3747
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
48+
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080
49+
# AGENTBBS_MAIL_API_TOKEN=<the API_TOKEN above>
3850
# AGENTBBS_MAIL_MASTER_USER=gateway
3951
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set>
4052

4153
# --- Admin bootstrap ---------------------------------------------------------
4254
INITIAL_ADMIN_ACCOUNT=admin
43-
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
55+
INITIAL_ADMIN_DOMAIN=bbs.profullstack.com
4456
INITIAL_ADMIN_PW=CHANGEME_admin_password

deploy/mailu/provision-mailbox.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
set -euo pipefail
1515

1616
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
17-
DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}"
17+
# The address domain (the @-part), which may differ from the mail server host.
18+
DOMAIN="${MAIL_ADDR_DOMAIN:-${MAIL_DOMAIN:-bbs.profullstack.com}}"
1819
MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
1920
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB
2021

0 commit comments

Comments
 (0)