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

@ -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"])
}
}