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

83
docs/messaging.md Normal file
View file

@ -0,0 +1,83 @@
# Member messaging — direct, group & broadcast
AgentBBS members leave each other store-and-forward notes (the `messages` table).
Recipients read them in the hub's **Members ▸ inbox**; an unread badge shows on
login. There are two delivery channels:
- **BBS inbox** — in-BBS, members-only. Any member can message one member, a
hand-picked group, or everyone. This is the default.
- **Email** — reaches members in their actual mailbox. This is a separate,
explicit operator action (`agentbbs broadcast`), so an ordinary group message
never fans out to email.
## Inbox messaging — the `msg@` route
```bash
ssh msg@bbs.profullstack.com bob hi there # one member
ssh msg@bbs.profullstack.com alice,bob,carol hi # a group (comma-separated, no spaces)
ssh msg@bbs.profullstack.com all heads up, all # broadcast to every member
echo "long note" | ssh msg@bbs.profullstack.com all # body from stdin
```
The first argument is the **recipient spec**; the rest is the message (or stdin
when omitted). The spec is either a comma-separated list of member names, or one
of `all` / `*` / `everyone` / `@all` to reach everyone. The sender, unknown
names, duplicates, and banned accounts are handled for you: you can't message
yourself, an unknown name aborts the send with a hint, and a broadcast skips
banned members. Requires your registered SSH key (members only).
## Inbox messaging — the hub TUI
In **Members** (the in-hub directory):
| Key | Action |
|---|---|
| `↑/↓` | move the cursor |
| `space` | select / deselect the member under the cursor |
| `a` | select all (press again to clear) |
| `m` | message — the selected group if any, else the member under the cursor |
| `enter` | finger (view) the member |
| `i` | open your inbox |
| `q` | back |
Selected rows show `[x]`; a "`N selected`" line appears once a group is started.
`m` opens a one-line composer whose header names the audience (e.g. `3 members`);
`enter` sends to everyone in the group at once, `esc` cancels. The selection
clears after a successful send.
All inbox writes for a group/broadcast happen in **one transaction**
(`store.SendMessageMulti`), so a partial failure never leaves some inboxes
written and others not.
## Email broadcast — `agentbbs broadcast` (operator)
Reaching every member by **email** (e.g. a maintenance notice) is an operator
command run on the host where the DB and SMTP env live. Like `notify-creds` it is
a **preview by default** and only delivers under `--send`.
```bash
agentbbs broadcast "We're upgrading the box at 0200 UTC." # PREVIEW (sends nothing)
agentbbs broadcast --send "Heads up: brief downtime tonight." # inbox + email to all
echo "long notice" | agentbbs broadcast --send # body on stdin
agentbbs broadcast --send --no-email "BBS-only notice" # inbox channel only
agentbbs broadcast --send --no-inbox --subject "Outage" "..." # email channel only
agentbbs broadcast --send --user alice,bob "targeted note" # only these members
```
| Flag | Effect |
|---|---|
| *(none)* | **Preview** — prints intended deliveries, sends nothing. |
| `--send` | Actually deliver. |
| `--no-inbox` | Skip the BBS inbox channel. |
| `--no-email` | Skip the email channel (inbox-only broadcast). |
| `--from NAME` | Inbox sender name (default `sysop`, or `AGENTBBS_BROADCAST_FROM`). |
| `--subject S` | Email subject (default `Announcement from <host>`). |
| `--user a,b` | Restrict to a comma-separated allow-list (default: all members). |
The **inbox** channel reaches every non-banned member in scope; the **email**
channel reaches only members with a **verified** address (others are skipped).
`--send` refuses the email channel when SMTP is unconfigured
(`AGENTBBS_SMTP_HOST`/`_FROM`) — use `--no-email` for an inbox-only blast. It
prints a per-channel delivered/failed summary and exits non-zero on any email
failure. The email footer tells members how to reply in-BBS
(`ssh msg@<host> sysop <reply>`).