# Design — send an invitation to one guest

## Context

See `proposal.md` — Why. What matters technically is the shape of what already exists.

`message_job` **is** the queue (add-rsvp-v1 D3/D5). Every send in the system is a row there,
claimed by the worker with `FOR UPDATE SKIP LOCKED`, made exactly-once by a unique
`idempotency_key` of `invitation_id:schedule_id:channel`. A job stores **no content**: the
sender re-renders from a `message_template` at send time. That is right for waves — the copy
is per purpose, not per guest — and wrong for a message an admin has just hand-edited for one
person, which no template can reproduce afterwards.

The other constraint is the composed body itself. The sentence a guest reads is resolved
through `greeting.resolve(event.invitation_messages, wedding.invitation_messages, …)` by
locale and invitation type (add-event-invitation-card D9/D10, D11's store-on-difference rule).
The frontend already contains a partial mirror of that fallback in `EventMessages.tsx`, for
seeding textareas. Mirroring it a second time, in a place where the result is what a real
guest receives, is how the two copies drift.

Delivery is `providers.get_provider(Channel.EMAIL)` → `ResendEmailProvider`, wrapped by
`DryRunProvider` when `DRY_RUN` is set. No key is configured yet, and this change does not
configure one.

## Goals / Non-Goals

**Goals**

- One manual send is indistinguishable from any other send in the log, the audit trail, the
  webhook path and the suppression rules.
- The admin sees the true outcome of their click, not a queue receipt.
- The invitation sentence is resolved in exactly one place — the API.
- The guest detail modal reads as three separate jobs rather than one wall.

**Non-Goals**

- Provider configuration (Postmark/Resend keys, sender domain, DKIM). Later change.
- Per-host SMTP or sending from the couple's own mailbox. `Reply-To` is the whole identity
  story here.
- Bulk send from the guest list. The bulk path already exists on the send screen.
- Any change to reminder planning, wave audiences or template management.
- Bangla admin UI. The compose panel chrome is English; the *message* follows the guest's
  locale.

## Decisions

### D1 — A manual send is a `message_job`, not a direct provider call

**Decision.** `POST /admin/invitations/{id}/send` inserts a `message_job` and dispatches it in
the same request.

**Why.** The alternative — calling the provider directly and skipping the table — creates a
second source of truth for send state, which CLAUDE.md forbids for exactly this reason. A
directly-sent email has no row for the delivery webhook to update, so it never becomes
`delivered` and a hard bounce never sets `email_invalid`. It would also be invisible in the
message log, which is the screen a host opens when a guest says "I never got it".

### D2 — Per-send idempotency key, `invitation:manual:{nonce}:email`

**Decision.** `messaging.idempotency_key` gains a `manual` form carrying a fresh
`secrets.token_hex(8)`, so every manual send is a distinct key.

**Why.** The existing `direct` key makes the second manual send to a guest a silent no-op —
the row insert conflicts, `enqueue` returns `False`, and the admin sees "sent" while nothing
went out. Silent is the failure mode that matters: an admin resending a lost invitation is a
real, common request. The nonce also flows to the provider's `Idempotency-Key` header, so a
retry of *one* send still collapses vendor-side.

**Alternative rejected.** Keying on `invitation:manual:{utcdate}` — a natural once-per-day
cap, but it fails the "they lost it, send again" case on the same day, which is the case the
feature exists for. Duplicate protection moves to where a human can act on it: the panel
states when this invitation was last emailed (D8).

### D3 — The job carries its own subject and body

**Decision.** Add nullable `subject` and `body_text` to `message_job`. `process_job` uses them
when present and falls back to template rendering when absent, so every existing job path is
untouched.

**Why.** The content is per-send and hand-edited; there is nothing to re-render it from. Two
alternatives were considered and dropped: creating a throwaway `message_template` per send
(pollutes a table the template screen lists, and templates are keyed unique on
channel/purpose/locale), and rendering at request time then passing the text through memory to
the provider (works only because the send is synchronous — a retry after a process restart
would send an empty body).

**Consequence worth stating:** these columns hold guest-visible personal text, including the
guest's name and token URL. The message log already exposes `invitation_id`; it must not start
rendering `body_text` into a list screen, or the log becomes a PII surface.

### D4 — Composition happens on the server

**Decision.** `GET /admin/invitations/{id}/message` returns
`{subject, header, body, footer, invite_url, to_email, blocked_reason, last_sent_at}` —
the parts, already resolved, already in the guest's locale.

**Why.** Resolution is `event → wedding → built-in default`, keyed by locale *and* invitation
type, and deliberately does not fall back across locales (D10 of add-event-invitation-card).
Reproducing that in TypeScript would mean the admin's preview and the guest's invitation page
could disagree about the same sentence. The existing frontend mirror in `EventMessages.tsx`
stays, because it seeds an editor for a value that is *about* to be stored — it never claims
to be what a guest receives.

Composition maps to the parts the request specifies:

| Part | Source |
|---|---|
| subject | `You're invited — {event_title}`, locale-aware |
| header | `Dear {guest_name}` / `প্রিয় {guest_name}` |
| body | `greeting.resolve(...)` for the guest's `invitation_type` + blank line + `invite_url` |
| footer | `Regards {host_name}` where host name is `{bride} & {groom}` |

**Assumption recorded:** there is no host-name field. `wedding.bride_name & groom_name` is the
only name the couple has given the system, and `templating.build_variables` already publishes
it as `couple_names`. The footer is editable, so a host who wants "Regards, the Rahman family"
types it.

### D5 — Quiet hours are warned about, then overridden

**Decision.** `message_job.override_quiet_hours` (boolean, default false). `process_job` skips
`defer_past_quiet_hours` when it is set. The API sets it only when the request arrives with
`confirmed_quiet_hours: true`, and returns `409` with the local time when the send lands in
quiet hours without that flag.

**Why.** Quiet hours protect guests from a 3 a.m. automated wave. A human clicking Send at
23:30 has already made the judgement — deferring silently to 08:00 while reporting "sent"
would be a lie, and reporting "queued for 08:00" makes the feature useless for the case it
exists for ("they're standing here, send it now"). The 409-then-confirm shape puts the
decision in front of the admin instead of in a config file. Automated jobs never set the flag,
so FR-6.9 is unchanged for every wave.

The check is server-side, not a browser `confirm()` deciding on its own: the browser's clock
and timezone are not `Asia/Dhaka`, and the API is the authority on when quiet hours are.

### D6 — Suppression is checked twice and overridable never

**Decision.** The endpoint rejects `do_not_contact`, `email_invalid` and a missing address
with `409` before inserting anything; `process_job`'s existing `_skip_reason` check remains as
the second line.

**Why.** `do_not_contact` is a consent record. Nothing in the admin may override it, including
a confirmation dialog — a dialog that can override consent is a dialog someone will click
through. Checking early gives a clear error; checking again at send time is what catches an
unsubscribe that lands between compose and send.

### D7 — Send synchronously, report the real outcome

**Decision.** The endpoint inserts the job, commits, then awaits `process_job` for that row and
returns its terminal status.

**Why.** Waiting 30 seconds for the worker's poll to learn whether one email went out is not an
answer to a button press, and "queued" is not an outcome an admin can act on. This is the same
reasoning as the existing post-RSVP `BackgroundTask`, taken one step further because here a
human is watching. The row is committed first, so a crash mid-send leaves a `queued`/`sending`
job the worker's stuck-job requeue recovers — the safety net is unchanged.

**Trade-off.** The request now blocks on a provider call (~1 s, 20 s timeout). Acceptable for
a one-off admin action; it is not, and must not become, the bulk path.

### D8 — `last_sent_at` from the job table, not a new column

**Decision.** The compose response derives `last_sent_at` as `max(sent_at)` over that
invitation's `sent` jobs.

**Why.** The data already exists and a denormalised column would need maintaining in the
webhook path too. The query is one indexed lookup on `invitation_id` per compose.

### D9 — `Reply-To` on the send request, `From` unchanged

**Decision.** `providers.SendRequest` gains `reply_to: str | None`; `ResendEmailProvider` maps
it to `reply_to` in the payload; `wedding.host_email` supplies it for every send, not only
manual ones.

**Why.** SPF and DKIM authorise the *sender domain*. Putting the couple's Gmail in `From`
fails authentication and lands invitations in spam — the single worst outcome for this
product. `Reply-To` gets replies to the couple with no deliverability cost. Applying it to all
sends rather than manual ones only means a guest replying to a reminder is not silently
dropped either.

### D10 — Remove the drawer's QR rather than collapse it

**Decision.** Delete the QR block and the `canGenerateQr` prop from `GuestDrawer`. Leave
`QrView` and `downloadUrl.guestQr` untouched.

**Why.** The drawer's job is reaching one guest now; printing is a batch activity that already
has a screen. The endpoint stays because the QR screen uses it — this is a UI removal, not an
API one.

### D11 — Section identity through surface, not colour alone

**Decision.** Each modal section is a bordered block on a tinted surface with an
`<h3>` heading: link section neutral (`stone`), messaging section warm accent, RSVP override
neutral-bordered. Semantic colour stays reserved for what it already means in this admin —
amber for warnings, red for errors, the existing `StatusBadge` palette for RSVP state.

**Why.** "Arrange all sections with proper colour" is satisfied by containment and heading
hierarchy; colour that carries no meaning trains the eye to ignore the colour that does. Every
section is identifiable without perceiving hue, which is also what the accessibility pass will
require.

### D12 — The unsubscribe footer is appended after the admin's edits

**Decision.** `templating.append_unsubscribe` runs on the final assembled body, exactly as it
does for template sends.

**Why.** It is already positioned as the thing a host cannot delete from their own copy. An
edited body is a host's own copy. Nothing changes.

## Risks / Trade-offs

- **A hand-edited body is unreviewable copy going to a real guest.** → The panel shows the
  whole message rather than a diff or a summary, so what is sent is what was read. The
  unsubscribe line is appended regardless (D12), and link length is checked as elsewhere.
- **Repeatable sends make double-sending possible.** → Deliberate (D2), with `last_sent_at`
  stated in the panel and the send control disabled while a send is in flight. Worst case is a
  duplicate invitation, against a failure mode of a guest who never receives one.
- **The quiet-hours override could be reached for habitually.** → It costs a confirmation
  naming the local time, and it exists only on the one-guest path. Waves cannot reach it.
- **`body_text` is PII at rest.** → Same class as `guest.full_name`, in the same database,
  under the same backup. The constraint is that it must not leak into list screens (D3).
- **Synchronous send couples an admin request to a third-party API.** → Bounded by the
  provider's 20 s timeout; a failure is reported to the admin with the job left recorded, not
  swallowed (D7).
- **Guests replying to `Reply-To` reach an inbox nobody watches.** → Host-facing risk, and
  strictly better than replies reaching the platform address. Optional: absent host email
  means no reply address, exactly as today.

## Migration Plan

One additive Alembic revision, no backfill, no downtime:

- `message_job.subject` (text, null), `message_job.body_text` (text, null),
  `message_job.override_quiet_hours` (bool, not null, default false),
  `message_job.sent_by_admin_id` (uuid, null, FK → `admin_user`, `ON DELETE SET NULL`)
- `wedding.host_email` (text, null)

Every existing row reads correctly under the new code: null content means "render from the
template", false override means "defer as before", null host email means "no reply address".
The downgrade drops the columns; the only loss is the stored copy of past manual sends, and
the jobs themselves — with their statuses and provider ids — survive.

Deploy order is the normal one: `make migrate`, then the API, then the web app. The old API
against the new schema simply ignores the columns, so the window between them is safe.

## Open Questions

- Whether a manual send should also be offered from the event's guest list header for a
  selected subset (a "send to these 12" path). Deliberately not designed here — it is the bulk
  path with a different audience filter, and belongs to the send screen if it is wanted.
- Whether `sent_by_admin_id` should surface in the message log UI. The column is recorded now
  because it is free at insert time and impossible to reconstruct later; displaying it can wait
  for someone to ask.
