mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +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
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -6,5 +6,11 @@
|
|||
*.log
|
||||
.env
|
||||
|
||||
# Mailu runtime: secrets, local network override, and state
|
||||
deploy/mailu/mailu.env
|
||||
deploy/mailu/docker-compose.override.yml
|
||||
deploy/mailu/data/
|
||||
deploy/mailu/certs/
|
||||
|
||||
# Claude Code local worktrees/state
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -59,11 +60,11 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/calls"
|
||||
"github.com/profullstack/agentbbs/internal/chat"
|
||||
"github.com/profullstack/agentbbs/internal/forgejo"
|
||||
"github.com/profullstack/agentbbs/internal/forwardemail"
|
||||
"github.com/profullstack/agentbbs/internal/games"
|
||||
"github.com/profullstack/agentbbs/internal/hub"
|
||||
"github.com/profullstack/agentbbs/internal/mail"
|
||||
"github.com/profullstack/agentbbs/internal/mailbox"
|
||||
"github.com/profullstack/agentbbs/internal/mailu"
|
||||
"github.com/profullstack/agentbbs/internal/news"
|
||||
"github.com/profullstack/agentbbs/internal/payments"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
|
|
@ -73,9 +74,9 @@ import (
|
|||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/internal/tor"
|
||||
"github.com/profullstack/agentbbs/plugins/about"
|
||||
"github.com/profullstack/agentbbs/plugins/hello"
|
||||
"github.com/profullstack/agentbbs/plugins/agentgames"
|
||||
"github.com/profullstack/agentbbs/plugins/arcade"
|
||||
"github.com/profullstack/agentbbs/plugins/hello"
|
||||
"github.com/profullstack/agentbbs/plugins/members"
|
||||
qryptinviteplugin "github.com/profullstack/agentbbs/plugins/qryptinvite"
|
||||
)
|
||||
|
|
@ -98,21 +99,24 @@ func envInt(k string, def int) int {
|
|||
}
|
||||
|
||||
type app struct {
|
||||
st store.Store
|
||||
pods *pods.Manager // nil when no container engine on host
|
||||
sites *sites.Manager
|
||||
registry []plugin.Plugin
|
||||
sandbox *sandbox.Runner
|
||||
mail mail.Config
|
||||
fe forwardemail.Config // premium @bbs email provisioning
|
||||
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
|
||||
live *liveReg // in-memory live-session registry (admin console)
|
||||
gamesReg *games.Registry // AgentGames catalog
|
||||
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
|
||||
dataDir string
|
||||
assets string
|
||||
host string // public hostname used in user-facing messages
|
||||
newsAddr string // loopback NNTP address the news@ reader dials
|
||||
st store.Store
|
||||
pods *pods.Manager // nil when no container engine on host
|
||||
sites *sites.Manager
|
||||
registry []plugin.Plugin
|
||||
sandbox *sandbox.Runner
|
||||
mail mail.Config
|
||||
mailu *mailu.Client // member mailbox provisioning (nil when unconfigured)
|
||||
mailDomain string // email address domain, e.g. bbs.profullstack.com
|
||||
mailHost string // mail server host (IMAP/SMTP), e.g. mail.profullstack.com
|
||||
webmailURL string // webmail (Roundcube) URL shown to members
|
||||
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
|
||||
live *liveReg // in-memory live-session registry (admin console)
|
||||
gamesReg *games.Registry // AgentGames catalog
|
||||
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
|
||||
dataDir string
|
||||
assets string
|
||||
host string // public hostname used in user-facing messages
|
||||
newsAddr string // loopback NNTP address the news@ reader dials
|
||||
}
|
||||
|
||||
// Version is the agentbbs stack release, surfaced via `agentbbs version` and
|
||||
|
|
@ -155,22 +159,28 @@ func main() {
|
|||
}
|
||||
|
||||
host := env("AGENTBBS_HOST", "bbs.profullstack.com")
|
||||
fe := forwardemail.ConfigFromEnv()
|
||||
if fe.Domain == "" {
|
||||
// Member mailboxes live on a dedicated mail subdomain (mail.profullstack.com),
|
||||
// not the BBS host and not the apex (which is reserved for corporate mail).
|
||||
fe.Domain = env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
// Member email addresses are <name>@<addr-domain> (e.g. bbs.profullstack.com).
|
||||
// The mail server (IMAP/SMTP/webmail) lives on a dedicated host
|
||||
// (mail.profullstack.com); the apex is reserved for corporate mail.
|
||||
mailHost := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
mailDomain := env("AGENTBBS_MAIL_ADDR_DOMAIN", host)
|
||||
mailuClient := mailu.NewFromEnv()
|
||||
if !mailuClient.Configured() {
|
||||
mailuClient = nil
|
||||
}
|
||||
a := &app{
|
||||
st: st,
|
||||
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
|
||||
mail: mail.ConfigFromEnv(),
|
||||
fe: fe,
|
||||
forgejo: forgejo.ConfigFromEnv(),
|
||||
live: newLiveReg(),
|
||||
dataDir: dataDir,
|
||||
assets: env("AGENTBBS_ASSETS", "./assets"),
|
||||
host: host,
|
||||
st: st,
|
||||
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
|
||||
mail: mail.ConfigFromEnv(),
|
||||
mailu: mailuClient,
|
||||
mailDomain: mailDomain,
|
||||
mailHost: mailHost,
|
||||
webmailURL: env("AGENTBBS_WEBMAIL_URL", "https://"+mailHost),
|
||||
forgejo: forgejo.ConfigFromEnv(),
|
||||
live: newLiveReg(),
|
||||
dataDir: dataDir,
|
||||
assets: env("AGENTBBS_ASSETS", "./assets"),
|
||||
host: host,
|
||||
}
|
||||
a.gamesReg = games.Catalog()
|
||||
a.mm = games.NewMatchmaker(a.gamesReg, a.st,
|
||||
|
|
@ -485,19 +495,22 @@ func (a *app) sessionApps(s ssh.Session, su store.User, guest bool) []hub.Sessio
|
|||
Cmd: sessionExec{run: func() error { return a.runNews(s, su.Name) }},
|
||||
})
|
||||
|
||||
// Mail — a Founding Lifetime Member perk: the AgentMail TUI.
|
||||
// Mail — a free benefit of membership: the AgentMail TUI for your
|
||||
// <name>@<mailDomain> mailbox.
|
||||
mailLock := ""
|
||||
switch {
|
||||
case guest:
|
||||
mailLock = membersOnly
|
||||
case !su.Premium:
|
||||
mailLock = "Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@" + a.host
|
||||
case !a.mailEnabled():
|
||||
mailLock = "mail is temporarily unavailable on this host"
|
||||
}
|
||||
apps = append(apps, hub.SessionApp{
|
||||
Title: "Mail",
|
||||
Description: "your " + a.fe.Domain + " mailbox",
|
||||
Description: "your " + a.mailAddress(su.Name) + " mailbox",
|
||||
Locked: mailLock,
|
||||
Cmd: sessionExec{run: func() error {
|
||||
// Make sure the mailbox exists before opening it.
|
||||
_ = a.ensureMailbox(su)
|
||||
c, err := a.mailClientFor(su)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -612,7 +625,7 @@ func (a *app) handleJoin(s ssh.Session) {
|
|||
}, "\n"))
|
||||
|
||||
// 1) email -> emailed code -> enter code. A verified account is a free
|
||||
// member: it gets a Docker pod, IRC/news, and a /~name homepage, all from the hub.
|
||||
// member: it gets a Docker pod, a mailbox, IRC/news, and a /~name homepage.
|
||||
if !u.EmailVerified {
|
||||
if !a.verifyEmailInteractive(s, in, &u) {
|
||||
_ = s.Exit(1)
|
||||
|
|
@ -621,22 +634,41 @@ func (a *app) handleJoin(s ssh.Session) {
|
|||
a.notifySignup(u)
|
||||
}
|
||||
|
||||
// Every verified member gets a homepage at https://<host>/~<name>.
|
||||
// Every verified member gets a homepage at https://<host>/~<name> and a
|
||||
// mailbox at <name>@<mailDomain> (best-effort; mail is a bonus, never a gate).
|
||||
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)
|
||||
_ = a.ensureMailbox(u)
|
||||
// Give them a webmail password so free members can log into webmail. The
|
||||
// in-BBS reader uses the gateway master user and needs no password, but
|
||||
// Roundcube does. (Re)set on each join@; they can change it in webmail.
|
||||
webmailPW := a.setWebmailPassword(u)
|
||||
|
||||
wish.Println(s, "\n"+strings.Join([]string{
|
||||
includes := []string{
|
||||
" You're in. One login gets you everything — no other servers to ssh into:",
|
||||
"",
|
||||
" ssh " + u.Name + "@" + a.host,
|
||||
"",
|
||||
" Inside, free membership includes:",
|
||||
" • your own Linux pod (a full shell)",
|
||||
" • email " + a.mailAddress(u.Name) + " (pick “Mail” in the hub)",
|
||||
" • IRC chat + Usenet/news (members-only)",
|
||||
" • the arcade & games",
|
||||
" • your homepage https://" + a.host + "/~" + u.Name,
|
||||
}, "\n"))
|
||||
}
|
||||
if a.webmailURL != "" && webmailPW != "" {
|
||||
includes = append(includes,
|
||||
"",
|
||||
" Webmail (read your mail in a browser):",
|
||||
" • url "+a.webmailURL,
|
||||
" • login "+a.mailAddress(u.Name),
|
||||
" • password "+webmailPW+" (change it in webmail Settings)",
|
||||
)
|
||||
} else if a.webmailURL != "" {
|
||||
includes = append(includes, " • webmail "+a.webmailURL)
|
||||
}
|
||||
wish.Println(s, "\n"+strings.Join(includes, "\n"))
|
||||
|
||||
// 2) Founding Lifetime ($99 one-time): personal @host email + custom domains.
|
||||
// 2) Founding Lifetime ($99 one-time): custom domains + Tor shell.
|
||||
a.offerPremium(s, &u)
|
||||
_ = s.Exit(0)
|
||||
}
|
||||
|
|
@ -796,10 +828,11 @@ func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.U
|
|||
return false
|
||||
}
|
||||
|
||||
// ensurePremium upgrades *u to premium if its CoinPay charge has settled,
|
||||
// provisioning the member's @host email alias on the transition. It is silent
|
||||
// (no session output) so it is safe to call from the hub. Returns the current
|
||||
// premium state.
|
||||
// ensurePremium upgrades *u to premium if its CoinPay charge has settled. It is
|
||||
// silent (no session output) so it is safe to call from the hub. Returns the
|
||||
// current premium state. Email is no longer a premium perk — every verified
|
||||
// member gets a mailbox (see ensureMailbox) — so this only unlocks custom
|
||||
// domains and the Tor shell.
|
||||
func (a *app) ensurePremium(u *store.User) bool {
|
||||
if u.Premium {
|
||||
return true
|
||||
|
|
@ -816,33 +849,25 @@ func (a *app) ensurePremium(u *store.User) bool {
|
|||
return false
|
||||
}
|
||||
u.Premium = true
|
||||
// Create their <name>@host alias forwarding to the email they verified.
|
||||
if a.fe.Configured() && u.Email != "" {
|
||||
if err := a.fe.CreateAlias(u.Name, u.Email); err != nil {
|
||||
log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// showPremiumWelcome prints a premium member's perks: their mailbox, the webmail
|
||||
// URL, the in-hub Mail/Tor entries, and custom domains.
|
||||
// showPremiumWelcome prints a premium member's perks: custom domains and the
|
||||
// in-hub Tor shell. (Email is free for all members — see the join@ summary.)
|
||||
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
|
||||
lines := []string{
|
||||
"",
|
||||
" ★ Founding Lifetime Member — thanks! Your perks:",
|
||||
" ★ Founding Lifetime Member — thanks! Your bonus perks:",
|
||||
"",
|
||||
" mailbox " + a.fe.Address(u.Name),
|
||||
" webmail https://" + a.fe.Domain,
|
||||
" mail/tor pick “Mail” or “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
|
||||
" domains ssh domain@" + a.host + " add <yourdomain.com>",
|
||||
" tor pick “Tor shell” in the hub: ssh " + u.Name + "@" + a.host,
|
||||
"",
|
||||
}
|
||||
wish.Println(s, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
// offerPremium pitches the $99 Founding Lifetime membership — a personal @host email and
|
||||
// custom domains. When CoinPay can mint a charge in-session it shows the exact
|
||||
// offerPremium pitches the $99 Founding Lifetime membership — custom domains and
|
||||
// the Tor shell. When CoinPay can mint a charge in-session it shows the exact
|
||||
// amount and deposit address; otherwise it falls back to a pay command.
|
||||
// Non-blocking: the member pays out of band and perks unlock on their next
|
||||
// connect (or re-running join@).
|
||||
|
|
@ -859,9 +884,9 @@ func (a *app) offerPremium(s ssh.Session, u *store.User) {
|
|||
" ★ Founding Lifetime Member — $" + payments.PremiumAmount() + ", one-time",
|
||||
" Only the first " + payments.FoundingCap + " accounts. Pay once, keep it for life.",
|
||||
"",
|
||||
" Everything in your free membership stays free — founding adds these",
|
||||
" bonus features, forever:",
|
||||
" • your own mailbox " + a.fe.Address(u.Name) + " (webmail: https://" + a.fe.Domain + ")",
|
||||
" Everything in your free membership stays free — including your",
|
||||
" " + a.mailAddress(u.Name) + " mailbox. Founding adds these bonus",
|
||||
" features, forever:",
|
||||
" • custom domains point yourdomain.com at your homepage",
|
||||
" • Tor a “Tor shell” in your pod — everything over Tor",
|
||||
" • locked-in price founding rate is yours for life — never renew, never pay again",
|
||||
|
|
@ -1301,29 +1326,95 @@ func (a *app) runNews(s ssh.Session, name string) error {
|
|||
return news.RunReader(s, addr, name)
|
||||
}
|
||||
|
||||
// mailClientFor builds a paid-gated AgentMail client for a member, connecting to
|
||||
// the self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
|
||||
// "<name>*<master>") so the BBS gateway can open any member's mailbox with one
|
||||
// secret; SMTP defaults to the co-located relay (no auth). Returns an error if
|
||||
// the IMAP connection/login fails.
|
||||
// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com.
|
||||
func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain }
|
||||
|
||||
// mailEnabled reports whether member mailboxes can be provisioned (Mailu admin
|
||||
// API configured). When false the address is still shown but not created.
|
||||
func (a *app) mailEnabled() bool { return a.mailu.Configured() }
|
||||
|
||||
// ensureMailbox provisions the member's <name>@<mailDomain> mailbox on Mailu if
|
||||
// it doesn't already exist. Idempotent and best-effort: it logs and returns the
|
||||
// error but callers treat mail as a bonus that shouldn't block onboarding. A
|
||||
// no-op when Mailu isn't configured.
|
||||
func (a *app) ensureMailbox(u store.User) error {
|
||||
if !a.mailEnabled() || u.Name == "" {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := a.mailu.EnsureUser(ctx, u.Name, a.mailDomain); err != nil {
|
||||
log.Error("provision mailbox", "err", err, "address", a.mailAddress(u.Name))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setWebmailPassword sets (and returns) a fresh webmail password for the member
|
||||
// so free members can log into webmail. Best-effort: returns "" when Mailu isn't
|
||||
// configured or the API call fails. The in-BBS reader doesn't use this (it goes
|
||||
// through the gateway master user); only webmail needs a member password.
|
||||
func (a *app) setWebmailPassword(u store.User) string {
|
||||
if !a.mailEnabled() || u.Name == "" {
|
||||
return ""
|
||||
}
|
||||
pw := readablePassword()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := a.mailu.SetPassword(ctx, u.Name, a.mailDomain, pw); err != nil {
|
||||
log.Error("set webmail password", "err", err, "address", a.mailAddress(u.Name))
|
||||
return ""
|
||||
}
|
||||
return pw
|
||||
}
|
||||
|
||||
// readablePassword returns a 16-char password from an unambiguous alphabet (no
|
||||
// 0/O/1/l/I) — easy to read off a terminal once and type into webmail.
|
||||
func readablePassword() string {
|
||||
const alphabet = "abcdefghijkmnpqrstuvwxyzACDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// Fall back to a hex token; correctness over readability.
|
||||
var f [12]byte
|
||||
_, _ = rand.Read(f[:])
|
||||
return hex.EncodeToString(f[:])
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b[:])
|
||||
}
|
||||
|
||||
// mailClientFor builds an AgentMail client for a member, connecting to the
|
||||
// self-hosted Mailu backend. IMAP uses Dovecot master-user auth (login
|
||||
// "<addr>*<master>") so the BBS gateway can open any member's mailbox with one
|
||||
// secret; SMTP defaults to the co-located relay (no auth). The client stamps
|
||||
// outgoing mail with the member's <name>@<mailDomain> address. Returns an error
|
||||
// if the IMAP connection/login fails.
|
||||
func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
|
||||
domain := env("AGENTBBS_MAIL_DOMAIN", "mail.profullstack.com")
|
||||
login := su.Name
|
||||
// Mailu keys mailboxes by full address, so the IMAP login (and the master
|
||||
// login "<addr>*<master>") must use the address, not the bare handle.
|
||||
login := a.mailAddress(su.Name)
|
||||
if master := os.Getenv("AGENTBBS_MAIL_MASTER_USER"); master != "" {
|
||||
login = su.Name + "*" + master
|
||||
login = a.mailAddress(su.Name) + "*" + master
|
||||
}
|
||||
cfg := mailbox.IMAPConfig{
|
||||
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", domain+":993"),
|
||||
IMAPAddr: env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993"),
|
||||
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
|
||||
Username: login,
|
||||
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
|
||||
// Mailu's front nginx pre-authenticates against its user DB before
|
||||
// proxying, which rejects the "<addr>*master" master login. The gateway
|
||||
// therefore talks to Dovecot directly over loopback (plaintext, on-host)
|
||||
// when AGENTBBS_MAIL_IMAP_PLAINTEXT=1. See docs/mail.md.
|
||||
Plaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1",
|
||||
// SMTPUser/SMTPPass left empty: submit via the trusted local relay.
|
||||
}
|
||||
tr, err := mailbox.NewIMAPTransport(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, domain, 50), nil
|
||||
return mailbox.NewClient(tr, mailbox.Identity{Name: su.Name, Paid: su.Premium}, a.mailDomain, 50), nil
|
||||
}
|
||||
|
||||
// handleMail routes a Founding Lifetime member into AgentMail: an interactive
|
||||
|
|
@ -1347,11 +1438,18 @@ func (a *app) handleMail(s ssh.Session) {
|
|||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if !a.ensurePremium(&u) {
|
||||
wish.Println(s, " mail is a Founding Lifetime Member feature ($99 one-time). Upgrade: ssh join@"+a.host)
|
||||
if !u.EmailVerified {
|
||||
wish.Println(s, " verify your email first: ssh -t join@"+a.host)
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
if !a.mailEnabled() {
|
||||
wish.Println(s, " mail is temporarily unavailable on this host.")
|
||||
_ = s.Exit(1)
|
||||
return
|
||||
}
|
||||
// Mail is a free benefit of membership — make sure the mailbox exists.
|
||||
_ = a.ensureMailbox(u)
|
||||
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "mail")
|
||||
defer func() { _ = a.st.EndSession(sessID) }()
|
||||
|
||||
|
|
|
|||
36
deploy/mailu/docker-compose.override.yml.example
Normal file
36
deploy/mailu/docker-compose.override.yml.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# docker-compose.override.yml — copy to docker-compose.override.yml (gitignored).
|
||||
# Compose loads this file automatically. It carries three fixes the trimmed base
|
||||
# compose needs; see docs/mail.md for the full rationale.
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
# Mailu trusts SUBNET (mailu.env) as its internal network for
|
||||
# service-to-service auth/relay; the real network MUST match it.
|
||||
- subnet: 192.168.203.0/24
|
||||
services:
|
||||
# Mailu requires a DNSSEC-validating resolver or admin won't start.
|
||||
resolver:
|
||||
image: ghcr.io/mailu/unbound:2024.06
|
||||
env_file: mailu.env
|
||||
restart: always
|
||||
networks:
|
||||
default:
|
||||
ipv4_address: 192.168.203.254
|
||||
front: { dns: [192.168.203.254], depends_on: [resolver] }
|
||||
admin: { dns: [192.168.203.254], depends_on: [resolver] }
|
||||
imap:
|
||||
dns: [192.168.203.254]
|
||||
depends_on: [resolver]
|
||||
# Publish Dovecot directly on loopback so the agentbbs gateway can use the
|
||||
# master-user login (the front's nginx auth proxy rejects "<addr>*master").
|
||||
# Plaintext is fine: the connection never leaves the host.
|
||||
ports: ["127.0.0.1:14143:143"]
|
||||
smtp: { dns: [192.168.203.254], depends_on: [resolver] }
|
||||
antispam: { dns: [192.168.203.254], depends_on: [resolver] }
|
||||
# In Mailu 2024.06 the webmail image is "webmail" (not "roundcube:2024.06").
|
||||
webmail:
|
||||
image: ghcr.io/mailu/webmail:2024.06
|
||||
dns: [192.168.203.254]
|
||||
depends_on: [resolver]
|
||||
|
|
@ -1,15 +1,24 @@
|
|||
# Mailu configuration for mail.profullstack.com — copy to deploy/mailu/mailu.env
|
||||
# and fill the secrets. See docs/mail.md for the full setup (DNS, certs, gateway).
|
||||
# Mailu configuration — copy to deploy/mailu/mailu.env and fill the secrets.
|
||||
# See docs/mail.md for the full setup (DNS, certs, gateway).
|
||||
#
|
||||
# Generate secrets with: openssl rand -hex 16
|
||||
#
|
||||
# NOTE: DOMAIN is the member ADDRESS domain (the @-part); HOSTNAMES is the mail
|
||||
# SERVER host (TLS/HELO + webmail/admin/API). These deliberately differ:
|
||||
# members get <name>@bbs.profullstack.com, served from mail.profullstack.com.
|
||||
|
||||
# --- General -----------------------------------------------------------------
|
||||
SECRET_KEY=CHANGEME_16_HEX # openssl rand -hex 16
|
||||
DOMAIN=mail.profullstack.com # member addresses are <name>@mail.profullstack.com
|
||||
DOMAIN=bbs.profullstack.com # member addresses are <name>@bbs.profullstack.com
|
||||
HOSTNAMES=mail.profullstack.com,smtp.profullstack.com
|
||||
POSTMASTER=postmaster
|
||||
# Apex profullstack.com is reserved for corporate mail and is NOT served here.
|
||||
|
||||
# Admin REST API: agentbbs auto-provisions member mailboxes through it. Mirror
|
||||
# this value into the agentbbs service as AGENTBBS_MAIL_API_TOKEN.
|
||||
API=true
|
||||
API_TOKEN=CHANGEME_api_token # openssl rand -hex 24
|
||||
|
||||
# TLS_FLAVOR=mail: Mailu does NOT run its own ACME (Caddy owns :80/:443). We feed
|
||||
# it certs copied from Caddy's mail.profullstack.com cert (deploy/mailu/refresh-certs.sh).
|
||||
TLS_FLAVOR=mail
|
||||
|
|
@ -32,13 +41,16 @@ MESSAGE_SIZE_LIMIT=52428800 # 50 MB
|
|||
# A Dovecot master user lets the agentbbs gateway open any member's mailbox with
|
||||
# one secret (login "<name>*<master>"). Created by deploy/mailu/provision-mailbox.sh.
|
||||
# Mirror these into the agentbbs service env:
|
||||
# AGENTBBS_MAIL_ADDR_DOMAIN=bbs.profullstack.com
|
||||
# AGENTBBS_MAIL_DOMAIN=mail.profullstack.com
|
||||
# AGENTBBS_MAIL_IMAP_ADDR=mail.profullstack.com:993
|
||||
# AGENTBBS_MAIL_SMTP_ADDR=127.0.0.1:25
|
||||
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080
|
||||
# AGENTBBS_MAIL_API_TOKEN=<the API_TOKEN above>
|
||||
# AGENTBBS_MAIL_MASTER_USER=gateway
|
||||
# AGENTBBS_MAIL_MASTER_PASS=<the master password you set>
|
||||
|
||||
# --- Admin bootstrap ---------------------------------------------------------
|
||||
INITIAL_ADMIN_ACCOUNT=admin
|
||||
INITIAL_ADMIN_DOMAIN=mail.profullstack.com
|
||||
INITIAL_ADMIN_DOMAIN=bbs.profullstack.com
|
||||
INITIAL_ADMIN_PW=CHANGEME_admin_password
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
MAILU_DIR="${MAILU_DIR:-/opt/agentbbs/deploy/mailu}"
|
||||
DOMAIN="${MAIL_DOMAIN:-mail.profullstack.com}"
|
||||
# The address domain (the @-part), which may differ from the mail server host.
|
||||
DOMAIN="${MAIL_ADDR_DOMAIN:-${MAIL_DOMAIN:-bbs.profullstack.com}}"
|
||||
MASTER_USER="${AGENTBBS_MAIL_MASTER_USER:-gateway}"
|
||||
QUOTA_BYTES="${MAIL_QUOTA_BYTES:-1000000000}" # 1 GB
|
||||
|
||||
|
|
|
|||
146
docs/mail.md
146
docs/mail.md
|
|
@ -1,16 +1,22 @@
|
|||
# Mail — self-hosted Mailu at `mail.profullstack.com`
|
||||
# Mail — self-hosted Mailu
|
||||
|
||||
AgentBBS gives **Founding Lifetime (paid) members** a real mailbox at
|
||||
`<name>@mail.profullstack.com`, reached two ways:
|
||||
AgentBBS gives **every verified member** (free and paid alike) a real mailbox at
|
||||
`<name>@bbs.profullstack.com`, reached two ways:
|
||||
|
||||
- **Webmail** — `https://mail.profullstack.com` (Roundcube), the only
|
||||
member-facing mail surface.
|
||||
- **Webmail** — `https://mail.profullstack.com` (Roundcube).
|
||||
- **AgentMail** — the in-BBS client (`internal/mailbox`): the `Mail` hub entry
|
||||
or `ssh mail@bbs.profullstack.com` (a TUI for humans, a JSON bot mode for
|
||||
agents). It connects to this stack.
|
||||
|
||||
Two distinct names are involved — don't conflate them:
|
||||
|
||||
| | value | role |
|
||||
|---|---|---|
|
||||
| **Address domain** | `bbs.profullstack.com` | the `@`-part of member addresses (`AGENTBBS_MAIL_ADDR_DOMAIN`) |
|
||||
| **Mail server host** | `mail.profullstack.com` | where IMAP/SMTP/webmail actually run (`AGENTBBS_MAIL_DOMAIN`) |
|
||||
|
||||
The apex `profullstack.com` is **reserved for corporate mail** and is not served
|
||||
here — member mail lives only on the `mail.` subdomain.
|
||||
here.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -19,97 +25,139 @@ Mailu (Postfix + Dovecot + Roundcube + rspamd) runs as a Docker Compose stack:
|
|||
|
||||
- Mailu owns the **mail ports** on the host: `25, 465, 587, 993, 995`.
|
||||
- Mailu's HTTP front is bound to **loopback** (`127.0.0.1:8080`); **Caddy**
|
||||
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin).
|
||||
reverse-proxies `https://mail.profullstack.com` to it (webmail + admin + API).
|
||||
- **TLS:** `TLS_FLAVOR=mail` — Mailu does *not* run its own ACME (Caddy is the
|
||||
only ACME client). Caddy obtains the `mail.profullstack.com` cert from its site
|
||||
block; [`deploy/mailu/refresh-certs.sh`](../deploy/mailu/refresh-certs.sh)
|
||||
copies it into Mailu and reloads it on renewal — the same pattern as the
|
||||
Ergo/IRC and NNTP cert refreshers.
|
||||
only ACME client). Caddy obtains the `mail.profullstack.com` cert; the cert
|
||||
refresher copies it into Mailu and reloads on renewal.
|
||||
- The **agentbbs gateway** reads/sends on behalf of members: IMAP via a Dovecot
|
||||
**master user** (one secret opens any mailbox), SMTP via the co-located relay
|
||||
on `127.0.0.1:25`. Members therefore never manage an IMAP/SMTP password.
|
||||
- **Provisioning** is automatic: when a member verifies their email at `join@`
|
||||
(or opens `Mail`), agentbbs ensures `<name>@bbs.profullstack.com` exists via
|
||||
Mailu's **admin REST API** (`internal/mailu`, token = `API_TOKEN`). The manual
|
||||
`deploy/mailu/provision-mailbox.sh` is only for the gateway master user and
|
||||
backfills.
|
||||
|
||||
```
|
||||
┌─────────── Caddy (:443) ───────────┐
|
||||
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front, HTTP)
|
||||
webmail → │ mail.profullstack.com → 127.0.0.1:8080 (Mailu front: webmail/admin/API)
|
||||
└───────────────┬─────────────────────┘
|
||||
│ copies LE cert (refresh-certs.sh)
|
||||
clients → Mailu front (:25 :465 :587 :993 :995) ──→ Postfix / Dovecot / rspamd
|
||||
▲
|
||||
agentbbs ──IMAP 993 (master user)──┘ ──SMTP 127.0.0.1:25 (local relay)──▶
|
||||
agentbbs ──admin API (token) http://127.0.0.1:8080/api/v1──▶ (auto-provision)
|
||||
```
|
||||
|
||||
## DNS
|
||||
|
||||
`mail.profullstack.com` and `smtp.profullstack.com` A records are added. Also set:
|
||||
Mail is delivered to the **address domain** (`bbs.profullstack.com`), so its MX
|
||||
must point at the **server host** (`mail.profullstack.com`):
|
||||
|
||||
| Type | Host | Value |
|
||||
|---|---|---|
|
||||
| A | `mail.profullstack.com` | host IP |
|
||||
| A | `smtp.profullstack.com` | host IP |
|
||||
| MX | `mail.profullstack.com` | `10 mail.profullstack.com.` |
|
||||
| TXT (SPF) | `mail.profullstack.com` | `v=spf1 mx -all` |
|
||||
| TXT (DMARC) | `_dmarc.mail.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@mail.profullstack.com` |
|
||||
| TXT (DKIM) | `dkim._domainkey.mail.profullstack.com` | from `flask mailu config-export` after first boot |
|
||||
| MX | `bbs.profullstack.com` | `10 mail.profullstack.com.` |
|
||||
| TXT (SPF) | `bbs.profullstack.com` | `v=spf1 mx -all` |
|
||||
| TXT (DMARC) | `_dmarc.bbs.profullstack.com` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@bbs.profullstack.com` |
|
||||
| TXT (DKIM) | `dkim._domainkey.bbs.profullstack.com` | from `flask mailu config-export` after first boot |
|
||||
| PTR | host IP | `mail.profullstack.com` (set at your VPS provider) |
|
||||
|
||||
> **Port 25 / deliverability:** many cloud providers block outbound `:25` by
|
||||
> default — request an unblock, set the PTR/rDNS, and warm the IP, or relay
|
||||
> outbound through a smarthost. Inbound MX and the gateway's local submission
|
||||
> work regardless.
|
||||
> **Port 25 / deliverability:** many cloud providers (incl. DigitalOcean) block
|
||||
> outbound `:25` by default — request an unblock, set the PTR/rDNS, and warm the
|
||||
> IP, or relay outbound through a smarthost. Inbound MX and the gateway's local
|
||||
> submission work regardless.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd /opt/agentbbs/deploy/mailu
|
||||
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, etc.
|
||||
cp mailu.env.example mailu.env # fill SECRET_KEY, INITIAL_ADMIN_PW, API_TOKEN, DOMAIN=bbs.profullstack.com, HOSTNAMES=mail.profullstack.com
|
||||
docker compose up -d
|
||||
# seed the gateway master user + (optionally) backfill member mailboxes:
|
||||
# add the address domain + the gateway master user:
|
||||
docker compose exec admin flask mailu domain bbs.profullstack.com
|
||||
AGENTBBS_MAIL_MASTER_USER=gateway ./provision-mailbox.sh --master "$(openssl rand -hex 16)"
|
||||
```
|
||||
|
||||
Add the Caddy site (setup.sh writes this when `MAIL=1`):
|
||||
|
||||
```
|
||||
mail.profullstack.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Then install the cert refresher on a timer (setup.sh does this too):
|
||||
|
||||
```bash
|
||||
install -m 0755 deploy/mailu/refresh-certs.sh /usr/local/bin/agentbbs-mailu-certs
|
||||
# systemd timer runs it every ~12h; first run swaps in the real cert once Caddy issues it.
|
||||
```
|
||||
setup.sh writes the Caddy `mail.profullstack.com` site and the cert-refresh
|
||||
timer when `MAIL=1`, and brings the stack up once `mailu.env` exists.
|
||||
|
||||
## agentbbs gateway env
|
||||
|
||||
Set these on the agentbbs service so the `Mail` hub entry / `ssh mail@` work:
|
||||
Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones):
|
||||
|
||||
| Var | Value |
|
||||
|---|---|
|
||||
| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` |
|
||||
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
|
||||
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
|
||||
| `AGENTBBS_MAIL_IMAP_ADDR` | `127.0.0.1:14143` (Dovecot direct, loopback) |
|
||||
| `AGENTBBS_MAIL_IMAP_PLAINTEXT` | `1` (the loopback path is plaintext) |
|
||||
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
|
||||
| `AGENTBBS_MAIL_ADMIN_URL` | `http://127.0.0.1:8080` |
|
||||
| `AGENTBBS_MAIL_API_TOKEN` | the Mailu `API_TOKEN` (secret) |
|
||||
| `AGENTBBS_MAIL_MASTER_USER` | `gateway` |
|
||||
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above |
|
||||
| `AGENTBBS_MAIL_MASTER_PASS` | the master password set above (secret) |
|
||||
| `AGENTBBS_WEBMAIL_URL` | `https://mail.profullstack.com` (default = mail host) |
|
||||
|
||||
Without `AGENTBBS_MAIL_API_TOKEN` auto-provisioning is skipped (the address is
|
||||
still shown); without `AGENTBBS_MAIL_MASTER_PASS` the gateway can't open
|
||||
mailboxes.
|
||||
|
||||
### Why the gateway talks to Dovecot directly (plaintext loopback)
|
||||
|
||||
Mailu's **front** (nginx mail proxy) pre-authenticates every IMAP/SMTP login
|
||||
against Mailu's user DB before proxying to Dovecot — and it rejects the Dovecot
|
||||
master-user login form `<addr>*gateway`. So the gateway must reach **Dovecot
|
||||
directly**, bypassing the front. The `imap` container has no TLS cert (only the
|
||||
front does), so the bypass is plaintext over loopback — safe because the
|
||||
connection (and the master password) never leave the host. Wiring:
|
||||
|
||||
- Publish Dovecot's IMAP on loopback (docker-compose.override.yml):
|
||||
`imap.ports: ["127.0.0.1:14143:143"]`.
|
||||
- The Dovecot master user is defined in `data/overrides/dovecot/dovecot.conf`
|
||||
(Mailu includes exactly that filename — *not* `*.conf`):
|
||||
|
||||
```
|
||||
auth_master_user_separator = *
|
||||
passdb { driver = passwd-file; master = yes; args = /overrides/master-users }
|
||||
```
|
||||
|
||||
with `data/overrides/dovecot/master-users` holding `gateway:{SHA512-CRYPT}$6$…`
|
||||
(the hash of `AGENTBBS_MAIL_MASTER_PASS`). The file must be **world-readable
|
||||
(644)** — Dovecot reads it as a non-root user, and 640 root:root yields a
|
||||
`temp_fail`. Do **not** add `result_success = continue` (that would also
|
||||
require the target user's own password); the target mailbox comes from userdb.
|
||||
- Point the gateway at it: `AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143` +
|
||||
`AGENTBBS_MAIL_IMAP_PLAINTEXT=1`.
|
||||
|
||||
## Sending mail from the BBS (verify codes + notifications)
|
||||
|
||||
The join@ verification code and signup notifications use `internal/mail` (the
|
||||
`AGENTBBS_SMTP_*` knobs), separate from the per-member mailbox client. Point
|
||||
them at the local Mailu relay so codes actually send:
|
||||
|
||||
```
|
||||
AGENTBBS_SMTP_HOST=127.0.0.1
|
||||
AGENTBBS_SMTP_PORT=25
|
||||
AGENTBBS_SMTP_FROM=bbs@bbs.profullstack.com
|
||||
# user/pass omitted: the co-located relay accepts local submission unauthenticated
|
||||
```
|
||||
|
||||
## Provisioning member mailboxes
|
||||
|
||||
A mailbox must exist before the gateway can open it. Provision when a member
|
||||
becomes paid (or backfill):
|
||||
Provisioning is automatic at `join@` verification. To create or backfill by hand:
|
||||
|
||||
```bash
|
||||
deploy/mailu/provision-mailbox.sh alice # creates alice@mail.profullstack.com
|
||||
deploy/mailu/provision-mailbox.sh alice # creates alice@bbs.profullstack.com
|
||||
```
|
||||
|
||||
The Dovecot **master user** (`gateway`) then authenticates as any member with
|
||||
the login form `alice*gateway` + the master password — which is exactly what
|
||||
(Set `MAIL_DOMAIN=bbs.profullstack.com` for the script, since the address domain
|
||||
differs from the server host.)
|
||||
|
||||
The Dovecot **master user** (`gateway`) authenticates as any member with the
|
||||
login form `alice*gateway` + the master password — exactly what
|
||||
`internal/mailbox`'s IMAP adapter sends. See
|
||||
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for the master-user
|
||||
override and operational details.
|
||||
[`deploy/mailu/README.md`](../deploy/mailu/README.md) for details.
|
||||
|
||||
## Webmail only for members
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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"])
|
||||
}
|
||||
}
|
||||
52
setup.sh
52
setup.sh
|
|
@ -368,10 +368,11 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
|||
# AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com
|
||||
|
||||
# Membership model:
|
||||
# Free verified members get their own Docker pod (ssh pod@) and a homepage
|
||||
# at https://${DOMAIN}/~<name>.
|
||||
# Premium \$10 one-time, lifetime — a personal <name>@${DOMAIN} email
|
||||
# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@.
|
||||
# Free verified members get their own Docker pod (ssh pod@), a homepage at
|
||||
# https://${DOMAIN}/~<name>, AND a real mailbox <name>@${DOMAIN} on the
|
||||
# self-hosted Mailu stack (read it in the hub's "Mail" or via webmail).
|
||||
# Premium \$10 one-time, lifetime — custom domains (ssh domain@) + a Tor shell.
|
||||
# Offered at join@.
|
||||
|
||||
# Premium payments hit the CoinPay REST API directly (no coinpay CLI needed):
|
||||
# join@ creates a charge and shows the amount + deposit address; a later connect
|
||||
|
|
@ -385,11 +386,23 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
|||
# AGENTBBS_PREMIUM_CURRENCY=USD
|
||||
# AGENTBBS_PREMIUM_BLOCKCHAIN=eth
|
||||
|
||||
# Premium email aliases (<name>@${DOMAIN}) auto-created on forwardemail.net.
|
||||
# Without an API key the address is shown but not created (add it manually).
|
||||
# AGENTBBS_FORWARDEMAIL_API_KEY=
|
||||
# AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN}
|
||||
# AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN}
|
||||
# Member email (free for every verified member). Addresses are <name>@${DOMAIN}
|
||||
# (the address domain), while the Mailu server lives on the mail host below.
|
||||
# Mailboxes are auto-provisioned at join@ via the Mailu admin REST API: set the
|
||||
# API token (API_TOKEN in deploy/mailu/mailu.env). Without it the address is
|
||||
# shown but not created. See docs/mail.md.
|
||||
# AGENTBBS_MAIL_ADDR_DOMAIN=${DOMAIN} # the @-part of member addresses
|
||||
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080 # Mailu admin (loopback)
|
||||
# AGENTBBS_MAIL_API_TOKEN=<mailu API_TOKEN>
|
||||
# AGENTBBS_MAIL_QUOTA_BYTES=1073741824 # 1 GiB per mailbox
|
||||
# AGENTBBS_WEBMAIL_URL=https://${MAIL_DOMAIN} # Roundcube (defaults to mail host)
|
||||
# The in-BBS mail reader opens mailboxes via a Dovecot master user, reaching
|
||||
# Dovecot directly over loopback (plaintext, on-host) to bypass Mailu's front
|
||||
# auth proxy. §9e sets these; the master pass is a secret (see docs/mail.md):
|
||||
# AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143
|
||||
# AGENTBBS_MAIL_IMAP_PLAINTEXT=1
|
||||
# AGENTBBS_MAIL_MASTER_USER=gateway
|
||||
# AGENTBBS_MAIL_MASTER_PASS=<gateway master password>
|
||||
|
||||
# AgentGit (git.profullstack.com): every verified member — free and paid alike —
|
||||
# is provisioned a Forgejo account when they confirm their email. The admin token
|
||||
|
|
@ -1059,18 +1072,25 @@ else
|
|||
systemctl disable --now forgejo >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ---- 9e. Mailu mail stack (co-located mail.${DOMAIN#*.}) --------------------
|
||||
# ---- 9e. Mailu mail stack (server on ${MAIL_DOMAIN}) ------------------------
|
||||
# Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns
|
||||
# the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert
|
||||
# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of paid members. Full setup,
|
||||
# DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0.
|
||||
# (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of EVERY verified member
|
||||
# (free + paid) — addresses are <name>@${DOMAIN}, the server is ${MAIL_DOMAIN}.
|
||||
# Full setup, DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0.
|
||||
MAILU_DIR="${SRC_DIR}/deploy/mailu"
|
||||
if [ "$MAIL" = "1" ]; then
|
||||
log "configuring Mailu mail stack (${MAIL_DOMAIN})"
|
||||
# Tell agentbbs how to reach the mailbox backend (master user/pass are secrets
|
||||
# the operator sets; see docs/mail.md).
|
||||
log "configuring Mailu mail stack (server ${MAIL_DOMAIN}, addresses @${DOMAIN})"
|
||||
# Tell agentbbs how to reach the mailbox backend (master user/pass + the Mailu
|
||||
# API token are secrets the operator sets; see docs/mail.md).
|
||||
upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}"
|
||||
upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993"
|
||||
upsert_env AGENTBBS_MAIL_ADDR_DOMAIN "${DOMAIN}"
|
||||
# The gateway reads Dovecot DIRECTLY over loopback (docker-compose.override.yml
|
||||
# publishes it on 127.0.0.1:14143), bypassing Mailu's front nginx auth proxy so
|
||||
# the master-user login works. Plaintext is safe — it never leaves the host.
|
||||
# See docs/mail.md ("Why the gateway talks to Dovecot directly").
|
||||
upsert_env AGENTBBS_MAIL_IMAP_ADDR "127.0.0.1:14143"
|
||||
upsert_env AGENTBBS_MAIL_IMAP_PLAINTEXT "1"
|
||||
upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25"
|
||||
|
||||
# Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue