mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
* 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>
150 lines
4.9 KiB
Go
150 lines
4.9 KiB
Go
package mailbox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func seeded() *MemoryTransport {
|
|
m := NewMemoryTransport()
|
|
m.Add(Message{MessageSummary: MessageSummary{UID: 1, Mailbox: Inbox, From: Address{Name: "Carol", Address: "carol@example.com"}, Subject: "Welcome", Date: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)}, Text: "hi alice"})
|
|
m.Add(Message{MessageSummary: MessageSummary{UID: 2, Mailbox: Inbox, From: Address{Address: "deploy@ci.example.com"}, Subject: "Build passed", Date: time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)}, Text: "all green"})
|
|
return m
|
|
}
|
|
|
|
func paidClient(t Transport) *Client {
|
|
return NewClient(t, Identity{Name: "alice", Paid: true}, "bbs.profullstack.com", 50)
|
|
}
|
|
|
|
func TestParseFormatAddress(t *testing.T) {
|
|
a := ParseAddress("Ada Lovelace <ada@x.com>")
|
|
if a.Name != "Ada Lovelace" || a.Address != "ada@x.com" {
|
|
t.Fatalf("parse named: %+v", a)
|
|
}
|
|
if got := ParseAddress("ada@x.com"); got.Address != "ada@x.com" || got.Name != "" {
|
|
t.Fatalf("parse bare: %+v", got)
|
|
}
|
|
if got := FormatAddress(Address{Name: "Doe, John", Address: "j@x.com"}); got != `"Doe, John" <j@x.com>` {
|
|
t.Fatalf("format specials: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestValidEmailAndDraft(t *testing.T) {
|
|
if !ValidEmail("a@b.com") || ValidEmail("nope") {
|
|
t.Fatal("ValidEmail")
|
|
}
|
|
if _, err := NormalizeDraft(Draft{Subject: "x", Text: "y"}); err == nil {
|
|
t.Fatal("expected error for no recipients")
|
|
}
|
|
d, err := NormalizeDraft(Draft{To: []Address{{Address: " a@b.com "}}, Subject: " hi ", Text: "z"})
|
|
if err != nil || len(d.To) != 1 || d.To[0].Address != "a@b.com" || d.Subject != "hi" {
|
|
t.Fatalf("normalize: %+v err=%v", d, err)
|
|
}
|
|
}
|
|
|
|
func TestGate(t *testing.T) {
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Inbox is a thin helper used in tests and bot mode.
|
|
func (c *Client) Inbox(ctx context.Context, limit int) ([]MessageSummary, error) {
|
|
return c.List(ctx, Inbox, limit)
|
|
}
|
|
|
|
func TestListNewestFirst(t *testing.T) {
|
|
c := paidClient(seeded())
|
|
got, err := c.List(context.Background(), Inbox, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 || got[0].UID != 2 {
|
|
t.Fatalf("expected newest first [2,1], got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestReadMarksSeenAndPeek(t *testing.T) {
|
|
tr := seeded()
|
|
c := paidClient(tr)
|
|
if _, ok, _ := c.Read(context.Background(), Inbox, 1, false); !ok {
|
|
t.Fatal("read 1")
|
|
}
|
|
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Seen {
|
|
t.Fatal("uid 1 should be seen")
|
|
}
|
|
if _, _, _ = c.Read(context.Background(), Inbox, 2, true); true {
|
|
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 2); m.Seen {
|
|
t.Fatal("peek must not mark seen")
|
|
}
|
|
}
|
|
if _, ok, _ := c.Read(context.Background(), Inbox, 999, false); ok {
|
|
t.Fatal("unknown uid should be ok=false")
|
|
}
|
|
}
|
|
|
|
func TestSearch(t *testing.T) {
|
|
c := paidClient(seeded())
|
|
hits, _ := c.Search(context.Background(), "green", "", 0)
|
|
if len(hits) != 1 || hits[0].UID != 2 {
|
|
t.Fatalf("search green: %+v", hits)
|
|
}
|
|
hits, _ = c.Search(context.Background(), "carol@example.com", "", 0)
|
|
if len(hits) != 1 || hits[0].UID != 1 {
|
|
t.Fatalf("search sender: %+v", hits)
|
|
}
|
|
}
|
|
|
|
func TestSendAndReply(t *testing.T) {
|
|
tr := seeded()
|
|
c := paidClient(tr)
|
|
res, err := c.Send(context.Background(), Draft{To: []Address{{Address: "carol@example.com"}}, Subject: " Hi ", Text: "yo"})
|
|
if err != nil || res.MessageID == "" {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
|
|
if len(sent) != 1 || sent[0].From.Address != "alice@bbs.profullstack.com" || sent[0].Subject != "Hi" {
|
|
t.Fatalf("sent: %+v", sent)
|
|
}
|
|
|
|
orig, _, _ := c.Read(context.Background(), Inbox, 1, true)
|
|
if _, err := c.Reply(context.Background(), orig, "thanks", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sent, _ = tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
|
|
var re MessageSummary
|
|
for _, s := range sent {
|
|
if s.Subject == "Re: Welcome" {
|
|
re = s
|
|
}
|
|
}
|
|
if re.Subject != "Re: Welcome" || re.To[0].Address != "carol@example.com" {
|
|
t.Fatalf("reply: %+v", sent)
|
|
}
|
|
}
|
|
|
|
func TestFlagAndDelete(t *testing.T) {
|
|
tr := seeded()
|
|
c := paidClient(tr)
|
|
if err := c.Flag(context.Background(), Inbox, 1, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m, _, _ := tr.ReadMessage(context.Background(), Inbox, 1); !m.Flagged {
|
|
t.Fatal("uid 1 should be flagged")
|
|
}
|
|
if err := c.Delete(context.Background(), Inbox, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, ok, _ := tr.ReadMessage(context.Background(), Inbox, 1); ok {
|
|
t.Fatal("uid 1 should be deleted")
|
|
}
|
|
}
|