agentbbs/internal/mailbox/imap.go
Anthony Ettinger 006235ce92
Feat/mail all members (#55)
* 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>
2026-06-23 03:38:31 -07:00

345 lines
9.5 KiB
Go

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
// Plaintext dials IMAP without TLS. Used only for a co-located backend over
// loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing
// the front's auth proxy so master-user login works) — the password never
// leaves the host. Never enable it for a remote server.
Plaintext bool
}
// 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) {
dial := imapclient.DialTLS
if cfg.Plaintext {
dial = imapclient.DialInsecure
}
c, err := dial(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)
}