@@ -61,6 +61,7 @@ import (
6161 "github.com/profullstack/agentbbs/internal/hub"
6262 "github.com/profullstack/agentbbs/internal/irc"
6363 "github.com/profullstack/agentbbs/internal/mail"
64+ "github.com/profullstack/agentbbs/internal/mailbox"
6465 "github.com/profullstack/agentbbs/internal/news"
6566 "github.com/profullstack/agentbbs/internal/payments"
6667 "github.com/profullstack/agentbbs/internal/plugin"
@@ -317,6 +318,8 @@ func (a *app) router() wish.Middleware {
317318 a .handleIRC (s )
318319 case auth .IsNewsName (user ):
319320 a .handleNews (s )
321+ case auth .IsMailName (user ):
322+ a .handleMail (s )
320323 case isVideo :
321324 a .handleVideo (s , code )
322325 case user == "agent" :
@@ -472,6 +475,28 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
472475 Cmd : sessionExec {run : func () error { return a .runNews (s , su .Name ) }},
473476 })
474477
478+ // Mail — a Founding Lifetime Member perk: the AgentMail TUI.
479+ mailLock := ""
480+ switch {
481+ case guest :
482+ mailLock = membersOnly
483+ case ! su .Premium :
484+ mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a .host
485+ }
486+ apps = append (apps , hub.SessionApp {
487+ Title : "Mail" ,
488+ Description : "your " + a .fe .Domain + " mailbox" ,
489+ Locked : mailLock ,
490+ Cmd : sessionExec {run : func () error {
491+ c , err := a .mailClientFor (su )
492+ if err != nil {
493+ return err
494+ }
495+ defer c .Close ()
496+ return mailbox .RunReader (s , c )
497+ }},
498+ })
499+
475500 // Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
476501 torLock := ""
477502 switch {
@@ -1273,6 +1298,83 @@ func (a *app) runNews(s ssh.Session, name string) error {
12731298 return news .RunReader (s , addr , name )
12741299}
12751300
1301+ // mailClientFor builds a paid-gated AgentMail client for a member, connecting to
1302+ // the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
1303+ // "<name>*<master>") so the BBS gateway can open any member's mailbox with one
1304+ // secret; SMTP defaults to the co-located relay (no auth). Returns an error if
1305+ // the IMAP connection/login fails.
1306+ func (a * app ) mailClientFor (su store.User ) (* mailbox.Client , error ) {
1307+ domain := env ("AGENTBBS_MAIL_DOMAIN" , "mail.profullstack.com" )
1308+ login := su .Name
1309+ if master := os .Getenv ("AGENTBBS_MAIL_MASTER_USER" ); master != "" {
1310+ login = su .Name + "*" + master
1311+ }
1312+ cfg := mailbox.IMAPConfig {
1313+ IMAPAddr : env ("AGENTBBS_MAIL_IMAP_ADDR" , domain + ":993" ),
1314+ SMTPAddr : env ("AGENTBBS_MAIL_SMTP_ADDR" , "127.0.0.1:25" ),
1315+ Username : login ,
1316+ Password : os .Getenv ("AGENTBBS_MAIL_MASTER_PASS" ),
1317+ // SMTPUser/SMTPPass left empty: submit via the trusted local relay.
1318+ }
1319+ tr , err := mailbox .NewIMAPTransport (cfg )
1320+ if err != nil {
1321+ return nil , err
1322+ }
1323+ return mailbox .NewClient (tr , mailbox.Identity {Name : su .Name , Paid : su .Premium }, domain , 50 ), nil
1324+ }
1325+
1326+ // handleMail routes a Founding Lifetime member into AgentMail: an interactive
1327+ // TUI when a PTY is present and no command is given, or the JSON bot mode
1328+ // (ssh mail@host <cmd>, or no PTY) for agents.
1329+ func (a * app ) handleMail (s ssh.Session ) {
1330+ fp := auth .Fingerprint (s .PublicKey ())
1331+ if fp == "" {
1332+ wish .Println (s , "mail@ needs your registered SSH key. New here? ssh join@" + a .host )
1333+ _ = s .Exit (1 )
1334+ return
1335+ }
1336+ u , found , err := a .st .UserByFingerprint (fp )
1337+ if err != nil || ! found {
1338+ wish .Println (s , "key not registered — run: ssh join@" + a .host )
1339+ _ = s .Exit (1 )
1340+ return
1341+ }
1342+ if u .Banned {
1343+ wish .Println (s , "this account is suspended." )
1344+ _ = s .Exit (1 )
1345+ return
1346+ }
1347+ if ! a .ensurePremium (& u ) {
1348+ wish .Println (s , " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@" + a .host )
1349+ _ = s .Exit (1 )
1350+ return
1351+ }
1352+ sessID , _ := a .st .RecordSession (u .ID , s .User (), remoteIP (s ), "mail" )
1353+ defer func () { _ = a .st .EndSession (sessID ) }()
1354+
1355+ c , err := a .mailClientFor (u )
1356+ if err != nil {
1357+ wish .Println (s , "mail: " + err .Error ())
1358+ _ = s .Exit (1 )
1359+ return
1360+ }
1361+ defer c .Close ()
1362+
1363+ args := s .Command ()
1364+ _ , _ , hasPty := s .Pty ()
1365+ if len (args ) > 0 || ! hasPty {
1366+ // Agent/bot mode: JSON in, JSON out.
1367+ if err := mailbox .RunBot (s .Context (), c , args , s , s ); err != nil {
1368+ _ = s .Exit (1 )
1369+ }
1370+ return
1371+ }
1372+ if err := mailbox .RunReader (s , c ); err != nil {
1373+ wish .Println (s , "mail: " + err .Error ())
1374+ _ = s .Exit (1 )
1375+ }
1376+ }
1377+
12761378// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
12771379// member's pod, never on the host. Premium; requires a PTY.
12781380func (a * app ) handleTorCmd (s ssh.Session ) {
0 commit comments