mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-14 06:47: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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue