Notify envelope
Pure helpers to build a roster from per-channel lists, compare lists, and build a one-message To and Cc envelope.
Build this with your agent
Copy a ready prompt for Claude Code, Cursor or any coding agent.
{
"to": [
"mara@example.org",
"jules@example.org"
],
"cc": [
"admin@example.org"
],
"replyTo": [
"admin@example.org",
"jules@example.org"
]
}"use client"
import * as React from "react"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#
pnpm dlx shadcn@latest add https://realgood.site/r/notify-envelope.json
Or, with the @crisp namespace set up:
pnpm dlx shadcn@latest add @crisp/notify-envelope
It has no dependencies.
Usage#
import {
applySavedChannel,
buildEnvelope,
listForChannel,
peopleFromLists,
sameList,
} from "@/lib/notify-envelope"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 onccandreplyTo. One send reaches everyone or fails as a whole, so a retry never double-mails half the list. UsebuildEnvelope. - A list whose members must not learn about each other: one message per recipient. Do not use
buildEnvelope.
Notes#
buildEnvelopede-duplicatesto. Addresses are trimmed and compared without regard to case, and the first spelling is kept.- An address is never on both
toandcc. Anyone intois dropped fromcc. They stay inreplyTo, so replies still reach them. ccandreplyToare trimmed but not de-duplicated. Pass a clean copied list.peopleFromListslowercases and trims every address and merges duplicates across channels. Keys inliststhat are not inchannelKeysare ignored.sameListignores order, case and duplicates. Use it to decide whether there is anything to save.applySavedChannelonly 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.
buildEnvelopeputs everyone onto, 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
ccandreplyTo. 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, 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.
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
towith the registrar oncc. 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 onccandreplyTo. - Engineering. A release notice to the reviewers on
to, with the document controller onccandreplyTo. - Health. Staff licence reminders go one message per person, since staff should not learn each other's status, so do not use
buildEnvelopethere. Patient lists are out of scope.
Next#
Comes after: confirm-send. Leads to: audit-timeline.