mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
Add AgentMail: paid-member mailbox reader (TUI + bot mode) on the BBS
internal/mailbox is a transport-agnostic mail client for Founding Lifetime members: read, search, compose, send, reply, flag, delete. - types/transport/client: paid-gated facade returning JSON-serializable structs (same shapes as @logicsrc/plugin-agentmail) - memory.go: in-memory transport (tests/dev/reference) + tests - imap.go/smtp.go: real backend — go-imap/v2 (Dovecot IMAP, master-user login) + net/smtp submission via the co-located relay; go-message parses bodies/attachments - reader_tui.go: Bubble Tea reader for humans (list/read/flag/delete) - bot.go: JSON in/out mode for agents (ssh mail@host list|read|send|…) - wired as the paid "Mail" hub entry and the ssh mail@ route + auth.MailNames Connects to the self-hosted Mailu stack at mail.profullstack.com / smtp.profullstack.com. Rebased onto main; build + vet + tests clean (Go 1.26). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6b4db2b934
commit
e165ecee26
13 changed files with 1660 additions and 0 deletions
|
|
@ -61,6 +61,7 @@ import (
|
||||||
"github.com/profullstack/agentbbs/internal/hub"
|
"github.com/profullstack/agentbbs/internal/hub"
|
||||||
"github.com/profullstack/agentbbs/internal/irc"
|
"github.com/profullstack/agentbbs/internal/irc"
|
||||||
"github.com/profullstack/agentbbs/internal/mail"
|
"github.com/profullstack/agentbbs/internal/mail"
|
||||||
|
"github.com/profullstack/agentbbs/internal/mailbox"
|
||||||
"github.com/profullstack/agentbbs/internal/news"
|
"github.com/profullstack/agentbbs/internal/news"
|
||||||
"github.com/profullstack/agentbbs/internal/payments"
|
"github.com/profullstack/agentbbs/internal/payments"
|
||||||
"github.com/profullstack/agentbbs/internal/plugin"
|
"github.com/profullstack/agentbbs/internal/plugin"
|
||||||
|
|
@ -317,6 +318,8 @@ func (a *app) router() wish.Middleware {
|
||||||
a.handleIRC(s)
|
a.handleIRC(s)
|
||||||
case auth.IsNewsName(user):
|
case auth.IsNewsName(user):
|
||||||
a.handleNews(s)
|
a.handleNews(s)
|
||||||
|
case auth.IsMailName(user):
|
||||||
|
a.handleMail(s)
|
||||||
case isVideo:
|
case isVideo:
|
||||||
a.handleVideo(s, code)
|
a.handleVideo(s, code)
|
||||||
case user == "agent":
|
case user == "agent":
|
||||||
|
|
@ -472,6 +475,28 @@ 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) }},
|
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mail — a Founding Lifetime Member perk: the AgentMail TUI.
|
||||||
|
mailLock := ""
|
||||||
|
switch {
|
||||||
|
case guest:
|
||||||
|
mailLock = membersOnly
|
||||||
|
case !su.Premium:
|
||||||
|
mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
|
||||||
|
}
|
||||||
|
apps = append(apps, hub.SessionApp{
|
||||||
|
Title: "Mail",
|
||||||
|
Description: "your " + a.fe.Domain + " mailbox",
|
||||||
|
Locked: mailLock,
|
||||||
|
Cmd: sessionExec{run: func() error {
|
||||||
|
c, err := a.mailClientFor(su)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
return mailbox.RunReader(s, c)
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
|
// Tor — a Founding Lifetime Member perk: a torsocks shell in the pod.
|
||||||
torLock := ""
|
torLock := ""
|
||||||
switch {
|
switch {
|
||||||
|
|
@ -1273,6 +1298,83 @@ func (a *app) runNews(s ssh.Session, name string) error {
|
||||||
return news.RunReader(s, addr, name)
|
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
|
||||||
|
// "<name>*<master>") 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.
|
||||||
|
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"),
|
||||||
|
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
|
||||||
|
Username: login,
|
||||||
|
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
|
||||||
|
// SMTPUser/SMTPPass left empty: submit via the trusted local relay.
|
||||||
|
}
|
||||||
|
tr, err := mailbox.NewIMAPTransport(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMail routes a Founding Lifetime member into AgentMail: an interactive
|
||||||
|
// TUI when a PTY is present and no command is given, or the JSON bot mode
|
||||||
|
// (ssh mail@host <cmd>, or no PTY) for agents.
|
||||||
|
func (a *app) handleMail(s ssh.Session) {
|
||||||
|
fp := auth.Fingerprint(s.PublicKey())
|
||||||
|
if fp == "" {
|
||||||
|
wish.Println(s, "mail@ needs your registered SSH key. New here? ssh join@"+a.host)
|
||||||
|
_ = s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, found, err := a.st.UserByFingerprint(fp)
|
||||||
|
if err != nil || !found {
|
||||||
|
wish.Println(s, "key not registered — run: ssh join@"+a.host)
|
||||||
|
_ = s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Banned {
|
||||||
|
wish.Println(s, "this account is suspended.")
|
||||||
|
_ = 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)
|
||||||
|
_ = s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
|
||||||
|
defer func() { _ = a.st.EndSession(sessID) }()
|
||||||
|
|
||||||
|
c, err := a.mailClientFor(u)
|
||||||
|
if err != nil {
|
||||||
|
wish.Println(s, "mail: "+err.Error())
|
||||||
|
_ = s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
args := s.Command()
|
||||||
|
_, _, hasPty := s.Pty()
|
||||||
|
if len(args) > 0 || !hasPty {
|
||||||
|
// Agent/bot mode: JSON in, JSON out.
|
||||||
|
if err := mailbox.RunBot(s.Context(), c, args, s, s); err != nil {
|
||||||
|
_ = s.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := mailbox.RunReader(s, c); err != nil {
|
||||||
|
wish.Println(s, "mail: "+err.Error())
|
||||||
|
_ = s.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
|
// handleTorCmd runs an arbitrary command through Tor (torsocks) inside the
|
||||||
// member's pod, never on the host. Premium; requires a PTY.
|
// member's pod, never on the host. Premium; requires a PTY.
|
||||||
func (a *app) handleTorCmd(s ssh.Session) {
|
func (a *app) handleTorCmd(s ssh.Session) {
|
||||||
|
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -10,6 +10,8 @@ require (
|
||||||
github.com/charmbracelet/wish v1.4.7
|
github.com/charmbracelet/wish v1.4.7
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1
|
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
|
github.com/emersion/go-message v0.18.2
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||||
github.com/livekit/protocol v1.46.0
|
github.com/livekit/protocol v1.46.0
|
||||||
github.com/livekit/server-sdk-go/v2 v2.16.6
|
github.com/livekit/server-sdk-go/v2 v2.16.6
|
||||||
|
|
@ -46,6 +48,7 @@ require (
|
||||||
github.com/dennwc/iters v1.2.2 // indirect
|
github.com/dennwc/iters v1.2.2 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/frostbyte73/core v0.1.1 // indirect
|
github.com/frostbyte73/core v0.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -99,6 +99,12 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
|
||||||
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s=
|
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1 h1:R90ND7acg9HKYj3oJBKKefk73DULdC7IlcnS7MV0X1s=
|
||||||
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y=
|
github.com/dustin/go-nntp v0.0.0-20210723005859-f00d51cf8cc1/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y=
|
||||||
github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0=
|
github.com/dustin/httputil v0.0.0-20170305193905-c47743f54f89/go.mod h1:ZoDWdnxro8Kesk3zrCNOHNFWtajFPSnDMjVEjGjQu/0=
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||||
|
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
|
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,10 @@ var IRCNames = map[string]bool{"irc": true}
|
||||||
// via an in-process newsreader. Free for any registered member, like irc@.
|
// via an in-process newsreader. Free for any registered member, like irc@.
|
||||||
var NewsNames = map[string]bool{"news": true}
|
var NewsNames = map[string]bool{"news": true}
|
||||||
|
|
||||||
|
// MailNames route a Founding Lifetime (paid) member into the AgentMail client —
|
||||||
|
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
|
||||||
|
var MailNames = map[string]bool{"mail": true}
|
||||||
|
|
||||||
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
||||||
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
||||||
var GameNames = map[string]bool{"game": true, "games": true}
|
var GameNames = map[string]bool{"game": true, "games": true}
|
||||||
|
|
@ -99,6 +103,9 @@ func IsIRCName(u string) bool { return IRCNames[strings.ToLower(u)] }
|
||||||
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
|
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
|
||||||
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
|
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
|
// IsMailName reports whether the SSH username requests the AgentMail client.
|
||||||
|
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
|
||||||
|
|
||||||
// systemReserved are names that don't drive an SSH route but would still
|
// systemReserved are names that don't drive an SSH route but would still
|
||||||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||||
// infra hostnames — so members may not claim them as account names.
|
// infra hostnames — so members may not claim them as account names.
|
||||||
|
|
|
||||||
173
internal/mailbox/bot.go
Normal file
173
internal/mailbox/bot.go
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BotUsage is printed when a bot/agent invokes mail with no/unknown command.
|
||||||
|
const BotUsage = `agentmail (non-interactive mode) — JSON in, JSON out:
|
||||||
|
|
||||||
|
mailboxes list folders
|
||||||
|
list [mailbox] [limit] message summaries (default INBOX)
|
||||||
|
read <mailbox> <uid> full message (marks seen; "peek" 4th arg to keep unseen)
|
||||||
|
search <query> [mailbox] search summaries
|
||||||
|
send read a JSON Draft on stdin, send it
|
||||||
|
reply <mailbox> <uid> read {"text":...,"replyAll":bool} on stdin
|
||||||
|
flag <mailbox> <uid> [on|off] set/clear the flagged flag
|
||||||
|
seen <mailbox> <uid> [on|off] set/clear the seen flag
|
||||||
|
delete <mailbox> <uid> delete a message
|
||||||
|
|
||||||
|
Example: ssh mail@bbs.profullstack.com list INBOX 20`
|
||||||
|
|
||||||
|
// RunBot executes one non-interactive command for agents/bots, writing JSON to
|
||||||
|
// out. It returns an error (also emitted as {"error":...}) on failure.
|
||||||
|
func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintln(out, BotUsage)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(out)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
fail := func(err error) error {
|
||||||
|
_ = enc.Encode(map[string]string{"error": err.Error()})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(args[0]) {
|
||||||
|
case "mailboxes":
|
||||||
|
v, err := c.Mailboxes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(v)
|
||||||
|
|
||||||
|
case "list", "ls":
|
||||||
|
mailbox := Inbox
|
||||||
|
if len(args) > 1 {
|
||||||
|
mailbox = args[1]
|
||||||
|
}
|
||||||
|
limit := 0
|
||||||
|
if len(args) > 2 {
|
||||||
|
limit, _ = strconv.Atoi(args[2])
|
||||||
|
}
|
||||||
|
v, err := c.List(ctx, mailbox, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(v)
|
||||||
|
|
||||||
|
case "read", "show":
|
||||||
|
mailbox, uid, err := mailboxUID(args)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
peek := len(args) > 3 && strings.EqualFold(args[3], "peek")
|
||||||
|
msg, ok, err := c.Read(ctx, mailbox, uid, peek)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fail(notFound(mailbox, uid))
|
||||||
|
}
|
||||||
|
return enc.Encode(msg)
|
||||||
|
|
||||||
|
case "search":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fail(fmt.Errorf("usage: search <query> [mailbox]"))
|
||||||
|
}
|
||||||
|
mailbox := ""
|
||||||
|
if len(args) > 2 {
|
||||||
|
mailbox = args[2]
|
||||||
|
}
|
||||||
|
v, err := c.Search(ctx, args[1], mailbox, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(v)
|
||||||
|
|
||||||
|
case "send":
|
||||||
|
var d Draft
|
||||||
|
if err := json.NewDecoder(in).Decode(&d); err != nil {
|
||||||
|
return fail(fmt.Errorf("send expects a JSON Draft on stdin: %w", err))
|
||||||
|
}
|
||||||
|
res, err := c.Send(ctx, d)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(res)
|
||||||
|
|
||||||
|
case "reply":
|
||||||
|
mailbox, uid, err := mailboxUID(args)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
ReplyAll bool `json:"replyAll"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(in).Decode(&body); err != nil {
|
||||||
|
return fail(fmt.Errorf("reply expects {\"text\":...} on stdin: %w", err))
|
||||||
|
}
|
||||||
|
orig, ok, err := c.Read(ctx, mailbox, uid, true)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fail(notFound(mailbox, uid))
|
||||||
|
}
|
||||||
|
res, err := c.Reply(ctx, orig, body.Text, body.ReplyAll)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(res)
|
||||||
|
|
||||||
|
case "flag", "seen":
|
||||||
|
mailbox, uid, err := mailboxUID(args)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
on := true
|
||||||
|
if len(args) > 3 {
|
||||||
|
on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0"
|
||||||
|
}
|
||||||
|
if args[0] == "flag" {
|
||||||
|
err = c.Flag(ctx, mailbox, uid, on)
|
||||||
|
} else {
|
||||||
|
err = c.MarkSeen(ctx, mailbox, uid, on)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(map[string]any{"ok": true, "mailbox": mailbox, "uid": uid, args[0]: on})
|
||||||
|
|
||||||
|
case "delete", "rm":
|
||||||
|
mailbox, uid, err := mailboxUID(args)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
if err := c.Delete(ctx, mailbox, uid); err != nil {
|
||||||
|
return fail(err)
|
||||||
|
}
|
||||||
|
return enc.Encode(map[string]any{"ok": true, "deleted": map[string]any{"mailbox": mailbox, "uid": uid}})
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(out, BotUsage)
|
||||||
|
return fmt.Errorf("unknown command: %s", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailboxUID(args []string) (string, uint32, error) {
|
||||||
|
if len(args) < 3 {
|
||||||
|
return "", 0, fmt.Errorf("usage: %s <mailbox> <uid>", args[0])
|
||||||
|
}
|
||||||
|
uid, err := strconv.ParseUint(args[2], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("invalid uid %q", args[2])
|
||||||
|
}
|
||||||
|
return args[1], uint32(uid), nil
|
||||||
|
}
|
||||||
172
internal/mailbox/client.go
Normal file
172
internal/mailbox/client.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Identity is the acting member and whether they hold the paid membership.
|
||||||
|
type Identity struct {
|
||||||
|
Name string // local-part / handle, e.g. "alice"
|
||||||
|
Paid bool // Founding Lifetime Member; mail is gated on this
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// Client is the ergonomic, paid-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
|
||||||
|
id Identity
|
||||||
|
domain string
|
||||||
|
pageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient builds a paid-gated client. domain is the mail domain (e.g.
|
||||||
|
// mail.profullstack.com); pageSize defaults to 50 when <= 0.
|
||||||
|
func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 50
|
||||||
|
}
|
||||||
|
return &Client{t: t, id: id, domain: domain, pageSize: pageSize}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address is the member's own mailbox address, e.g. alice@mail.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
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailboxes lists folders with unread/total counts.
|
||||||
|
func (c *Client) Mailboxes(ctx context.Context) ([]Mailbox, error) {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.t.ListMailboxes(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns newest-first summaries for a mailbox (INBOX when empty).
|
||||||
|
func (c *Client) List(ctx context.Context, mailbox string, limit int) ([]MessageSummary, error) {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mailbox == "" {
|
||||||
|
mailbox = Inbox
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = c.pageSize
|
||||||
|
}
|
||||||
|
return c.t.ListMessages(ctx, ListOptions{Mailbox: mailbox, Limit: limit})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fetches a full message, marking it seen unless peek is true.
|
||||||
|
func (c *Client) Read(ctx context.Context, mailbox string, uid uint32, peek bool) (Message, bool, error) {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return Message{}, false, err
|
||||||
|
}
|
||||||
|
msg, ok, err := c.t.ReadMessage(ctx, mailbox, uid)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return msg, ok, err
|
||||||
|
}
|
||||||
|
if !peek && !msg.Seen {
|
||||||
|
seen := true
|
||||||
|
if err := c.t.SetFlags(ctx, mailbox, uid, FlagChange{Seen: &seen}); err == nil {
|
||||||
|
msg.Seen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search runs a free-text search across a mailbox (or all when empty).
|
||||||
|
func (c *Client) Search(ctx context.Context, query, mailbox string, limit int) ([]MessageSummary, error) {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = c.pageSize
|
||||||
|
}
|
||||||
|
return c.t.Search(ctx, SearchOptions{Query: query, Mailbox: mailbox, Limit: limit})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send validates, stamps From, and sends a draft.
|
||||||
|
func (c *Client) Send(ctx context.Context, d Draft) (SendResult, error) {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return SendResult{}, err
|
||||||
|
}
|
||||||
|
norm, err := NormalizeDraft(d)
|
||||||
|
if err != nil {
|
||||||
|
return SendResult{}, err
|
||||||
|
}
|
||||||
|
return c.t.Send(ctx, c.Address(), norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply addresses the original sender (and, when replyAll, the other recipients
|
||||||
|
// minus the member), prefixes "Re:", threads via In-Reply-To, and sends.
|
||||||
|
func (c *Client) Reply(ctx context.Context, orig Message, text string, replyAll bool) (SendResult, error) {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return SendResult{}, err
|
||||||
|
}
|
||||||
|
self := strings.ToLower(c.Address())
|
||||||
|
to := orig.From
|
||||||
|
if orig.ReplyTo != nil {
|
||||||
|
to = *orig.ReplyTo
|
||||||
|
}
|
||||||
|
var cc []Address
|
||||||
|
if replyAll {
|
||||||
|
for _, a := range append(append([]Address{}, orig.To...), orig.CC...) {
|
||||||
|
la := strings.ToLower(a.Address)
|
||||||
|
if la != self && la != strings.ToLower(to.Address) {
|
||||||
|
cc = append(cc, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subject := orig.Subject
|
||||||
|
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
|
||||||
|
subject = "Re: " + subject
|
||||||
|
}
|
||||||
|
return c.Send(ctx, Draft{To: []Address{to}, CC: cc, Subject: subject, Text: text, InReplyTo: orig.MessageID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag sets or clears the \Flagged flag.
|
||||||
|
func (c *Client) Flag(ctx context.Context, mailbox string, uid uint32, flagged bool) error {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.t.SetFlags(ctx, mailbox, uid, FlagChange{Flagged: &flagged})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen sets or clears the \Seen flag.
|
||||||
|
func (c *Client) MarkSeen(ctx context.Context, mailbox string, uid uint32, seen bool) error {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.t.SetFlags(ctx, mailbox, uid, FlagChange{Seen: &seen})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a message.
|
||||||
|
func (c *Client) Delete(ctx context.Context, mailbox string, uid uint32) error {
|
||||||
|
if err := c.gate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.t.DeleteMessage(ctx, mailbox, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the underlying transport.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
if c.t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.t.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper used by bot mode to surface a friendly "not found" message.
|
||||||
|
func notFound(mailbox string, uid uint32) error {
|
||||||
|
return fmt.Errorf("%w: %s/%d", ErrNotFound, mailbox, uid)
|
||||||
|
}
|
||||||
336
internal/mailbox/imap.go
Normal file
336
internal/mailbox/imap.go
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
|
gomail "github.com/emersion/go-message/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IMAPConfig connects the adapter to the mail backend (Mailu: Dovecot IMAP +
|
||||||
|
// Postfix submission). Username/Password are the resolved login (which may be a
|
||||||
|
// Dovecot master-user login like "alice*gateway").
|
||||||
|
type IMAPConfig struct {
|
||||||
|
IMAPAddr string // host:port, e.g. mail.profullstack.com:993 (implicit TLS)
|
||||||
|
SMTPAddr string // host:port, e.g. smtp.profullstack.com:587 (STARTTLS)
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
// SMTPUser/SMTPPass default to Username/Password when empty.
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPass string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imapTransport is a Transport backed by a single authenticated IMAP connection
|
||||||
|
// plus SMTP submission. Commands are serialized (the IMAP client is not safe for
|
||||||
|
// concurrent in-flight commands).
|
||||||
|
type imapTransport struct {
|
||||||
|
cfg IMAPConfig
|
||||||
|
mu sync.Mutex
|
||||||
|
c *imapclient.Client
|
||||||
|
selected string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
|
||||||
|
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
|
||||||
|
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
|
||||||
|
}
|
||||||
|
if err := c.Login(cfg.Username, cfg.Password).Wait(); err != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
return nil, fmt.Errorf("imap login: %w", err)
|
||||||
|
}
|
||||||
|
return &imapTransport{cfg: cfg, c: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) Close() error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if t.c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = t.c.Logout().Wait()
|
||||||
|
return t.c.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) selectMailbox(name string, readOnly bool) (*imap.SelectData, error) {
|
||||||
|
data, err := t.c.Select(name, &imap.SelectOptions{ReadOnly: readOnly}).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("select %s: %w", name, err)
|
||||||
|
}
|
||||||
|
t.selected = name
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) ListMailboxes(_ context.Context) ([]Mailbox, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
entries, err := t.c.List("", "*", nil).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Mailbox, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
mb := Mailbox{Name: e.Mailbox, Path: e.Mailbox}
|
||||||
|
st, err := t.c.Status(e.Mailbox, &imap.StatusOptions{NumMessages: true, NumUnseen: true}).Wait()
|
||||||
|
if err == nil {
|
||||||
|
if st.NumMessages != nil {
|
||||||
|
mb.Total = int(*st.NumMessages)
|
||||||
|
}
|
||||||
|
if st.NumUnseen != nil {
|
||||||
|
mb.Unseen = int(*st.NumUnseen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, mb)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) ListMessages(_ context.Context, opts ListOptions) ([]MessageSummary, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
sel, err := t.selectMailbox(opts.Mailbox, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := sel.NumMessages
|
||||||
|
if n == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
start := uint32(1)
|
||||||
|
if opts.Limit > 0 && n > uint32(opts.Limit) {
|
||||||
|
start = n - uint32(opts.Limit) + 1
|
||||||
|
}
|
||||||
|
seq := imap.SeqSet(nil)
|
||||||
|
seq.AddRange(start, n)
|
||||||
|
bufs, err := t.c.Fetch(seq, &imap.FetchOptions{Envelope: true, Flags: true, UID: true}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch: %w", err)
|
||||||
|
}
|
||||||
|
rows := make([]MessageSummary, 0, len(bufs))
|
||||||
|
for _, b := range bufs {
|
||||||
|
rows = append(rows, summaryFromBuf(opts.Mailbox, b))
|
||||||
|
}
|
||||||
|
// newest first
|
||||||
|
for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
rows[i], rows[j] = rows[j], rows[i]
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) ReadMessage(_ context.Context, mailbox string, uid uint32) (Message, bool, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if _, err := t.selectMailbox(mailbox, false); err != nil {
|
||||||
|
return Message{}, false, err
|
||||||
|
}
|
||||||
|
set := imap.UIDSetNum(imap.UID(uid))
|
||||||
|
section := &imap.FetchItemBodySection{}
|
||||||
|
bufs, err := t.c.Fetch(set, &imap.FetchOptions{
|
||||||
|
Envelope: true,
|
||||||
|
Flags: true,
|
||||||
|
UID: true,
|
||||||
|
BodySection: []*imap.FetchItemBodySection{section},
|
||||||
|
}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("fetch uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
if len(bufs) == 0 {
|
||||||
|
return Message{}, false, nil
|
||||||
|
}
|
||||||
|
b := bufs[0]
|
||||||
|
msg := Message{MessageSummary: summaryFromBuf(mailbox, b)}
|
||||||
|
raw := b.FindBodySection(section)
|
||||||
|
if len(raw) > 0 {
|
||||||
|
fillBody(&msg, raw)
|
||||||
|
}
|
||||||
|
msg.Snippet = Snippet(msg.Text, 140)
|
||||||
|
return msg, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) Search(_ context.Context, opts SearchOptions) ([]MessageSummary, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
mailbox := opts.Mailbox
|
||||||
|
if mailbox == "" {
|
||||||
|
mailbox = Inbox
|
||||||
|
}
|
||||||
|
if _, err := t.selectMailbox(mailbox, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := t.c.UIDSearch(&imap.SearchCriteria{Text: []string{opts.Query}}, nil).Wait()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search: %w", err)
|
||||||
|
}
|
||||||
|
uids := data.AllUIDs()
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if opts.Limit > 0 && len(uids) > opts.Limit {
|
||||||
|
uids = uids[len(uids)-opts.Limit:]
|
||||||
|
}
|
||||||
|
bufs, err := t.c.Fetch(imap.UIDSetNum(uids...), &imap.FetchOptions{Envelope: true, Flags: true, UID: true}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search fetch: %w", err)
|
||||||
|
}
|
||||||
|
rows := make([]MessageSummary, 0, len(bufs))
|
||||||
|
for _, b := range bufs {
|
||||||
|
rows = append(rows, summaryFromBuf(mailbox, b))
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) Send(_ context.Context, from string, d Draft) (SendResult, error) {
|
||||||
|
msg, msgID := buildRFC822(from, d)
|
||||||
|
// SMTPUser may be empty for a trusted local relay (no AUTH).
|
||||||
|
if err := smtpSend(t.cfg.SMTPAddr, t.cfg.SMTPUser, t.cfg.SMTPPass, from, recipients(d), msg); err != nil {
|
||||||
|
return SendResult{}, fmt.Errorf("smtp send: %w", err)
|
||||||
|
}
|
||||||
|
// Best-effort copy to Sent so the message shows in the member's mailbox.
|
||||||
|
t.mu.Lock()
|
||||||
|
_, _ = t.c.Append(Sent, int64(len(msg)), nil).Wait() // ignore APPEND errors
|
||||||
|
t.mu.Unlock()
|
||||||
|
return SendResult{MessageID: msgID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) SetFlags(_ context.Context, mailbox string, uid uint32, fc FlagChange) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if _, err := t.selectMailbox(mailbox, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
set := imap.UIDSetNum(imap.UID(uid))
|
||||||
|
apply := func(flag imap.Flag, on bool) error {
|
||||||
|
op := imap.StoreFlagsDel
|
||||||
|
if on {
|
||||||
|
op = imap.StoreFlagsAdd
|
||||||
|
}
|
||||||
|
return t.c.Store(set, &imap.StoreFlags{Op: op, Flags: []imap.Flag{flag}, Silent: true}, nil).Close()
|
||||||
|
}
|
||||||
|
if fc.Seen != nil {
|
||||||
|
if err := apply(imap.FlagSeen, *fc.Seen); err != nil {
|
||||||
|
return fmt.Errorf("store seen: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fc.Flagged != nil {
|
||||||
|
if err := apply(imap.FlagFlagged, *fc.Flagged); err != nil {
|
||||||
|
return fmt.Errorf("store flagged: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *imapTransport) DeleteMessage(_ context.Context, mailbox string, uid uint32) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
if _, err := t.selectMailbox(mailbox, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
set := imap.UIDSetNum(imap.UID(uid))
|
||||||
|
if err := t.c.Store(set, &imap.StoreFlags{Op: imap.StoreFlagsAdd, Flags: []imap.Flag{imap.FlagDeleted}, Silent: true}, nil).Close(); err != nil {
|
||||||
|
return fmt.Errorf("store deleted: %w", err)
|
||||||
|
}
|
||||||
|
if err := t.c.Expunge().Close(); err != nil {
|
||||||
|
return fmt.Errorf("expunge: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func summaryFromBuf(mailbox string, b *imapclient.FetchMessageBuffer) MessageSummary {
|
||||||
|
s := MessageSummary{Mailbox: mailbox, UID: uint32(b.UID)}
|
||||||
|
if b.Envelope != nil {
|
||||||
|
env := b.Envelope
|
||||||
|
s.Subject = env.Subject
|
||||||
|
s.Date = env.Date
|
||||||
|
s.From = firstAddr(env.From)
|
||||||
|
s.To = addrs(env.To)
|
||||||
|
}
|
||||||
|
for _, f := range b.Flags {
|
||||||
|
switch f {
|
||||||
|
case imap.FlagSeen:
|
||||||
|
s.Seen = true
|
||||||
|
case imap.FlagFlagged:
|
||||||
|
s.Flagged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstAddr(list []imap.Address) Address {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return Address{}
|
||||||
|
}
|
||||||
|
return imapAddr(list[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func addrs(list []imap.Address) []Address {
|
||||||
|
out := make([]Address, 0, len(list))
|
||||||
|
for _, a := range list {
|
||||||
|
out = append(out, imapAddr(a))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func imapAddr(a imap.Address) Address {
|
||||||
|
addr := a.Mailbox
|
||||||
|
if a.Host != "" {
|
||||||
|
addr = a.Mailbox + "@" + a.Host
|
||||||
|
}
|
||||||
|
return Address{Name: a.Name, Address: addr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillBody parses the raw RFC822 message into the text/html body + attachment
|
||||||
|
// metadata using go-message's mail reader.
|
||||||
|
func fillBody(msg *Message, raw []byte) {
|
||||||
|
mr, err := gomail.CreateReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
// Not MIME we can parse — keep the raw text after the header break.
|
||||||
|
msg.Text = rawBody(raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if env := mr.Header; env.Get("Message-Id") != "" {
|
||||||
|
msg.MessageID = env.Get("Message-Id")
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
p, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch h := p.Header.(type) {
|
||||||
|
case *gomail.InlineHeader:
|
||||||
|
body, _ := io.ReadAll(p.Body)
|
||||||
|
ct, _, _ := h.ContentType()
|
||||||
|
if strings.EqualFold(ct, "text/html") {
|
||||||
|
msg.HTML = string(body)
|
||||||
|
} else if msg.Text == "" {
|
||||||
|
msg.Text = string(body)
|
||||||
|
}
|
||||||
|
case *gomail.AttachmentHeader:
|
||||||
|
name, _ := h.Filename()
|
||||||
|
ct, _, _ := h.ContentType()
|
||||||
|
body, _ := io.ReadAll(p.Body)
|
||||||
|
msg.Attachments = append(msg.Attachments, Attachment{Filename: name, ContentType: ct, Size: len(body)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg.HasAttachments = len(msg.Attachments) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawBody returns the body after the first blank line of a raw RFC822 message.
|
||||||
|
func rawBody(raw []byte) string {
|
||||||
|
if i := bytes.Index(raw, []byte("\r\n\r\n")); i >= 0 {
|
||||||
|
return string(raw[i+4:])
|
||||||
|
}
|
||||||
|
if i := bytes.Index(raw, []byte("\n\n")); i >= 0 {
|
||||||
|
return string(raw[i+2:])
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
144
internal/mailbox/mailbox_test.go
Normal file
144
internal/mailbox/mailbox_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seeded() *MemoryTransport {
|
||||||
|
m := NewMemoryTransport()
|
||||||
|
m.Add(Message{MessageSummary: MessageSummary{UID: 1, Mailbox: Inbox, From: Address{Name: "Carol", Address: "carol@example.com"}, Subject: "Welcome", Date: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)}, Text: "hi alice"})
|
||||||
|
m.Add(Message{MessageSummary: MessageSummary{UID: 2, Mailbox: Inbox, From: Address{Address: "deploy@ci.example.com"}, Subject: "Build passed", Date: time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)}, Text: "all green"})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func paidClient(t Transport) *Client {
|
||||||
|
return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFormatAddress(t *testing.T) {
|
||||||
|
a := ParseAddress("Ada Lovelace <ada@x.com>")
|
||||||
|
if a.Name != "Ada Lovelace" || a.Address != "ada@x.com" {
|
||||||
|
t.Fatalf("parse named: %+v", a)
|
||||||
|
}
|
||||||
|
if got := ParseAddress("ada@x.com"); got.Address != "ada@x.com" || got.Name != "" {
|
||||||
|
t.Fatalf("parse bare: %+v", got)
|
||||||
|
}
|
||||||
|
if got := FormatAddress(Address{Name: "Doe, John", Address: "j@x.com"}); got != `"Doe, John" <j@x.com>` {
|
||||||
|
t.Fatalf("format specials: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidEmailAndDraft(t *testing.T) {
|
||||||
|
if !ValidEmail("a@b.com") || ValidEmail("nope") {
|
||||||
|
t.Fatal("ValidEmail")
|
||||||
|
}
|
||||||
|
if _, err := NormalizeDraft(Draft{Subject: "x", Text: "y"}); err == nil {
|
||||||
|
t.Fatal("expected error for no recipients")
|
||||||
|
}
|
||||||
|
d, err := NormalizeDraft(Draft{To: []Address{{Address: " a@b.com "}}, Subject: " hi ", Text: "z"})
|
||||||
|
if err != nil || len(d.To) != 1 || d.To[0].Address != "a@b.com" || d.Subject != "hi" {
|
||||||
|
t.Fatalf("normalize: %+v err=%v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbox is a thin helper used in tests and bot mode.
|
||||||
|
func (c *Client) Inbox(ctx context.Context, limit int) ([]MessageSummary, error) {
|
||||||
|
return c.List(ctx, Inbox, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListNewestFirst(t *testing.T) {
|
||||||
|
c := paidClient(seeded())
|
||||||
|
got, err := c.List(context.Background(), Inbox, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0].UID != 2 {
|
||||||
|
t.Fatalf("expected newest first [2,1], got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadMarksSeenAndPeek(t *testing.T) {
|
||||||
|
tr := seeded()
|
||||||
|
c := paidClient(tr)
|
||||||
|
if _, ok, _ := c.Read(context.Background(), Inbox, 1, false); !ok {
|
||||||
|
t.Fatal("read 1")
|
||||||
|
}
|
||||||
|
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Seen {
|
||||||
|
t.Fatal("uid 1 should be seen")
|
||||||
|
}
|
||||||
|
if _, _, _ = c.Read(context.Background(), Inbox, 2, true); true {
|
||||||
|
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 2); m.Seen {
|
||||||
|
t.Fatal("peek must not mark seen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok, _ := c.Read(context.Background(), Inbox, 999, false); ok {
|
||||||
|
t.Fatal("unknown uid should be ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearch(t *testing.T) {
|
||||||
|
c := paidClient(seeded())
|
||||||
|
hits, _ := c.Search(context.Background(), "green", "", 0)
|
||||||
|
if len(hits) != 1 || hits[0].UID != 2 {
|
||||||
|
t.Fatalf("search green: %+v", hits)
|
||||||
|
}
|
||||||
|
hits, _ = c.Search(context.Background(), "carol@example.com", "", 0)
|
||||||
|
if len(hits) != 1 || hits[0].UID != 1 {
|
||||||
|
t.Fatalf("search sender: %+v", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendAndReply(t *testing.T) {
|
||||||
|
tr := seeded()
|
||||||
|
c := paidClient(tr)
|
||||||
|
res, err := c.Send(context.Background(), Draft{To: []Address{{Address: "carol@example.com"}}, Subject: " Hi ", Text: "yo"})
|
||||||
|
if err != nil || res.MessageID == "" {
|
||||||
|
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" {
|
||||||
|
t.Fatalf("sent: %+v", sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
orig, _, _ := c.Read(context.Background(), Inbox, 1, true)
|
||||||
|
if _, err := c.Reply(context.Background(), orig, "thanks", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent, _ = tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
|
||||||
|
var re MessageSummary
|
||||||
|
for _, s := range sent {
|
||||||
|
if s.Subject == "Re: Welcome" {
|
||||||
|
re = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if re.Subject != "Re: Welcome" || re.To[0].Address != "carol@example.com" {
|
||||||
|
t.Fatalf("reply: %+v", sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlagAndDelete(t *testing.T) {
|
||||||
|
tr := seeded()
|
||||||
|
c := paidClient(tr)
|
||||||
|
if err := c.Flag(context.Background(), Inbox, 1, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Flagged {
|
||||||
|
t.Fatal("uid 1 should be flagged")
|
||||||
|
}
|
||||||
|
if err := c.Delete(context.Background(), Inbox, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok, _ := tr.ReadMessage(context.Background(), Inbox, 1); ok {
|
||||||
|
t.Fatal("uid 1 should be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
186
internal/mailbox/memory.go
Normal file
186
internal/mailbox/memory.go
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemoryTransport is a complete, dependency-free Transport for tests, local
|
||||||
|
// development, and as the reference for what the IMAP/SMTP adapter must do.
|
||||||
|
type MemoryTransport struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
byMailbox map[string][]Message
|
||||||
|
nextUID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryTransport returns an empty in-memory transport.
|
||||||
|
func NewMemoryTransport() *MemoryTransport {
|
||||||
|
return &MemoryTransport{byMailbox: map[string][]Message{}, nextUID: 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inserts a message, filling defaults, and returns the stored copy.
|
||||||
|
func (m *MemoryTransport) Add(msg Message) Message {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if msg.UID == 0 {
|
||||||
|
msg.UID = m.nextUID
|
||||||
|
}
|
||||||
|
if msg.UID >= m.nextUID {
|
||||||
|
m.nextUID = msg.UID + 1
|
||||||
|
}
|
||||||
|
if msg.Date.IsZero() {
|
||||||
|
msg.Date = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if msg.MessageID == "" {
|
||||||
|
msg.MessageID = fmt.Sprintf("<%s@memory.local>", randToken())
|
||||||
|
}
|
||||||
|
if msg.Snippet == "" {
|
||||||
|
msg.Snippet = Snippet(msg.Text, 140)
|
||||||
|
}
|
||||||
|
msg.HasAttachments = len(msg.Attachments) > 0
|
||||||
|
m.byMailbox[msg.Mailbox] = append(m.byMailbox[msg.Mailbox], msg)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) ListMailboxes(_ context.Context) ([]Mailbox, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]Mailbox, 0, len(m.byMailbox))
|
||||||
|
for path, list := range m.byMailbox {
|
||||||
|
unseen := 0
|
||||||
|
for _, msg := range list {
|
||||||
|
if !msg.Seen {
|
||||||
|
unseen++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, Mailbox{Name: path, Path: path, Total: len(list), Unseen: unseen})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) ListMessages(_ context.Context, opts ListOptions) ([]MessageSummary, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
list := append([]Message{}, m.byMailbox[opts.Mailbox]...)
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].Date.After(list[j].Date) })
|
||||||
|
if opts.Limit > 0 && len(list) > opts.Limit {
|
||||||
|
list = list[:opts.Limit]
|
||||||
|
}
|
||||||
|
return summaries(list), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) ReadMessage(_ context.Context, mailbox string, uid uint32) (Message, bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, msg := range m.byMailbox[mailbox] {
|
||||||
|
if msg.UID == uid {
|
||||||
|
return msg, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Message{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) Search(_ context.Context, opts SearchOptions) ([]MessageSummary, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
q := strings.ToLower(opts.Query)
|
||||||
|
var hits []Message
|
||||||
|
boxes := []string{opts.Mailbox}
|
||||||
|
if opts.Mailbox == "" {
|
||||||
|
boxes = boxes[:0]
|
||||||
|
for b := range m.byMailbox {
|
||||||
|
boxes = append(boxes, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range boxes {
|
||||||
|
for _, msg := range m.byMailbox[b] {
|
||||||
|
hay := strings.ToLower(msg.Subject + " " + msg.From.Address + " " + msg.From.Name + " " + msg.Text)
|
||||||
|
if strings.Contains(hay, q) {
|
||||||
|
hits = append(hits, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(hits, func(i, j int) bool { return hits[i].Date.After(hits[j].Date) })
|
||||||
|
if opts.Limit > 0 && len(hits) > opts.Limit {
|
||||||
|
hits = hits[:opts.Limit]
|
||||||
|
}
|
||||||
|
return summaries(hits), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) Send(_ context.Context, from string, d Draft) (SendResult, error) {
|
||||||
|
domain := "memory.local"
|
||||||
|
if at := strings.LastIndexByte(from, '@'); at >= 0 {
|
||||||
|
domain = from[at+1:]
|
||||||
|
}
|
||||||
|
id := fmt.Sprintf("<%s@%s>", randToken(), domain)
|
||||||
|
var refs []string
|
||||||
|
if d.InReplyTo != "" {
|
||||||
|
refs = []string{d.InReplyTo}
|
||||||
|
}
|
||||||
|
m.Add(Message{
|
||||||
|
MessageSummary: MessageSummary{Mailbox: Sent, From: Address{Address: from}, To: d.To, Subject: d.Subject, Seen: true},
|
||||||
|
CC: d.CC,
|
||||||
|
Text: d.Text,
|
||||||
|
MessageID: id,
|
||||||
|
References: refs,
|
||||||
|
})
|
||||||
|
return SendResult{MessageID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) SetFlags(_ context.Context, mailbox string, uid uint32, fc FlagChange) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
list := m.byMailbox[mailbox]
|
||||||
|
for i := range list {
|
||||||
|
if list[i].UID == uid {
|
||||||
|
if fc.Seen != nil {
|
||||||
|
list[i].Seen = *fc.Seen
|
||||||
|
}
|
||||||
|
if fc.Flagged != nil {
|
||||||
|
list[i].Flagged = *fc.Flagged
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) DeleteMessage(_ context.Context, mailbox string, uid uint32) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
list := m.byMailbox[mailbox]
|
||||||
|
for i := range list {
|
||||||
|
if list[i].UID == uid {
|
||||||
|
m.byMailbox[mailbox] = append(list[:i], list[i+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryTransport) Close() error { return nil }
|
||||||
|
|
||||||
|
func summaries(list []Message) []MessageSummary {
|
||||||
|
out := make([]MessageSummary, len(list))
|
||||||
|
for i, msg := range list {
|
||||||
|
s := msg.MessageSummary
|
||||||
|
s.HasAttachments = len(msg.Attachments) > 0
|
||||||
|
out[i] = s
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func randToken() string {
|
||||||
|
const alpha = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
b := make([]byte, 10)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = alpha[rand.Intn(len(alpha))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
251
internal/mailbox/reader_tui.go
Normal file
251
internal/mailbox/reader_tui.go
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mhTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||||
|
mhDim = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||||
|
mhCursor = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||||
|
mhUnseen = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
|
||||||
|
mhFlag = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
mhErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||||
|
mhFrame = lipgloss.NewStyle().Padding(1, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunReader runs the interactive mail TUI for a member on the session. Used by
|
||||||
|
// the hub "Mail" entry and the ssh mail@ route (with a PTY).
|
||||||
|
func RunReader(s ssh.Session, c *Client) error {
|
||||||
|
m := readerModel{c: c, ctx: s.Context(), mailbox: Inbox, status: "loading…"}
|
||||||
|
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
|
||||||
|
_, err := p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type readerMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeList readerMode = iota
|
||||||
|
modeMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
type readerModel struct {
|
||||||
|
c *Client
|
||||||
|
ctx context.Context
|
||||||
|
mailbox string
|
||||||
|
|
||||||
|
mode readerMode
|
||||||
|
rows []MessageSummary
|
||||||
|
cursor int
|
||||||
|
current Message
|
||||||
|
status string
|
||||||
|
errText string
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowsMsg struct{ rows []MessageSummary }
|
||||||
|
type openedMsg struct{ msg Message }
|
||||||
|
type actionDoneMsg struct{ status string }
|
||||||
|
type errMsg struct{ err error }
|
||||||
|
|
||||||
|
func (m readerModel) Init() tea.Cmd { return m.loadInbox() }
|
||||||
|
|
||||||
|
func (m readerModel) loadInbox() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
rows, err := m.c.List(m.ctx, m.mailbox, 0)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return rowsMsg{rows}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m readerModel) open(uid uint32) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg, ok, err := m.c.Read(m.ctx, m.mailbox, uid, false)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errMsg{notFound(m.mailbox, uid)}
|
||||||
|
}
|
||||||
|
return openedMsg{msg}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m readerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
case rowsMsg:
|
||||||
|
m.rows = msg.rows
|
||||||
|
if m.cursor >= len(m.rows) {
|
||||||
|
m.cursor = max(0, len(m.rows)-1)
|
||||||
|
}
|
||||||
|
m.status = fmt.Sprintf("%s — %d message(s)", m.mailbox, len(m.rows))
|
||||||
|
case openedMsg:
|
||||||
|
m.current = msg.msg
|
||||||
|
m.mode = modeMessage
|
||||||
|
case actionDoneMsg:
|
||||||
|
m.status = msg.status
|
||||||
|
return m, m.loadInbox()
|
||||||
|
case errMsg:
|
||||||
|
m.errText = msg.err.Error()
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.onKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m readerModel) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.errText = ""
|
||||||
|
if m.mode == modeMessage {
|
||||||
|
switch k.String() {
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "b", "esc", "left", "h":
|
||||||
|
m.mode = modeList
|
||||||
|
return m, m.loadInbox()
|
||||||
|
case "f":
|
||||||
|
uid := m.current.UID
|
||||||
|
flagged := !m.current.Flagged
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
if err := m.c.Flag(m.ctx, m.mailbox, uid, flagged); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return actionDoneMsg{status: flagState(flagged)}
|
||||||
|
}
|
||||||
|
case "x", "d":
|
||||||
|
uid := m.current.UID
|
||||||
|
m.mode = modeList
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
if err := m.c.Delete(m.ctx, m.mailbox, uid); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return actionDoneMsg{status: "deleted"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch k.String() {
|
||||||
|
case "q", "ctrl+c", "esc":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "j", "down":
|
||||||
|
if m.cursor < len(m.rows)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
case "k", "up":
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
m.status = "refreshing…"
|
||||||
|
return m, m.loadInbox()
|
||||||
|
case "enter", "l", "right":
|
||||||
|
if len(m.rows) > 0 {
|
||||||
|
return m, m.open(m.rows[m.cursor].UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m readerModel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(mhTitle.Render("AgentMail") + mhDim.Render(" · "+m.c.Address()) + "\n\n")
|
||||||
|
if m.mode == modeMessage {
|
||||||
|
b.WriteString(m.viewMessage())
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.viewList())
|
||||||
|
}
|
||||||
|
if m.errText != "" {
|
||||||
|
b.WriteString("\n" + mhErr.Render(m.errText))
|
||||||
|
}
|
||||||
|
return mhFrame.Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m readerModel) viewList() string {
|
||||||
|
var b strings.Builder
|
||||||
|
if len(m.rows) == 0 {
|
||||||
|
b.WriteString(mhDim.Render("(no messages)") + "\n")
|
||||||
|
}
|
||||||
|
for i, r := range m.rows {
|
||||||
|
cur := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
cur = mhCursor.Render("❯ ")
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if !r.Seen {
|
||||||
|
marker = "●"
|
||||||
|
}
|
||||||
|
flag := " "
|
||||||
|
if r.Flagged {
|
||||||
|
flag = mhFlag.Render("⚑")
|
||||||
|
}
|
||||||
|
from := r.From.Name
|
||||||
|
if from == "" {
|
||||||
|
from = r.From.Address
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("%s%s%s %-22.22s %s", cur, marker, flag, from, r.Subject)
|
||||||
|
if !r.Seen {
|
||||||
|
line = mhUnseen.Render(line)
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
b.WriteString(" " + mhDim.Render(r.Date.Format("2006-01-02 15:04")+" · "+r.Snippet) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + mhDim.Render(m.status))
|
||||||
|
b.WriteString("\n" + mhDim.Render("↑/↓ move · enter open · r refresh · q quit"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m readerModel) viewMessage() string {
|
||||||
|
msg := m.current
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(mhDim.Render("From: ") + FormatAddress(msg.From) + "\n")
|
||||||
|
b.WriteString(mhDim.Render("To: ") + joinAddrs(msg.To) + "\n")
|
||||||
|
if len(msg.CC) > 0 {
|
||||||
|
b.WriteString(mhDim.Render("Cc: ") + joinAddrs(msg.CC) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString(mhDim.Render("Date: ") + msg.Date.Format("2006-01-02 15:04") + "\n")
|
||||||
|
b.WriteString(mhDim.Render("Subject: ") + mhUnseen.Render(msg.Subject) + "\n")
|
||||||
|
if len(msg.Attachments) > 0 {
|
||||||
|
names := make([]string, len(msg.Attachments))
|
||||||
|
for i, a := range msg.Attachments {
|
||||||
|
names[i] = a.Filename
|
||||||
|
}
|
||||||
|
b.WriteString(mhDim.Render("Attach: ") + strings.Join(names, ", ") + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("\n" + msg.Text + "\n")
|
||||||
|
b.WriteString("\n" + mhDim.Render("b back · f flag · x delete · q quit"))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinAddrs(list []Address) string {
|
||||||
|
parts := make([]string, len(list))
|
||||||
|
for i, a := range list {
|
||||||
|
parts[i] = FormatAddress(a)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagState(on bool) string {
|
||||||
|
if on {
|
||||||
|
return "flagged"
|
||||||
|
}
|
||||||
|
return "unflagged"
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
81
internal/mailbox/smtp.go
Normal file
81
internal/mailbox/smtp.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildRFC822 renders a draft as an RFC 5322 message (CRLF line endings, UTF-8
|
||||||
|
// text body). It returns the bytes and the generated Message-ID.
|
||||||
|
func buildRFC822(from string, d Draft) ([]byte, string) {
|
||||||
|
domain := "localhost"
|
||||||
|
if at := strings.LastIndexByte(from, '@'); at >= 0 {
|
||||||
|
domain = from[at+1:]
|
||||||
|
}
|
||||||
|
msgID := fmt.Sprintf("<%s@%s>", randToken(), domain)
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
wh := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) }
|
||||||
|
wh("From", from)
|
||||||
|
wh("To", headerAddrs(d.To))
|
||||||
|
if len(d.CC) > 0 {
|
||||||
|
wh("Cc", headerAddrs(d.CC))
|
||||||
|
}
|
||||||
|
wh("Subject", mime.QEncoding.Encode("utf-8", d.Subject))
|
||||||
|
wh("Date", time.Now().Format(time.RFC1123Z))
|
||||||
|
wh("Message-Id", msgID)
|
||||||
|
if d.InReplyTo != "" {
|
||||||
|
wh("In-Reply-To", d.InReplyTo)
|
||||||
|
wh("References", d.InReplyTo)
|
||||||
|
}
|
||||||
|
wh("MIME-Version", "1.0")
|
||||||
|
wh("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
wh("Content-Transfer-Encoding", "8bit")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(strings.ReplaceAll(d.Text, "\n", "\r\n"))
|
||||||
|
return b.Bytes(), msgID
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerAddrs renders an address list for a header, encoding display names.
|
||||||
|
func headerAddrs(list []Address) string {
|
||||||
|
parts := make([]string, len(list))
|
||||||
|
for i, a := range list {
|
||||||
|
if a.Name == "" {
|
||||||
|
parts[i] = a.Address
|
||||||
|
} else {
|
||||||
|
parts[i] = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", a.Name), a.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recipients flattens To+Cc+Bcc into envelope recipient addresses.
|
||||||
|
func recipients(d Draft) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range [][]Address{d.To, d.CC, d.BCC} {
|
||||||
|
for _, a := range l {
|
||||||
|
out = append(out, a.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// smtpSend submits a built message via SMTP. With a non-empty user it does
|
||||||
|
// STARTTLS + AUTH (e.g. smtp.profullstack.com:587); with an empty user it sends
|
||||||
|
// unauthenticated, for a trusted local relay (e.g. the co-located Postfix).
|
||||||
|
func smtpSend(addr, user, pass, from string, rcpts []string, msg []byte) error {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp addr %q: %w", addr, err)
|
||||||
|
}
|
||||||
|
var auth smtp.Auth
|
||||||
|
if user != "" {
|
||||||
|
auth = smtp.PlainAuth("", user, pass, host)
|
||||||
|
}
|
||||||
|
return smtp.SendMail(addr, auth, from, rcpts, msg)
|
||||||
|
}
|
||||||
100
internal/mailbox/transport.go
Normal file
100
internal/mailbox/transport.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transport is the low-level mailbox backend. The TUI, the bot mode, and the
|
||||||
|
// Client all talk to this interface, so an in-memory fake (tests/dev) and the
|
||||||
|
// real IMAP/SMTP adapter are interchangeable.
|
||||||
|
type Transport interface {
|
||||||
|
ListMailboxes(ctx context.Context) ([]Mailbox, error)
|
||||||
|
ListMessages(ctx context.Context, opts ListOptions) ([]MessageSummary, error)
|
||||||
|
// ReadMessage returns (Message, false, nil) when the uid is unknown.
|
||||||
|
ReadMessage(ctx context.Context, mailbox string, uid uint32) (Message, bool, error)
|
||||||
|
Search(ctx context.Context, opts SearchOptions) ([]MessageSummary, error)
|
||||||
|
// Send delivers an already-validated draft, stamped from `from`.
|
||||||
|
Send(ctx context.Context, from string, d Draft) (SendResult, error)
|
||||||
|
SetFlags(ctx context.Context, mailbox string, uid uint32, fc FlagChange) error
|
||||||
|
DeleteMessage(ctx context.Context, mailbox string, uid uint32) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a uid/mailbox cannot be resolved.
|
||||||
|
var ErrNotFound = errors.New("mailbox: message not found")
|
||||||
|
|
||||||
|
var addrRe = regexp.MustCompile(`^(.*)<([^>]+)>\s*$`)
|
||||||
|
|
||||||
|
// ParseAddress parses "Name <a@b>" or a bare "a@b".
|
||||||
|
func ParseAddress(raw string) Address {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if m := addrRe.FindStringSubmatch(s); m != nil {
|
||||||
|
name := strings.Trim(strings.TrimSpace(m[1]), `"`)
|
||||||
|
return Address{Name: strings.TrimSpace(name), Address: strings.TrimSpace(m[2])}
|
||||||
|
}
|
||||||
|
return Address{Address: strings.Trim(s, "<>")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatAddress renders an Address back to "Name <addr>" (or just the address).
|
||||||
|
func FormatAddress(a Address) string {
|
||||||
|
if a.Name == "" {
|
||||||
|
return a.Address
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(a.Name, `",<>@`) {
|
||||||
|
return fmt.Sprintf("%q <%s>", a.Name, a.Address)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s <%s>", a.Name, a.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet collapses whitespace and truncates to at most max runes.
|
||||||
|
func Snippet(body string, max int) string {
|
||||||
|
flat := strings.Join(strings.Fields(body), " ")
|
||||||
|
if max <= 0 || len([]rune(flat)) <= max {
|
||||||
|
return flat
|
||||||
|
}
|
||||||
|
r := []rune(flat)
|
||||||
|
return string(r[:max-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidEmail is a loose RFC5322-ish check: one @, a dotted domain, no spaces.
|
||||||
|
func ValidEmail(v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if len(v) < 3 || len(v) > 254 || strings.ContainsAny(v, " \t\r\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at := strings.LastIndexByte(v, '@')
|
||||||
|
if at <= 0 || at == len(v)-1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(v[at+1:], ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeDraft trims the subject, drops empty recipient lists, and rejects a
|
||||||
|
// draft with no valid recipient.
|
||||||
|
func NormalizeDraft(d Draft) (Draft, error) {
|
||||||
|
clean := func(in []Address) []Address {
|
||||||
|
out := make([]Address, 0, len(in))
|
||||||
|
for _, a := range in {
|
||||||
|
a.Address = strings.TrimSpace(a.Address)
|
||||||
|
if a.Address != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
to, cc, bcc := clean(d.To), clean(d.CC), clean(d.BCC)
|
||||||
|
all := append(append(append([]Address{}, to...), cc...), bcc...)
|
||||||
|
if len(all) == 0 {
|
||||||
|
return Draft{}, errors.New("a draft needs at least one recipient")
|
||||||
|
}
|
||||||
|
for _, a := range all {
|
||||||
|
if !ValidEmail(a.Address) {
|
||||||
|
return Draft{}, fmt.Errorf("invalid recipient address: %s", a.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Draft{To: to, CC: cc, BCC: bcc, Subject: strings.TrimSpace(d.Subject), Text: d.Text, InReplyTo: d.InReplyTo}, nil
|
||||||
|
}
|
||||||
99
internal/mailbox/types.go
Normal file
99
internal/mailbox/types.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are
|
||||||
|
// deliberately the same so tooling can move between them.
|
||||||
|
package mailbox
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Address is a parsed mail address.
|
||||||
|
type Address struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailbox is an IMAP folder with unread/total counts.
|
||||||
|
type Mailbox struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Unseen int `json:"unseen"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageSummary is a lightweight row for list/search views.
|
||||||
|
type MessageSummary struct {
|
||||||
|
UID uint32 `json:"uid"`
|
||||||
|
Mailbox string `json:"mailbox"`
|
||||||
|
From Address `json:"from"`
|
||||||
|
To []Address `json:"to"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Seen bool `json:"seen"`
|
||||||
|
Flagged bool `json:"flagged"`
|
||||||
|
HasAttachments bool `json:"hasAttachments"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment is attachment metadata (bytes fetched separately).
|
||||||
|
type Attachment struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is a fully fetched message.
|
||||||
|
type Message struct {
|
||||||
|
MessageSummary
|
||||||
|
CC []Address `json:"cc,omitempty"`
|
||||||
|
ReplyTo *Address `json:"replyTo,omitempty"`
|
||||||
|
MessageID string `json:"messageId"`
|
||||||
|
References []string `json:"references,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
HTML string `json:"html,omitempty"`
|
||||||
|
Attachments []Attachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft is an outgoing message.
|
||||||
|
type Draft struct {
|
||||||
|
To []Address `json:"to"`
|
||||||
|
CC []Address `json:"cc,omitempty"`
|
||||||
|
BCC []Address `json:"bcc,omitempty"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
InReplyTo string `json:"inReplyTo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOptions controls a mailbox listing.
|
||||||
|
type ListOptions struct {
|
||||||
|
Mailbox string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOptions controls a search.
|
||||||
|
type SearchOptions struct {
|
||||||
|
Mailbox string // empty = all mailboxes
|
||||||
|
Query string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlagChange is a partial flag update; nil fields are left unchanged.
|
||||||
|
type FlagChange struct {
|
||||||
|
Seen *bool
|
||||||
|
Flagged *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendResult reports a sent message's id.
|
||||||
|
type SendResult struct {
|
||||||
|
MessageID string `json:"messageId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Inbox is the default mailbox.
|
||||||
|
Inbox = "INBOX"
|
||||||
|
// Sent is where sent mail is stored.
|
||||||
|
Sent = "Sent"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue