---
path: /messages
surface: customer
domain: discovery
auth: signed-in
source: apps/oshun/web/src/app/messages/page.tsx
status: walked
last_walked:
  '2026-06-27 automated coverage reconciliation by Codex — MessageCenter source,
  shell-state previews, component tests, live browser-proxy Playwright, real BFF
  dispatch/read/archive/unsubscribe/replay coverage, producer notifications from
  Themis and Trust & Safety, profile notification preference handoffs, docs
  center generation, and current interaction states cross-checked.'
---

# Messages

## Purpose

The customer's in-product inbox for notifications, billing notes, support
replies, content updates, incident messages, and producer-originated decisions.
It lists BFF-backed messages with delivery receipts and history, supports
read/archive/deep-link follow actions, adds shell-core filters and local
snooze/topic-stop state, and exposes a "Manage notifications" form for
channel/category unsubscribe and re-subscribe.

## Current producer coverage

- **Direct customer-message dispatch** — `messages-center.spec.ts` seeds the
  real BFF through `POST /v1/admin/customer-messages/dispatch` for the browser's
  access-token subject, renders the rows through `/api/messages/inbox`, follows
  a registered deep link through the real follow proxy, marks read, archives,
  unsubscribes, proves the next matching dispatch is suppressed, re-subscribes,
  and verifies delivery is restored.
- **Customer-message BFF contracts** — `messages-center-bff.spec.ts` exercises
  auth/scope gates, invalid payloads, dispatch to inbox, deep-link follow,
  unknown deep-link rejection, mark-read, archive, unsubscribe/re-subscribe,
  dedupe, cross-user isolation, bounded latest-message snapshots, quiet-hours
  delay/replay, and crisis-frame suppression/bypass.
- **Themis appeal decisions** — `operator-metis-themis-review.spec.ts` resolves
  a live academic-integrity appeal; the BFF publishes an in-app `incident`
  message with subject "Academic integrity appeal decided", verdict id,
  resolution note, authenticated appeal deep link, delivery receipts, clicked
  receipt, and mark-read state.
- **Trust & Safety report decisions** — `user-flag-to-review.spec.ts` records a
  moderator decision and verifies the reporter receives "Your Trust & Safety
  report was reviewed", follows "Open Safety Center" through `/api/messages`,
  lands on `/profile/safety?reportId=<id>`, and reads the clicked receipt back.
- **Profile notification controls** — `profile-notifications-index.spec.ts`,
  `profile-notification-preferences.spec.ts`, and
  `profile-channel-bindings.spec.ts` cover the broader delivery-settings,
  quiet-hours, channel roster, and binding surfaces that `/messages` links to
  conceptually but does not embed.

## Entry points

- **Direct URL / bookmark** — `/messages`; auth required via shell middleware.
- **Shell notification / message affordances** — shell header and notification
  surfaces deep-link here or to message follow targets.
- **Push / email "Read in inbox" deep links** — registered links resolve through
  `/api/messages/<messageId>/follow?url=<encodedUrl>` and then route to the
  BFF-returned internal target.
- **Profile-adjacent navigation** — the route intentionally mounts
  `ShellLayout active="profile"`; notification delivery settings live under
  `/profile/notifications` and `/profile?path=notifications`.
- **Shell state preview** — `?shellState=<mode>` replaces live content with
  `ShellSurfaceStatePreview` for loading, empty, offline, degraded, retry,
  partial-response, partial-outage, and error modes.

## Layout regions

`page.tsx` mounts `ShellLayout` with `active="profile"` and breadcrumbs
`Messages`, then calls
`resolveShellRouteSurfaceContent({ surface: 'messages' })` around
`<MessageCenter />` and `<InProductHelp />`.

Inside `MessageCenter`:

- **Header** — eyebrow "Inbox", H1 "Messages", and
  `<unreadCount> unread of <totalCount>.` once loaded.
- **Summary metrics** (`data-testid="message-center-summary"`) — Visible,
  Unread, Issues, and History from `buildConsumerShellMessageCenter`.
- **Status row** — loading copy, load-error alert, and action-error alert.
- **Filters** (`data-testid="message-filters"`) — Kind, Channel, Status, Search,
  Show snoozed, and Show unsubscribed topics.
- **Message list** (`data-testid="message-list"`) — grouped shell-core message
  rows when any messages match the current filters.
- **Empty cards** — "You have no messages in your inbox." for true empty inbox;
  "No messages match the current filters." for filter-empty results.
- **Manage notifications** — channel/category selects plus Unsubscribe and
  Re-subscribe buttons.
- **InProductHelp** — shell-wide help affordance; the `/messages` pathname maps
  to "About messages" content in `InProductHelp.test.tsx`.

## States

- [x] **Loading** — `load.kind === 'loading'` shows "Loading your messages...".
      Covered by `MessageCenter.test.tsx` with a held inbox request.
- [x] **Idle (pre-refresh internal state)** — initial client state is
      `{ kind: 'idle' }`; no visible idle card is rendered before `refresh()`
      starts. Covered by `MessageCenter.test.tsx` render setup and source audit.
- [x] **Loaded with messages** — non-empty snapshots render summary metrics,
      filters, grouped message rows, unread count, delivery receipts, and
      history. Covered by `messages-center.spec.ts`, `MessageCenter.test.tsx`,
      and `messages-center-bff.spec.ts`.
- [x] **Loaded empty** — zero-message snapshots render "You have no messages in
      your inbox." and no list. Covered by `messages-center.spec.ts` and
      `MessageCenter.test.tsx`.
- [x] **Filtered empty** — loaded inbox with active filters but no matching rows
      renders "No messages match the current filters." Covered by filter
      coverage in `messages-center.spec.ts` and `MessageCenter.test.tsx`.
- [x] **Load error** — non-2xx `/api/messages/inbox` response renders a
      `role="alert"` message such as "Failed to load messages (HTTP 503)."
      Covered by `messages-center.spec.ts`.
- [x] **Action busy** — during mark-read/archive/follow/unsubscribe/resubscribe
      the row and form buttons receive `disabled={action.kind === 'busy'}`.
      Covered by source audit and success/error interaction tests.
- [x] **Action error** — failed mutations render a secondary `role="alert"`
      above the list, e.g. "Mark-read failed (HTTP 500)." Covered by
      `messages-center.spec.ts`.
- [x] **Unread highlight** — unread rows have `data-unread="true"`, an accent
      left border, and an unread meta chip; read rows have `data-unread="false"`
      and no Mark read button. Covered by `messages-center.spec.ts` and
      `MessageCenter.test.tsx`.
- [x] **Snoozed** — Snooze applies a local override, hides the row by default,
      adds "Snoozed until ..." history, and restores it when Show snoozed is
      checked. Covered by `messages-center.spec.ts` and
      `MessageCenter.test.tsx`.
- [x] **Unsubscribed topic** — Stop topic hides optional categories by default,
      adds local history/unsubscribed labeling, and restores rows when Show
      unsubscribed topics is checked. Covered by `messages-center.spec.ts` and
      `MessageCenter.test.tsx`.
- [x] **Required topic** — billing, privacy, support, incident, and system
      category topics are marked required in `CATEGORY_TOPICS`; Stop topic is
      disabled for required topics. Covered by source audit and shell-core model
      behavior.
- [x] **Shell state preview** — `?shellState=` swaps route content for
      `ShellSurfaceStatePreview`, including messages partial-response BFF trace
      data. Covered by `shell-surface-states.spec.ts` and
      `ShellSurfaceStatePreview.test.tsx`.
- [x] **Standalone PWA / offline shell** — this route relies on shared shell PWA
      and offline behavior rather than a messages-specific branch. Shell-state,
      PWA, and offline route specs cover the shared behavior.

## Interactions

### Inbox header and summary

- [x] **Unread summary** — read-only text
      `<unreadCount> unread of <totalCount>.` from the BFF snapshot. Covered by
      `messages-center.spec.ts` and `MessageCenter.test.tsx`.
- [x] **Summary metrics** — read-only Visible / Unread / Issues / History
      metrics derived from `buildConsumerShellMessageCenter`. Covered by the
      live browser-proxy messages spec.

### Filters

- [x] **Kind select** (`data-testid="message-filter-kind"`) — All, System,
      Support, Transactional, Education, Marketing. Covered by
      `messages-center.spec.ts` and `MessageCenter.test.tsx`.
- [x] **Channel select** (`data-testid="message-filter-channel"`) — All, In-app,
      Push, Email, SMS, Webhook; customer `voice` maps to shell-core `webhook`.
      Covered by `messages-center.spec.ts` and `MessageCenter.test.tsx`.
- [x] **Status select** (`data-testid="message-filter-status"`) — All, Active,
      Read, Snoozed. Covered over live dispatched rows in
      `messages-center.spec.ts`.
- [x] **Search input** (`data-testid="message-filter-search"`) — filters by
      message content while retaining the loaded BFF snapshot. Covered over live
      dispatched rows in `messages-center.spec.ts`.
- [x] **Show snoozed** — includes locally snoozed rows. Covered by
      `messages-center.spec.ts` and `MessageCenter.test.tsx`.
- [x] **Show unsubscribed topics** — includes locally stopped-topic rows.
      Covered by `messages-center.spec.ts` and `MessageCenter.test.tsx`.

### Message row

- [x] **Subject + meta** — read-only topic label, kind, status, unread, delivery
      issue, and unsubscribed chips. Covered by `messages-center.spec.ts`.
- [x] **Body** — read-only `message.body`. Covered by route/component tests.
- [x] **Deep links** — normal left-click fetches the follow proxy, records a
      clicked receipt for a registered link, then routes to the returned
      destination; modifier clicks keep native anchor behavior. Covered by
      `messages-center.spec.ts`, `messages-center-bff.spec.ts`,
      `operator-metis-themis-review.spec.ts`, and `user-flag-to-review.spec.ts`.
- [x] **Mark read** — `POST /api/messages/mark-read` with `{ messageId }`,
      refreshes inbox, clears unread state, and removes the button. Covered by
      `messages-center.spec.ts`, `MessageCenter.test.tsx`, and
      `messages-center-bff.spec.ts`.
- [x] **Archive** — `POST /api/messages/archive` with `{ messageId }`, refreshes
      inbox, and removes the row. Covered by `messages-center.spec.ts`,
      `MessageCenter.test.tsx`, and `messages-center-bff.spec.ts`.
- [x] **Snooze** — local override only; hides the row until Show snoozed is
      enabled and appends snooze history. Covered by `messages-center.spec.ts`
      and `MessageCenter.test.tsx`.
- [x] **Stop topic** — optional-topic unsubscribe from the row calls
      `/api/messages/unsubscribe`, hides the row until Show unsubscribed topics
      is enabled, and appends unsubscribe history. Covered by
      `messages-center.spec.ts` and `MessageCenter.test.tsx`.
- [x] **Delivery receipts `<details>`** — expands queued/sent/delivered/opened/
      failed receipt timestamps in ISO format. Covered by
      `messages-center.spec.ts`.
- [x] **History list** — shows created, receipt-derived, read, snoozed, and
      unsubscribed history events. Covered by `messages-center.spec.ts` and
      `MessageCenter.test.tsx`.

### Manage notifications card

- [x] **Channel select** — options: `in-app`, `email`, `push`, `sms`, `voice`.
      Covered by `messages-center.spec.ts` and component tests.
- [x] **Category select** — options: `onboarding`, `milestone`, `reengagement`,
      `billing`, `privacy`, `support`, `incident`, `content-update`, `social`,
      `system`. Covered by `messages-center.spec.ts`.
- [x] **Unsubscribe** — `POST /api/messages/unsubscribe` with channel, category,
      and `reason: 'user-opt-out'`; updates topic state locally, shows no
      success toast, and does not force an inbox refresh. Covered by
      `messages-center.spec.ts`, `MessageCenter.test.tsx`, and
      `messages-center-bff.spec.ts`.
- [x] **Re-subscribe** — `POST /api/messages/resubscribe` with channel and
      category; clears suppression locally and allows later dispatches for that
      channel/category. Covered by `messages-center.spec.ts`,
      `MessageCenter.test.tsx`, and `messages-center-bff.spec.ts`.

### In-product help

- [x] **`InProductHelp`** — route-specific help content appears below the inbox.
      Covered by `InProductHelp.test.tsx`.

## Data & contracts

- **Reads**:
  - Browser route: `GET /api/messages/inbox`.
  - BFF route: `GET /v1/customer/messages/inbox`, returning
    `{ snapshot: CustomerMessageInboxSnapshot }`.
  - Default BFF snapshot returns newest 25 `latestMessages` while preserving
    `totalCount` and `unreadCount`; explicit `limit` returns a smaller newest
    subset.
- **Writes**:
  - `POST /api/messages/mark-read` -> `/v1/customer/messages/mark-read`.
  - `POST /api/messages/archive` -> `/v1/customer/messages/archive`.
  - `POST /api/messages/unsubscribe` -> `/v1/customer/messages/unsubscribe`.
  - `POST /api/messages/resubscribe` -> `/v1/customer/messages/resubscribe`.
  - `GET /api/messages/<messageId>/follow?url=<encodedUrl>` ->
    `/v1/customer/messages/:messageId/follow`.
  - Producer/admin: `POST /v1/admin/customer-messages/dispatch` and
    `POST /v1/admin/customer-messages/replay-due`.
- **Realtime**: _None in the page. Refresh is request-driven after successful
  mark-read/archive mutations; no WS/SSE._
- **Caching**: inbox fetch uses `cache: 'no-store'`; BFF snapshots are
  per-recipient and auth scoped.
- **Auth/role check**: page calls same-origin API routes with session cookies;
  BFF customer routes require a valid bearer; admin dispatch/replay requires
  messaging admin scope.
- **Contracts**:
  - `CustomerMessage`, `CustomerMessageCategory`, `CustomerMessageChannel`,
    `CustomerMessageInboxSnapshot` from `@oshun/contracts`.
  - Shell-core projection via `buildConsumerShellMessageCenter`.

## Automated coverage

- [x] `apps/oshun/web/e2e/messages-center.spec.ts` covers live BFF
      producer-to-browser rendering through `/api/messages`, summary metrics,
      search/status filters, delivery receipts, follow proxy, clicked receipts,
      mark-read, archive, unsubscribe suppression, re-subscribe restoration, and
      component-level route/error/empty/filter/snooze/topic-stop cases.
- [x] `apps/oshun/web/e2e/messages-center-bff.spec.ts` covers the real BFF
      customer-message engine over HTTP: auth/scope gates, validation, dispatch,
      inbox snapshots, follow, unknown links, mark-read, archive, unsubscribe,
      resubscribe, quiet-hours delay/replay, crisis-frame suppression/bypass,
      dedupe, privacy isolation, and bounded snapshots.
- [x] `apps/oshun/web/src/components/__tests__/MessageCenter.test.tsx` covers
      local render/mutation behavior for loading, empty, populated, mark-read,
      archive, follow hrefs, unsubscribe/resubscribe, kind/channel filters,
      history, snooze, and row topic unsubscribe.
- [x] `apps/oshun/web/e2e/shell-surface-states.spec.ts` and
      `apps/oshun/web/src/components/__tests__/ShellSurfaceStatePreview.test.tsx`
      cover route shell-state previews for `/messages`, including
      partial-response BFF trace output.
- [x] `apps/oshun/web/e2e/profile-notifications-index.spec.ts`,
      `profile-notification-preferences.spec.ts`, and
      `profile-channel-bindings.spec.ts` cover the notification settings and
      channel-binding surfaces that remain outside `/messages`.
- [x] `apps/oshun/web/e2e/operator-metis-themis-review.spec.ts` and
      `apps/oshun/web/e2e/user-flag-to-review.spec.ts` cover real incident
      producers that deliver rows into this inbox and follow their links back
      through `/api/messages`.

## Cross-references

- Shell: [`shell/01-app-shell.md`](../../shell/01-app-shell.md)
- Notifications wiring:
  [`shell/05-notifications.md`](../../shell/05-notifications.md)
- Sibling routes:
  - [`home.md`](./home.md), [`activity.md`](./activity.md),
    [`library.md`](./library.md), [`explore.md`](./explore.md),
    [`search.md`](./search.md), [`switcher.md`](./switcher.md),
    [`notifications.md`](./notifications.md)
- Component sources:
  - `apps/oshun/web/src/components/MessageCenter.tsx`
  - `apps/oshun/web/src/components/InProductHelp.tsx`
- BFF and contracts:
  - `apps/oshun/bff/src/routes/customer-message-center.ts`
  - `apps/oshun/bff/src/customer/customer-message-center-store.ts`
  - `libs/oshun/customer-message-center/src/message-center.ts`
  - `libs/contracts/src/common/customer-message-center.ts`
- Feature spec: [`V1/features.md`](../../../V1/features.md)

## Open questions / known gaps

- [x] Push-token enrollment, digest cadence, quiet-hours, and channel bindings
      live under Profile notification surfaces, not inside `/messages`; covered
      by profile notification specs listed above.
- [x] Successful unsubscribe intentionally has no toast and no automatic inbox
      refresh; it updates topic state locally and leaves error alerts absent.
- [x] Back-pressure semantics are bounded by the BFF: newest 25 rows by default,
      with full aggregate counts preserved and explicit lower `limit` supported.
- [x] `/messages` remains profile-adjacent in the shell (`active="profile"`); it
      is not an Activity tab surface in current source.
