mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
* feat(mail): give every verified member a free @bbs.profullstack.com mailbox Email was built but paid-only (Founding Lifetime gate) and never wired to a running backend. Make it a free benefit of membership and split the address domain from the mail-server host. - internal/mailu: Mailu admin-API client; EnsureUser idempotently provisions a mailbox via the loopback admin REST API (token = mailu.env API_TOKEN). - main.go: auto-provision <name>@<mailDomain> at join@ verification and on first Mail open; un-gate the Mail hub entry + mail@ (membership/email-verified, not Premium); address domain (AGENTBBS_MAIL_ADDR_DOMAIN, default the BBS host) is now distinct from the mail server host (AGENTBBS_MAIL_DOMAIN) and the webmail URL. Drop the forwardemail alias path (Mailu now owns delivery for everyone). - mailbox: gate on membership (a registered handle) instead of Paid; ErrNotPaid -> ErrNotMember. - join@ copy: list email under free membership; premium now pitches custom domains + Tor only. - setup.sh / docs/mail.md / deploy/mailu: address-domain vs server-host split, Mailu API token, MX for the address domain, local-relay SMTP for verify codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(mailu): pin Docker network subnet to match SUBNET; ignore runtime state The base compose declares no network, so Docker assigns the default bridge an arbitrary subnet that won't match mailu.env SUBNET — breaking Mailu's internal service auth/relay. Add a docker-compose.override.yml.example that pins the default network to 192.168.203.0/24, and gitignore the live override + Mailu runtime state (mailu.env, certs/, data/). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mail): plaintext loopback IMAP so the gateway bypasses Mailu's front Mailu's front (nginx mail proxy) pre-authenticates against Mailu's user DB before proxying to Dovecot, which rejects the Dovecot master-user login <addr>*gateway. The gateway must reach Dovecot directly. The imap container has no TLS cert (only the front does), so the bypass is plaintext over loopback — the master password never leaves the host. - mailbox: IMAPConfig.Plaintext dials with DialInsecure (loopback only). - main.go: mailClientFor sets Plaintext from AGENTBBS_MAIL_IMAP_PLAINTEXT. - override.example: add the unbound resolver (admin needs DNSSEC), webmail image fix (2024.06 uses mailu/webmail), and publish Dovecot 143 on 127.0.0.1:14143. - docs/mail.md: document the front-bypass, the dovecot.conf master passdb (Mailu includes that exact filename), and the 644 master-users perms (640 = temp_fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * deploy(mailu): wire gateway IMAP to the loopback Dovecot path in setup.sh setup.sh §9e set AGENTBBS_MAIL_IMAP_ADDR to the front's :993, which the front's auth proxy rejects for the master-user login (and would clobber the working loopback wiring on every self-update). Point it at 127.0.0.1:14143 + AGENTBBS_MAIL_IMAP_PLAINTEXT=1 instead, matching the override + docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(mail): give free members a webmail password at join@ The gateway opens mailboxes via the Dovecot master user (no member password), but webmail (Roundcube) needs the member to have a password. join@ now sets a fresh, readable webmail password via the Mailu API and shows it with the webmail URL + login, so free members can use webmail at mail.profullstack.com. - mailu: SetPassword (PATCH /user/<email> raw_password) + test. - main.go: setWebmailPassword + readablePassword; join@ displays url/login/password. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
5.1 KiB
Go
175 lines
5.1 KiB
Go
package mailbox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Identity is the acting member. AgentMail is a free benefit of membership, so
|
|
// having a registered handle is the only requirement; Paid is retained for
|
|
// tier-aware features (e.g. quotas) but no longer gates access.
|
|
type Identity struct {
|
|
Name string // local-part / handle, e.g. "alice"
|
|
Paid bool // Founding Lifetime Member (informational; does not gate mail)
|
|
}
|
|
|
|
// ErrNotMember is returned when a caller without a registered handle attempts a
|
|
// mail action. AgentMail is open to every verified member.
|
|
var ErrNotMember = errors.New("AgentMail is a member feature — register first: ssh join@bbs.profullstack.com")
|
|
|
|
// Client is the ergonomic, member-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 member-gated client. domain is the email address domain
|
|
// (e.g. bbs.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@bbs.profullstack.com.
|
|
func (c *Client) Address() string { return c.id.Name + "@" + c.domain }
|
|
|
|
func (c *Client) gate() error {
|
|
if c.id.Name == "" {
|
|
return ErrNotMember
|
|
}
|
|
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)
|
|
}
|