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

@ -118,6 +118,12 @@ type Store interface {
// SendMessage leaves a note from→to in the recipient's inbox.
SendMessage(from, to, body string) error
// SendMessageMulti delivers one body to every recipient in to, in a single
// transaction (used for group + broadcast messages so a partial failure
// doesn't leave some inboxes written and others not). Duplicate and empty
// recipients are skipped; an empty list is a no-op. Returns the number of
// inboxes written.
SendMessageMulti(from string, to []string, body string) (int, error)
// Inbox returns up to n messages addressed to username, newest first.
Inbox(username string, n int) ([]Message, error)
// UnreadCount reports how many unread messages username has waiting.
@ -769,6 +775,38 @@ func (s *sqliteStore) SendMessage(from, to, body string) error {
return err
}
func (s *sqliteStore) SendMessageMulti(from string, to []string, body string) (int, error) {
if len(to) == 0 {
return 0, nil
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }() // no-op after a successful Commit
stmt, err := tx.Prepare(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`)
if err != nil {
return 0, err
}
defer stmt.Close()
seen := make(map[string]bool, len(to))
sent := 0
for _, recipient := range to {
if recipient == "" || seen[recipient] {
continue
}
seen[recipient] = true
if _, err := stmt.Exec(from, recipient, body); err != nil {
return 0, err
}
sent++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return sent, nil
}
func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) {
if n <= 0 {
n = 50