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
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue