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>
This commit is contained in:
Anthony Ettinger 2026-06-23 03:38:31 -07:00 committed by GitHub
parent de5517c000
commit 006235ce92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 725 additions and 164 deletions

View file

@ -7,16 +7,19 @@ import (
"strings"
)
// Identity is the acting member and whether they hold the paid membership.
// 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; mail is gated on this
Paid bool // Founding Lifetime Member (informational; does not gate mail)
}
// 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")
// 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, paid-gated facade the TUI and bot mode use. Every
// 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
@ -25,8 +28,8 @@ type Client struct {
pageSize int
}
// NewClient builds a paid-gated client. domain is the mail domain (e.g.
// mail.profullstack.com); pageSize defaults to 50 when <= 0.
// 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
@ -34,12 +37,12 @@ func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
return &Client{t: t, id: id, domain: domain, pageSize: pageSize}
}
// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com.
// 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 == "" || !c.id.Paid {
return ErrNotPaid
if c.id.Name == "" {
return ErrNotMember
}
return nil
}

View file

@ -24,6 +24,11 @@ type IMAPConfig struct {
// 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
@ -38,7 +43,11 @@ type imapTransport struct {
// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
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)
}

View file

@ -15,7 +15,7 @@ func seeded() *MemoryTransport {
}
func paidClient(t Transport) *Client {
return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50)
return NewClient(t, Identity{Name: "alice", Paid: true}, "bbs.profullstack.com", 50)
}
func TestParseFormatAddress(t *testing.T) {
@ -45,9 +45,15 @@ func TestValidEmailAndDraft(t *testing.T) {
}
func TestGate(t *testing.T) {
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0)
if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) {
t.Fatalf("expected ErrNotPaid, got %v", err)
// A free member (Paid: false) now has full mail access.
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "bbs.profullstack.com", 0)
if _, err := c.Inbox(context.Background(), 0); err != nil {
t.Fatalf("free member should have mail access, got %v", err)
}
// Only a caller without a registered handle is rejected.
anon := NewClient(seeded(), Identity{Name: "", Paid: true}, "bbs.profullstack.com", 0)
if _, err := anon.Inbox(context.Background(), 0); !errors.Is(err, ErrNotMember) {
t.Fatalf("expected ErrNotMember, got %v", err)
}
}
@ -106,7 +112,7 @@ func TestSendAndReply(t *testing.T) {
t.Fatalf("send: %v", err)
}
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" {
if len(sent) != 1 || sent[0].From.Address != "alice@bbs.profullstack.com" || sent[0].Subject != "Hi" {
t.Fatalf("sent: %+v", sent)
}

View file

@ -1,8 +1,9 @@
// Package mailbox is the BBS-side mail client for Founding Lifetime members: a
// transport-agnostic core (read, search, compose, send, flag, delete) with a
// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It
// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at
// mail.profullstack.com / smtp.profullstack.com.
// Package mailbox is the BBS-side mail client for members (a free benefit of
// membership): a transport-agnostic core (read, search, compose, send, flag,
// delete) with a Bubble Tea TUI for humans and a line-oriented JSON mode for
// agents/bots. Addresses are <name>@bbs.profullstack.com; it talks to the
// self-hosted Mailu stack (Dovecot IMAP + Postfix submission) hosted on
// mail.profullstack.com.
//
// The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are
// deliberately the same so tooling can move between them.

197
internal/mailu/mailu.go Normal file
View file

@ -0,0 +1,197 @@
// Package mailu provisions member mailboxes on the self-hosted Mailu stack via
// its admin REST API. Every verified AgentBBS member gets a real mailbox at
// <name>@<domain> (e.g. alice@bbs.profullstack.com); the agentbbs gateway then
// opens it over IMAP with the Dovecot master user, so members never manage an
// IMAP/SMTP password. Mailbox creation is the one thing that must happen up
// front, which is what EnsureUser does (idempotently).
//
// The Mailu admin API listens on the loopback HTTP front (default
// http://127.0.0.1:8080) and authenticates with the token set as API_TOKEN in
// mailu.env. When no token is configured Configured() reports false and callers
// skip provisioning (the address is still shown).
//
// Config (env):
//
// AGENTBBS_MAIL_ADMIN_URL Mailu admin base URL (default http://127.0.0.1:8080)
// AGENTBBS_MAIL_API_TOKEN Mailu API token (from mailu.env API_TOKEN)
// AGENTBBS_MAIL_QUOTA_BYTES per-mailbox quota in bytes (default 1 GiB)
package mailu
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// DefaultQuotaBytes is the per-mailbox storage quota when unset (1 GiB).
const DefaultQuotaBytes = 1 << 30
// Config holds the Mailu admin-API endpoint and credentials.
type Config struct {
BaseURL string
Token string
QuotaBytes int64
HTTP *http.Client
}
// ConfigFromEnv reads the Mailu admin settings from the environment.
func ConfigFromEnv() Config {
q, _ := strconv.ParseInt(os.Getenv("AGENTBBS_MAIL_QUOTA_BYTES"), 10, 64)
if q <= 0 {
q = DefaultQuotaBytes
}
base := os.Getenv("AGENTBBS_MAIL_ADMIN_URL")
if base == "" {
base = "http://127.0.0.1:8080"
}
return Config{
BaseURL: strings.TrimRight(base, "/"),
Token: os.Getenv("AGENTBBS_MAIL_API_TOKEN"),
QuotaBytes: q,
HTTP: &http.Client{Timeout: 15 * time.Second},
}
}
// Client talks to the Mailu admin REST API.
type Client struct {
cfg Config
}
// New builds a client. NewFromEnv is the usual entry point.
func New(cfg Config) *Client {
if cfg.HTTP == nil {
cfg.HTTP = &http.Client{Timeout: 15 * time.Second}
}
if cfg.QuotaBytes <= 0 {
cfg.QuotaBytes = DefaultQuotaBytes
}
return &Client{cfg: cfg}
}
// NewFromEnv builds a client from the environment.
func NewFromEnv() *Client { return New(ConfigFromEnv()) }
// Configured reports whether mailboxes can actually be provisioned.
func (c *Client) Configured() bool {
return c != nil && c.cfg.Token != "" && c.cfg.BaseURL != ""
}
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+"/api/v1"+path, rdr)
if err != nil {
return nil, err
}
// Mailu authenticates the admin API with the raw token in Authorization.
req.Header.Set("Authorization", c.cfg.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.cfg.HTTP.Do(req)
}
// UserExists reports whether email already has a mailbox.
func (c *Client) UserExists(ctx context.Context, email string) (bool, error) {
resp, err := c.do(ctx, http.MethodGet, "/user/"+email, nil)
if err != nil {
return false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
switch {
case resp.StatusCode == http.StatusOK:
return true, nil
case resp.StatusCode == http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("mailu user lookup %s: %s", email, resp.Status)
}
}
// EnsureUser creates email@... if it doesn't exist. It is idempotent: an
// existing mailbox (or an "already exists" create response) is success. The
// generated password is unused by members — the gateway master user opens every
// mailbox — but Mailu requires one at creation time.
func (c *Client) EnsureUser(ctx context.Context, localPart, domain string) error {
if !c.Configured() {
return fmt.Errorf("mailu not configured")
}
email := localPart + "@" + domain
exists, err := c.UserExists(ctx, email)
if err != nil {
return err
}
if exists {
return nil
}
pw, err := randomPassword()
if err != nil {
return err
}
payload := map[string]any{
"email": email,
"raw_password": pw,
"comment": "agentbbs member",
"quota_bytes": c.cfg.QuotaBytes,
"enabled": true,
}
resp, err := c.do(ctx, http.MethodPost, "/user", payload)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
// A concurrent create / pre-existing mailbox is fine.
if resp.StatusCode == http.StatusConflict ||
strings.Contains(strings.ToLower(string(b)), "already exists") {
return nil
}
return fmt.Errorf("mailu create user %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b)))
}
// SetPassword sets the mailbox password (so the member can log into webmail).
// The gateway opens mailboxes via the Dovecot master user and never needs this,
// but webmail (Roundcube) requires the member to have a known password.
func (c *Client) SetPassword(ctx context.Context, localPart, domain, password string) error {
if !c.Configured() {
return fmt.Errorf("mailu not configured")
}
email := localPart + "@" + domain
resp, err := c.do(ctx, http.MethodPatch, "/user/"+email, map[string]any{"raw_password": password})
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("mailu set password %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b)))
}
func randomPassword() (string, error) {
var b [24]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}

View file

@ -0,0 +1,124 @@
package mailu
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestConfigured(t *testing.T) {
if New(Config{BaseURL: "http://x"}).Configured() {
t.Fatal("no token should be unconfigured")
}
if !New(Config{BaseURL: "http://x", Token: "tok"}).Configured() {
t.Fatal("token should be configured")
}
var nilc *Client
if nilc.Configured() {
t.Fatal("nil client must be unconfigured")
}
}
func TestEnsureUserCreatesWhenMissing(t *testing.T) {
var created map[string]any
var sawToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawToken = r.Header.Get("Authorization")
switch {
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/user/"):
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/user":
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &created)
w.WriteHeader(http.StatusOK)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
c := New(Config{BaseURL: srv.URL, Token: "secret-tok"})
if err := c.EnsureUser(context.Background(), "alice", "bbs.profullstack.com"); err != nil {
t.Fatal(err)
}
if sawToken != "secret-tok" {
t.Fatalf("token header = %q", sawToken)
}
if created["email"] != "alice@bbs.profullstack.com" {
t.Fatalf("created email = %v", created["email"])
}
if created["raw_password"] == nil || created["raw_password"] == "" {
t.Fatal("expected a generated password")
}
}
func TestEnsureUserIdempotentWhenExists(t *testing.T) {
posted := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
return
}
posted = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(Config{BaseURL: srv.URL, Token: "t"})
if err := c.EnsureUser(context.Background(), "bob", "bbs.profullstack.com"); err != nil {
t.Fatal(err)
}
if posted {
t.Fatal("should not POST when the mailbox already exists")
}
}
func TestEnsureUserConflictIsSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusConflict)
_, _ = io.WriteString(w, `{"message":"already exists"}`)
}))
defer srv.Close()
c := New(Config{BaseURL: srv.URL, Token: "t"})
if err := c.EnsureUser(context.Background(), "carol", "bbs.profullstack.com"); err != nil {
t.Fatalf("conflict should be treated as success, got %v", err)
}
}
func TestEnsureUserUnconfigured(t *testing.T) {
if err := New(Config{}).EnsureUser(context.Background(), "x", "y"); err == nil {
t.Fatal("expected error when unconfigured")
}
}
func TestSetPassword(t *testing.T) {
var method, path string
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method, path = r.Method, r.URL.Path
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(Config{BaseURL: srv.URL, Token: "t"})
if err := c.SetPassword(context.Background(), "alice", "bbs.profullstack.com", "hunter2"); err != nil {
t.Fatal(err)
}
if method != http.MethodPatch || path != "/api/v1/user/alice@bbs.profullstack.com" {
t.Fatalf("got %s %s", method, path)
}
if body["raw_password"] != "hunter2" {
t.Fatalf("raw_password = %v", body["raw_password"])
}
}