mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
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
|
|
}
|