@@ -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
9999type 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.
792812func (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.)
819833func (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.
12791316func (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
0 commit comments