feat(messaging): group & broadcast messages + email-all

Members can now message a group or everyone, and the operator can announce to
the whole membership by email.

Inbox channel (any member):
  - msg@ recipient spec accepts a comma list (alice,bob,carol) or all/*/everyone
    to broadcast: `ssh msg@host alice,bob hi`, `ssh msg@host all hi`.
  - Members hub TUI gains multi-select: space toggles, `a` selects all, `m`
    messages the selected group (header names the audience); selection clears
    after send.
  - store.SendMessageMulti delivers one body to many inboxes in a single
    transaction (dedupes, skips empties); resolveRecipients validates names,
    excludes the sender, and skips banned members on broadcast.

Email channel (operator, explicit):
  - new `agentbbs broadcast` subcommand sends an announcement to ALL members via
    inbox + email. Preview by default (like notify-creds); --send delivers;
    --no-inbox/--no-email pick a channel; --subject/--from/--user refine it.
    Email reaches only verified addresses and refuses --send without SMTP.

Tests: SendMessageMulti (dedupe/empty), resolveRecipients (list/unknown/all
tokens, banned + sender exclusion), TUI selection (toggle/select-all/group
compose). Docs: docs/messaging.md. build/vet/gofmt/`go test ./...` green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-27 12:18:41 +00:00
parent 54da317f4e
commit 103ddeb7c9
8 changed files with 658 additions and 32 deletions

View file

@ -58,6 +58,33 @@ func TestMessagingRoundtrip(t *testing.T) {
}
}
func TestSendMessageMulti(t *testing.T) {
st := openTest(t)
for _, n := range []string{"alice", "bob", "carol"} {
_, _ = st.EnsureUser(n, "member", "SHA256:"+n)
}
// Duplicate + empty recipients are skipped; returns the count actually written.
sent, err := st.SendMessageMulti("alice", []string{"bob", "carol", "bob", ""}, "group hello")
if err != nil {
t.Fatalf("multi: %v", err)
}
if sent != 2 {
t.Fatalf("want 2 inboxes written, got %d", sent)
}
for _, who := range []string{"bob", "carol"} {
in, _ := st.Inbox(who, 10)
if len(in) != 1 || in[0].Body != "group hello" || in[0].From != "alice" {
t.Fatalf("%s inbox = %+v", who, in)
}
}
// Empty recipient list is a no-op.
if sent, err := st.SendMessageMulti("alice", nil, "x"); err != nil || sent != 0 {
t.Fatalf("empty list: sent=%d err=%v", sent, err)
}
}
func TestOnlineUsers(t *testing.T) {
st := openTest(t)