# Notify envelope

> Pure helpers to build a roster from per-channel lists, compare lists, and build a one-message To and Cc envelope.

```tsx
"use client"

import * as React from "react"

import { buildEnvelope } from "@/lib/notify-envelope"
import { Input } from "@/components/ui/input"

const split = (value: string) =>
  value
    .split(/[\s,;]+/)
    .map((item) => item.trim())
    .filter(Boolean)

// Fictional addresses. Nothing here is sent anywhere.
export function NotifyEnvelopeDemo() {
  const [recipients, setRecipients] = React.useState(
    "mara@example.org, jules@example.org, Mara@Example.org"
  )
  const [copied, setCopied] = React.useState(
    "admin@example.org, jules@example.org"
  )
  const envelope = buildEnvelope(split(recipients), split(copied))

  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <div className="flex flex-col gap-2">
        <label htmlFor="envelope-demo-to" className="text-sm font-medium">
          Recipients
        </label>
        <Input
          id="envelope-demo-to"
          value={recipients}
          onChange={(event) => setRecipients(event.target.value)}
        />
      </div>
      <div className="flex flex-col gap-2">
        <label htmlFor="envelope-demo-copied" className="text-sm font-medium">
          Copied
        </label>
        <Input
          id="envelope-demo-copied"
          value={copied}
          onChange={(event) => setCopied(event.target.value)}
        />
      </div>
      <pre
        aria-live="polite"
        className="overflow-x-auto rounded-lg bg-muted p-4 text-sm"
      >
        {JSON.stringify(envelope, null, 2)}
      </pre>
    </div>
  )
}
```

Use it to move between the two shapes a notification list takes: one saved list of addresses per channel, and one roster of people with a switch per channel. It also builds the envelope for a notice sent as a single message.

It has no React, no network and no dependencies, so it is safe to use on a server and to unit test.

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/notify-envelope.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/notify-envelope
```

It has no dependencies.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```ts title="lib/notify-envelope.ts"
/**
 * Pure helpers for the status-and-notify pattern. No network, no React, so they
 * are safe to unit test and to reuse on a server.
 */

/**
 * One person and which channels they are switched on for. Same shape as
 * `RosterPerson` in `recipient-roster`, so the two fit together without a
 * conversion.
 */
export interface RosterPerson {
  email: string
  channels: Record<string, boolean>
}

/** Build a roster from one saved list of addresses per channel. */
export function peopleFromLists(
  lists: Record<string, string[]>,
  channelKeys: string[]
): RosterPerson[] {
  const people = new Map<string, RosterPerson>()
  for (const key of channelKeys) {
    for (const raw of lists[key] ?? []) {
      const email = raw.trim().toLowerCase()
      if (!email) continue
      let person = people.get(email)
      if (!person) {
        person = {
          email,
          channels: Object.fromEntries(channelKeys.map((k) => [k, false])),
        }
        people.set(email, person)
      }
      person.channels[key] = true
    }
  }
  return [...people.values()]
}

/** The addresses switched on for one channel, in roster order. */
export function listForChannel(people: RosterPerson[], key: string): string[] {
  return people.filter((p) => p.channels[key]).map((p) => p.email)
}

const normalize = (list: string[]) =>
  [
    ...new Set(list.map((email) => email.trim().toLowerCase()).filter(Boolean)),
  ].sort()

/**
 * Order-, case- and duplicate-insensitive: neither a reordered list nor
 * "Mara@Example.org" versus "mara@example.org" is a change worth saving.
 */
export function sameList(a: string[], b: string[]): boolean {
  const sortedA = normalize(a)
  const sortedB = normalize(b)
  return (
    sortedA.length === sortedB.length &&
    sortedA.every((email, i) => email === sortedB[i])
  )
}

/**
 * Apply a newly SAVED list for one channel to the roster, leaving every other
 * channel's local (possibly unsaved) switches alone. People who are in the list
 * but not yet on the roster are added.
 */
export function applySavedChannel(
  people: RosterPerson[],
  key: string,
  saved: string[],
  channelKeys: string[]
): RosterPerson[] {
  const wanted = new Set(normalize(saved))
  const next = people.map((person) => ({
    ...person,
    channels: { ...person.channels, [key]: wanted.has(person.email) },
  }))
  for (const email of wanted) {
    if (!next.some((person) => person.email === email)) {
      next.push({
        email,
        channels: Object.fromEntries(channelKeys.map((k) => [k, k === key])),
      })
    }
  }
  return next
}

/**
 * The envelope for a notice sent as ONE message to a small group who are meant
 * to see one another: everyone on `to`, the copied parties on visible `cc` and
 * `replyTo`. An address never appears on both `to` and `cc`.
 *
 * For a list whose members must not learn about each other, send one message
 * per recipient instead and do not use this.
 */
export function buildEnvelope(
  recipients: string[],
  copied: string[]
): { to: string[]; cc: string[]; replyTo: string[] } {
  const seen = new Set<string>()
  const to = recipients
    .map((address) => address.trim())
    .filter(
      (address) =>
        address &&
        !seen.has(address.toLowerCase()) &&
        seen.add(address.toLowerCase())
    )
  const toSet = new Set(to.map((address) => address.toLowerCase()))
  const cleanCopied = copied.map((address) => address.trim()).filter(Boolean)
  return {
    to,
    cc: cleanCopied.filter((address) => !toSet.has(address.toLowerCase())),
    replyTo: cleanCopied,
  }
}
```

**Step 2.** Update the import paths to match your project setup.

## Usage

```tsx
import {
  applySavedChannel,
  buildEnvelope,
  listForChannel,
  peopleFromLists,
  sameList,
} from "@/lib/notify-envelope"
```

```ts
const keys = ["automatic", "onDemand"]

// Saved lists -> one roster, one row per person.
const people = peopleFromLists(saved, keys)

// Roster -> the list for one channel, ready to save.
const list = listForChannel(people, "onDemand")

// Is there anything to save?
const dirty = !sameList(list, saved.onDemand)

// A save landed: follow it, leave the other channel's edits alone.
const next = applySavedChannel(people, "onDemand", saved.onDemand, keys)

// One message, everyone on To, the copied parties on Cc and Reply-To.
const envelope = buildEnvelope(saved.onDemand, ["admin@example.org"])
// { to: [...], cc: ["admin@example.org"], replyTo: ["admin@example.org"] }
```

## Envelope rule

Choose the shape per audience:

- **A small, named group who are meant to see one another:** one message, everyone on `to`, the people you want copied on `cc` and `replyTo`. One send reaches everyone or fails as a whole, so a retry never double-mails half the list. Use `buildEnvelope`.
- **A list whose members must not learn about each other:** one message per recipient. Do not use `buildEnvelope`.

## Notes

- **`buildEnvelope` de-duplicates `to`.** Addresses are trimmed and compared without regard to case, and the first spelling is kept.
- **An address is never on both `to` and `cc`.** Anyone in `to` is dropped from `cc`. They stay in `replyTo`, so replies still reach them.
- **`cc` and `replyTo` are trimmed but not de-duplicated.** Pass a clean copied list.
- **`peopleFromLists` lowercases and trims** every address and merges duplicates across channels. Keys in `lists` that are not in `channelKeys` are ignored.
- **`sameList` ignores order, case and duplicates.** Use it to decide whether there is anything to save.
- **`applySavedChannel` only touches one channel.** It sets that channel's switch for everyone on the roster and adds people who are in the saved list but not yet on it. It does not remove people who end up with every channel off.

## What it does not do

- **It is not a component and sends nothing.** There is no React and no network. These are pure functions that move addresses between shapes and build one envelope. Sending it is your server's job.
- **It does not hide recipients from each other.** `buildEnvelope` puts everyone on `to`, so they all see one another. For a list whose members must not learn about each other, send one message per recipient and do not use it.
- **It does not validate addresses.** It trims, lowercases where it says so, and drops blanks. It does not check that an address is well formed.
- **It does not de-duplicate `cc` and `replyTo`.** Pass a clean copied list.
- **Contacts are email addresses only, and it makes no compliance claim.** There are no phone numbers.

## Reference

### peopleFromLists

`(lists: Record<string, string[]>, channelKeys: string[]) => RosterPerson[]`

Builds a roster from one list of addresses per channel. Every person has a flag for every key in `channelKeys`.

### listForChannel

`(people: RosterPerson[], key: string) => string[]`

The addresses switched on for one channel, in roster order.

### sameList

`(a: string[], b: string[]) => boolean`

True when the two lists hold the same addresses, ignoring order, case, surrounding spaces and duplicates.

### applySavedChannel

`(people: RosterPerson[], key: string, saved: string[], channelKeys: string[]) => RosterPerson[]`

Applies a newly saved list for one channel to the roster.

### buildEnvelope

`(recipients: string[], copied: string[]) => { to: string[]; cc: string[]; replyTo: string[] }`

The envelope for one message to a small group who are meant to see one another.

### RosterPerson

| Field      | Type                      | Description                               |
| ---------- | ------------------------- | ----------------------------------------- |
| `email`    | `string`                  | The person's address.                     |
| `channels` | `Record<string, boolean>` | Whether they are on each channel, by key. |

The same shape is exported by [`recipient-roster`](https://realgood.site/docs/components/recipient-roster.md), so the two fit together without a conversion.

## Agent prompt

Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.

```text
Goal: use the notify-envelope helpers to [keep one saved list of addresses per channel and show them as one roster / send one notice to a small named group].

Install: npx shadcn@latest add https://realgood.site/r/notify-envelope.json
Read the installed files (lib/notify-envelope.ts) before writing any code. Do not guess signatures.

Contract (pure functions, no React, no network):
type RosterPerson = { email: string; channels: Record<string, boolean> }
peopleFromLists(lists: Record<string, string[]>, channelKeys: string[]): RosterPerson[]
listForChannel(people: RosterPerson[], key: string): string[]
sameList(a: string[], b: string[]): boolean  // ignores order, case, spaces, duplicates
applySavedChannel(people: RosterPerson[], key: string, saved: string[], channelKeys: string[]): RosterPerson[]
buildEnvelope(recipients: string[], copied: string[]): { to: string[]; cc: string[]; replyTo: string[] }

Wiring rule: use sameList to decide whether a list is dirty. After a save lands, call applySavedChannel for that one channel and leave the other channel's edits alone. Use buildEnvelope on my SERVER only when one message goes to a small, named group who are meant to see one another. For a list whose members must not learn about each other, send one message per recipient and do not use buildEnvelope. Anyone on `to` is dropped from `cc` but stays on `replyTo`.

Cases to handle: an empty list, the same address on several channels (merged into one person, lowercased), an address in both recipients and copied, blank strings, keys that are not in channelKeys (ignored).

Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. A test: sameList(["A@x.org", " a@x.org "], ["a@x.org"]) is true.
3. A test: buildEnvelope(["a@x.org"], ["a@x.org", "b@x.org"]) gives to ["a@x.org"], cc ["b@x.org"] and replyTo ["a@x.org", "b@x.org"].
4. A test: peopleFromLists merges one address on two channels into one person, with every channel key set.
5. The file imports no React and makes no network call.

Contacts are email addresses only. UI helpers only: they do not send or store anything and make no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.
```

## Examples by sector

Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.

- **Education.** A summary to a small named group, such as the office and the principal on `to` with the registrar on `cc`. A notice to families goes one message per recipient, because they must not see each other.
- **Manufacturing.** A recall notice to three named gauge owners on `to`, with the quality manager on `cc` and `replyTo`.
- **Engineering.** A release notice to the reviewers on `to`, with the document controller on `cc` and `replyTo`.
- **Health.** Staff licence reminders go one message per person, since staff should not learn each other's status, so do not use `buildEnvelope` there. Patient lists are out of scope.

## Next

Comes after: [`confirm-send`](https://realgood.site/docs/components/confirm-send.md). Leads to: [`audit-timeline`](https://realgood.site/docs/components/audit-timeline.md).
