diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 7e166a4..cabd52f 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -52,6 +52,7 @@ import ( gossh "golang.org/x/crypto/ssh" "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/brand" "github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/forgejo" @@ -60,6 +61,7 @@ import ( "github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/irc" "github.com/profullstack/agentbbs/internal/mail" + "github.com/profullstack/agentbbs/internal/mailbox" "github.com/profullstack/agentbbs/internal/news" "github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/plugin" @@ -316,6 +318,8 @@ func (a *app) router() wish.Middleware { a.handleIRC(s) case auth.IsNewsName(user): a.handleNews(s) + case auth.IsMailName(user): + a.handleMail(s) case isVideo: a.handleVideo(s, code) case user == "agent": @@ -330,12 +334,9 @@ func (a *app) router() wish.Middleware { } // bbsBanner is the ASCII brand mark shown atop the hub menu and the join@ flow. -const bbsBanner = "" + - "┌─┐┬─┐┌─┐┌─┐┬ ┬┬ ┬ ┌─┐┌┬┐┌─┐┌─┐┬┌─\n" + - "├─┘├┬┘│ │├┤ │ ││ │ └─┐ │ ├─┤│ ├┴┐\n" + - "┴ ┴└─└─┘└ └─┘┴─┘┴─┘└─┘ ┴ ┴ ┴└─┘┴ ┴ .com" +var bbsBanner = brand.Logo() -var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8")) +var bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a")) // hubMOTD is the welcome message shown in a box on the hub menu. The body is // operator-overridable via AGENTBBS_MOTD; it is tailored for guests vs members. @@ -474,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) }}, }) + // 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. torLock := "" switch { @@ -1275,6 +1298,83 @@ func (a *app) runNews(s ssh.Session, name string) error { 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 +// "*") 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 , 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 // member's pod, never on the host. Premium; requires a PTY. func (a *app) handleTorCmd(s ssh.Session) { diff --git a/go.mod b/go.mod index 9fadebd..70691b4 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/charmbracelet/wish v1.4.7 github.com/creack/pty v1.1.24 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/livekit/protocol v1.46.0 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect diff --git a/go.sum b/go.sum index 12f22d1..41d9e03 100644 --- a/go.sum +++ b/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/go.mod h1:elGbp3dKCIIdwu6jm3y6L93EVn+I6MSzYrcZXhpNS3Y= 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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e8dcfe8..1b5e432 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -65,6 +65,10 @@ var IRCNames = map[string]bool{"irc": true} // via an in-process newsreader. Free for any registered member, like irc@. 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 // agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. 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. 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 // collide with a per-user subdomain (.), the agent route, or common // infra hostnames — so members may not claim them as account names. diff --git a/internal/brand/banner.go b/internal/brand/banner.go new file mode 100644 index 0000000..fd6c3e6 --- /dev/null +++ b/internal/brand/banner.go @@ -0,0 +1,26 @@ +// Package brand holds AgentBBS's terminal branding — the ASCII logo shown +// at the top of the join@ onboarding and the ssh @ hub prompts. +// Scaled down from the Profullstack mark. +package brand + +import "strings" + +// logo is the ASCII rendition of the brand mark (UTF-8 block glyphs). +const logo = ` + ▓████▓▒ + ▒█████▓ + +▒▓ +▓████▓+ ▒+ + :+▒▓▓▓▓ :▓██▓█▓+ ▒▓▓▓▒+ + +▒▒▓▓▓▓▓▓▓ ▓▓▓▓▓▓+ ▒▓▓▓▓▓▒▒+ +:+▒▓▓▓▓▓▓▓▒+ ▒▓▓▓▓▓▒ +▒▓▓▓▓▓▓▒▒: +▒▓▓▒▓▒▒▒: +▓▓▓▓▓▓ :▒▒▒▒▒▒▒▒ +▒▒▒▒++ ▒▓▓▓▓▓+ ++▒▒▒▒ +▒▒+ +▓▓▓▓▓▒ ++▒ +▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒ +▒▒▒▒▒▒▒+ ▒▓▓▓▓▒ +▒▒▒▒▒▒▒ ++▒▒▒▒▒▒▒▒▒+ +▓▓▓▓▓: +▒▒▒▒▒▒▒▒++ +` + +// Logo returns the plain (unstyled) multi-line ASCII brand banner, without +// leading or trailing blank lines. The hub/join@ flows apply their own color. +func Logo() string { return strings.Trim(logo, "\n") } diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 9f42402..d3a6092 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -27,7 +27,7 @@ var ( cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0")) lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) - bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#38bdf8")) + bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a")) motdStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#4ade80")). diff --git a/internal/mailbox/bot.go b/internal/mailbox/bot.go new file mode 100644 index 0000000..db12c52 --- /dev/null +++ b/internal/mailbox/bot.go @@ -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 full message (marks seen; "peek" 4th arg to keep unseen) + search [mailbox] search summaries + send read a JSON Draft on stdin, send it + reply read {"text":...,"replyAll":bool} on stdin + flag [on|off] set/clear the flagged flag + seen [on|off] set/clear the seen flag + delete 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 [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 ", 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 +} diff --git a/internal/mailbox/client.go b/internal/mailbox/client.go new file mode 100644 index 0000000..aba1928 --- /dev/null +++ b/internal/mailbox/client.go @@ -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) +} diff --git a/internal/mailbox/imap.go b/internal/mailbox/imap.go new file mode 100644 index 0000000..44ab7f3 --- /dev/null +++ b/internal/mailbox/imap.go @@ -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) +} diff --git a/internal/mailbox/mailbox_test.go b/internal/mailbox/mailbox_test.go new file mode 100644 index 0000000..09da9f1 --- /dev/null +++ b/internal/mailbox/mailbox_test.go @@ -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 ") + 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" ` { + 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") + } +} diff --git a/internal/mailbox/memory.go b/internal/mailbox/memory.go new file mode 100644 index 0000000..8ce108b --- /dev/null +++ b/internal/mailbox/memory.go @@ -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) +} diff --git a/internal/mailbox/reader_tui.go b/internal/mailbox/reader_tui.go new file mode 100644 index 0000000..489440a --- /dev/null +++ b/internal/mailbox/reader_tui.go @@ -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 +} diff --git a/internal/mailbox/smtp.go b/internal/mailbox/smtp.go new file mode 100644 index 0000000..19b5619 --- /dev/null +++ b/internal/mailbox/smtp.go @@ -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) +} diff --git a/internal/mailbox/transport.go b/internal/mailbox/transport.go new file mode 100644 index 0000000..b891117 --- /dev/null +++ b/internal/mailbox/transport.go @@ -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 " 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 " (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 +} diff --git a/internal/mailbox/types.go b/internal/mailbox/types.go new file mode 100644 index 0000000..a952772 --- /dev/null +++ b/internal/mailbox/types.go @@ -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" +)