mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
de5517c000
commit
006235ce92
13 changed files with 725 additions and 164 deletions
197
internal/mailu/mailu.go
Normal file
197
internal/mailu/mailu.go
Normal 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
|
||||
}
|
||||
124
internal/mailu/mailu_test.go
Normal file
124
internal/mailu/mailu_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue