Skip to content

Commit 45c46db

Browse files
authored
Merge pull request #16 from profullstack/feat/bbs-mail-reader
AgentMail: paid-member mailbox reader (TUI + bot mode) on the BBS
2 parents b304922 + e165ece commit 45c46db

13 files changed

Lines changed: 1660 additions & 0 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
12781380
func (a *app) handleTorCmd(s ssh.Session) {

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ require (
1010
github.com/charmbracelet/wish v1.4.7
1111
github.com/creack/pty v1.1.24
1212
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1
13+
github.com/emersion/go-imap/v2 v2.0.0-beta.8
14+
github.com/emersion/go-message v0.18.2
1315
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
1416
github.com/livekit/protocol v1.46.0
1517
github.com/livekit/server-sdk-go/v2 v2.16.6
@@ -46,6 +48,7 @@ require (
4648
github.com/dennwc/iters v1.2.2 // indirect
4749
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
4850
github.com/dustin/go-humanize v1.0.1 // indirect
51+
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
4952
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
5053
github.com/frostbyte73/core v0.1.1 // indirect
5154
github.com/fsnotify/fsnotify v1.9.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
9999
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s=
100100
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y=
101101
github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0=
102+
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
103+
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
104+
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
105+
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
106+
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
107+
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
102108
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
103109
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
104110
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=

internal/auth/auth.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ var IRCNames = map[string]bool{"irc": true}
6565
// via an in-process newsreader. Free for any registered member, like irc@.
6666
var NewsNames = map[string]bool{"news": true}
6767

68+
// MailNames route a Founding Lifetime (paid) member into the AgentMail client —
69+
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
70+
var MailNames = map[string]bool{"mail": true}
71+
6872
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
6973
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
7074
var GameNames = map[string]bool{"game": true, "games": true}
@@ -99,6 +103,9 @@ func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
99103
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
100104
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
101105

106+
// IsMailName reports whether the SSH username requests the AgentMail client.
107+
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
108+
102109
// systemReserved are names that don't drive an SSH route but would still
103110
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
104111
// infra hostnames — so members may not claim them as account names.

internal/mailbox/bot.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package mailbox
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// BotUsage is printed when a bot/agent invokes mail with no/unknown command.
13+
const BotUsage = `agentmail (non-interactive mode) — JSON in, JSON out:
14+
15+
mailboxes list folders
16+
list [mailbox] [limit] message summaries (default INBOX)
17+
read <mailbox> <uid> full message (marks seen; "peek" 4th arg to keep unseen)
18+
search <query> [mailbox] search summaries
19+
send read a JSON Draft on stdin, send it
20+
reply <mailbox> <uid> read {"text":...,"replyAll":bool} on stdin
21+
flag <mailbox> <uid> [on|off] set/clear the flagged flag
22+
seen <mailbox> <uid> [on|off] set/clear the seen flag
23+
delete <mailbox> <uid> delete a message
24+
25+
Example: ssh mail@bbs.profullstack.com list INBOX 20`
26+
27+
// RunBot executes one non-interactive command for agents/bots, writing JSON to
28+
// out. It returns an error (also emitted as {"error":...}) on failure.
29+
func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.Writer) error {
30+
if len(args) == 0 {
31+
fmt.Fprintln(out, BotUsage)
32+
return nil
33+
}
34+
enc := json.NewEncoder(out)
35+
enc.SetIndent("", " ")
36+
fail := func(err error) error {
37+
_ = enc.Encode(map[string]string{"error": err.Error()})
38+
return err
39+
}
40+
41+
switch strings.ToLower(args[0]) {
42+
case "mailboxes":
43+
v, err := c.Mailboxes(ctx)
44+
if err != nil {
45+
return fail(err)
46+
}
47+
return enc.Encode(v)
48+
49+
case "list", "ls":
50+
mailbox := Inbox
51+
if len(args) > 1 {
52+
mailbox = args[1]
53+
}
54+
limit := 0
55+
if len(args) > 2 {
56+
limit, _ = strconv.Atoi(args[2])
57+
}
58+
v, err := c.List(ctx, mailbox, limit)
59+
if err != nil {
60+
return fail(err)
61+
}
62+
return enc.Encode(v)
63+
64+
case "read", "show":
65+
mailbox, uid, err := mailboxUID(args)
66+
if err != nil {
67+
return fail(err)
68+
}
69+
peek := len(args) > 3 && strings.EqualFold(args[3], "peek")
70+
msg, ok, err := c.Read(ctx, mailbox, uid, peek)
71+
if err != nil {
72+
return fail(err)
73+
}
74+
if !ok {
75+
return fail(notFound(mailbox, uid))
76+
}
77+
return enc.Encode(msg)
78+
79+
case "search":
80+
if len(args) < 2 {
81+
return fail(fmt.Errorf("usage: search <query> [mailbox]"))
82+
}
83+
mailbox := ""
84+
if len(args) > 2 {
85+
mailbox = args[2]
86+
}
87+
v, err := c.Search(ctx, args[1], mailbox, 0)
88+
if err != nil {
89+
return fail(err)
90+
}
91+
return enc.Encode(v)
92+
93+
case "send":
94+
var d Draft
95+
if err := json.NewDecoder(in).Decode(&d); err != nil {
96+
return fail(fmt.Errorf("send expects a JSON Draft on stdin: %w", err))
97+
}
98+
res, err := c.Send(ctx, d)
99+
if err != nil {
100+
return fail(err)
101+
}
102+
return enc.Encode(res)
103+
104+
case "reply":
105+
mailbox, uid, err := mailboxUID(args)
106+
if err != nil {
107+
return fail(err)
108+
}
109+
var body struct {
110+
Text string `json:"text"`
111+
ReplyAll bool `json:"replyAll"`
112+
}
113+
if err := json.NewDecoder(in).Decode(&body); err != nil {
114+
return fail(fmt.Errorf("reply expects {\"text\":...} on stdin: %w", err))
115+
}
116+
orig, ok, err := c.Read(ctx, mailbox, uid, true)
117+
if err != nil {
118+
return fail(err)
119+
}
120+
if !ok {
121+
return fail(notFound(mailbox, uid))
122+
}
123+
res, err := c.Reply(ctx, orig, body.Text, body.ReplyAll)
124+
if err != nil {
125+
return fail(err)
126+
}
127+
return enc.Encode(res)
128+
129+
case "flag", "seen":
130+
mailbox, uid, err := mailboxUID(args)
131+
if err != nil {
132+
return fail(err)
133+
}
134+
on := true
135+
if len(args) > 3 {
136+
on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0"
137+
}
138+
if args[0] == "flag" {
139+
err = c.Flag(ctx, mailbox, uid, on)
140+
} else {
141+
err = c.MarkSeen(ctx, mailbox, uid, on)
142+
}
143+
if err != nil {
144+
return fail(err)
145+
}
146+
return enc.Encode(map[string]any{"ok": true, "mailbox": mailbox, "uid": uid, args[0]: on})
147+
148+
case "delete", "rm":
149+
mailbox, uid, err := mailboxUID(args)
150+
if err != nil {
151+
return fail(err)
152+
}
153+
if err := c.Delete(ctx, mailbox, uid); err != nil {
154+
return fail(err)
155+
}
156+
return enc.Encode(map[string]any{"ok": true, "deleted": map[string]any{"mailbox": mailbox, "uid": uid}})
157+
158+
default:
159+
fmt.Fprintln(out, BotUsage)
160+
return fmt.Errorf("unknown command: %s", args[0])
161+
}
162+
}
163+
164+
func mailboxUID(args []string) (string, uint32, error) {
165+
if len(args) < 3 {
166+
return "", 0, fmt.Errorf("usage: %s <mailbox> <uid>", args[0])
167+
}
168+
uid, err := strconv.ParseUint(args[2], 10, 32)
169+
if err != nil {
170+
return "", 0, fmt.Errorf("invalid uid %q", args[2])
171+
}
172+
return args[1], uint32(uid), nil
173+
}

0 commit comments

Comments
 (0)