mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +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
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue