# Send invitations to many guests at once, from the guest list

## Why

Inviting one guest works (`add-guest-invitation-send`), and inviting a whole audience works
(`/admin/send`, which mails everyone matching an event and a status from a stored template).
The thing a host actually does the week the cards go out sits between them: pick the forty
people whose addresses were just confirmed, look at the two messages that will go out — one
for the guests invited alone, one for the families — fix a word, and send.

Today that means opening forty guest modals, or mailing the entire event. The first is
unusable at 300 guests; the second sends to people the host deliberately left out and gives
no sight of the text before it goes. Neither surface lets a host *see the exact wording both
halves of their list will receive* and approve it in one look, which is the only moment where
a mistake is still cheap.

## What Changes

**Guest list**

- Every guest row gains a **selection checkbox**, with a header checkbox covering the rows on
  screen. When a filter matches more rows than one page shows, a banner offers **select all N
  matching** so a 400-guest wave does not need eight page visits. Selection is cleared
  whenever the filters change, so what is sent is always what was described on screen.
- A **Send invitations** button joins the toolbar beside Export CSV, Import CSV and Add guest.
  It is **disabled until an event is chosen and at least one guest is selected**, with the
  reason on it — the same treatment Import CSV and Add guest already carry, and for the same
  reason: the message text belongs to an event (design D11), so a selection spanning events
  has no single wording to preview.
- The button states how many are selected, and how many of those can actually be emailed.

**Send-invitations modal**

- **Two previews, side by side**: the message for guests whose invitation type is *single*,
  and the message for guests whose type is *family/multiple*. Each is the full message in
  four editable parts — subject, header (`Dear {guest_name}`), body (the event's invitation
  message for that type, then `{invitation_link}`), and footer (`Regards {host_name}`).
- Bodies are seeded from **that event's** invitation messages, resolved event → wedding →
  built-in default exactly as the invitation page and the single-guest panel resolve them.
- The text is a **template, not a finished message**: `{guest_name}` and `{invitation_link}`
  are substituted per recipient at send time, so each guest receives their own name and their
  own tokenised link. A body that no longer contains `{invitation_link}` is refused, and an
  unrecognised placeholder is named and refused rather than delivered literally to 300 people.
- Each pane states how many selected guests it will mail, and a pane with no selected guests
  of its type is shown but visibly inert — the modal always tells the same two-sided story.
- When the selection spans both locales, a locale switch above the panes lets the host author
  the Bangla pair as well; a Bangla guest is never mailed English text they cannot read.
  With a single-locale selection — the ordinary case — there are exactly two panes and no
  switch.
- Guests who cannot be emailed (no address, opted out of this event, hard-bounced) are
  excluded and counted before anything is sent, with the reasons broken out. Nothing in this
  modal can override that.
- One **Send** button at the bottom sends both panes in one action: the single message to the
  selected single guests, the family message to the selected family guests.

**Sending**

- A send is a **batch** of ordinary `message_job` rows — same queue, same worker, same
  delivery and bounce webhooks, same message log — each carrying its own personalised subject
  and body, exactly as a single manual send already does.
- The batch is identified by a client-supplied id that also forms each job's idempotency key,
  so a double-clicked Send or a retried request inserts nothing twice, while a deliberate
  second batch to the same guests is delivered.
- The request returns as soon as the jobs are recorded. The modal then **polls the batch** and
  shows live progress — queued, sent, failed, skipped — so the host watches a 400-guest wave
  land instead of watching a spinner or being told only "queued".
- Quiet hours (22:00–08:00 Asia/Dhaka) warn once, naming the local time, and are overridden
  for the whole batch on explicit confirmation. Automated waves keep deferring.
- New endpoints: `POST /api/admin/events/{id}/invitations/compose` returns both panes'
  defaults and the selection's deliverability breakdown;
  `POST /api/admin/events/{id}/invitations/send-batch` records the batch;
  `GET /api/admin/send-batches/{batch_id}` reports progress.

## Capabilities

### New Capabilities

None — this extends the guest list and the messaging pipeline that already exist.

### Modified Capabilities

- `messaging`: adds a batched manual send — per-invitation-type composition for a whole
  selection, admin-edited templates with per-recipient placeholder substitution, batch
  identity as the idempotency boundary, asynchronous dispatch with progress reporting, one
  quiet-hours decision for a batch, non-overridable suppression counted before sending.
- `guest-management`: the guest list gains row selection, a select-all-matching affordance
  and a toolbar Send-invitations action gated on an event and a non-empty selection.

## Impact

**API**

- `api/app/routers/admin_guests.py` — compose, send-batch and batch-progress routes.
- `api/app/services/messaging.py` — `compose_bulk` (one composition per invitation type),
  placeholder validation and per-recipient rendering, batch enqueue, batch progress query.
  `process_job` is untouched: these are stored-content jobs, which it already handles.
- `api/app/models/messaging.py` + new Alembic revision — `message_job.batch_id`
  (uuid, nullable, indexed).
- Policy: reuses `Action.SEND_MESSAGES`; no new action, no matrix change.
- Audit: one `MESSAGE_SEND` row per batch with its counts, not one per invitation.

**Web**

- `web/components/admin/GuestsView.tsx` — selection state, checkboxes, select-all-matching
  banner, toolbar button.
- `web/components/admin/BulkInviteModal.tsx` (new) — the two panes, the locale switch, the
  exclusion summary, Send and the progress view.
- `web/lib/api/browser.ts`, `web/lib/api/schema.d.ts` — regenerated client (`make client`).

**Docs**

- `docs/USER-GUIDE.md` — sending a wave from the guest list, beside the one-guest path.
- `CLAUDE.md` — batched manual sends and what `batch_id` guarantees.
