# Digest email

A `Digest` as an email: the same data and sections (needs you, health, what was done, notes), on EmailLayout's shell and colours, as a whole HTML document. Trends are in words, "Up 12% since last week" or "Not enough data yet: 2 points", since a sparkline's SVG doesn't survive mail clients; links are the app's URLs; agents and people are named as the app names them, and notes are text. Table layout and inline styles, so it survives email clients.

It is server-safe (no `"use client"`, no hooks): render it to a string with `renderDigestEmail` in a route handler, a server action or a worker. For the same email inline on a page, use `DigestEmailPreview`.

- Registry name: `digest-email` (`@candor/digest-email`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/digest-email

## Install

```bash
npx candor-ui@3 add digest-email
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/digest-email`.
It brings in: bidi, digest, email-heading, email-layout, heading-level, tokens.

## Example

```tsx
"use client"

import * as React from "react"
import { DigestEmailPreview, renderDigestEmail, type DigestEmailOptions } from "@/components/candor/digest-email"
import type { DigestData } from "@/components/candor/digest"
import { Button } from "@/components/candor/ui/button"

const planner = { kind: "agent" as const, agent: { id: "planner", name: "Planning agent", role: "planner" } }

const digest: DigestData = {
  title: "Your week",
  period: "6 to 12 November",
  needsYou: [{ id: "n1", title: "Approve the supplier import", detail: "Waiting since Tuesday", href: "https://app.example.com/review/n1" }],
  health: [
    { id: "h1", label: "Runs finished", value: 128, points: [96, 104, 99, 115, 121, 128], change: { value: 0.12, format: "percent", period: "since last week" } },
    { id: "h2", label: "Reruns", value: 5, points: [7, 5], change: { value: -2, period: "since last week" } },
    { id: "h3", label: "Hours saved", value: null, missing: "Not measured until the timer is on.", points: [] },
  ],
  done: [
    { id: "d1", title: "Drafted the quarterly summary", by: planner, href: "https://app.example.com/docs/summary" },
    { id: "d2", title: "Signed off the plan", by: { kind: "person", name: "Dana Reyes" } },
  ],
  notes: [{ id: "g1", name: "Northwind", notes: [{ id: "x1", text: "The supplier's file arrived a day late; nothing downstream moved.", by: planner }] }],
}

const options: DigestEmailOptions = {
  brand: "Keel",
  why: "You get this every Monday. Change it in Settings, Notifications.",
  link: { label: "Open your week", href: "https://app.example.com/week" },
}

export default function Example() {
  const [frame, setFrame] = React.useState(false)
  // renderDigestEmail's string goes to your mailer, from a route handler or a worker. DigestEmailPreview shows the same
  // email inline; the iframe shows the exact document.
  const html = React.useMemo(() => renderDigestEmail(digest, options), [])
  return (
    <div className="flex max-w-xl flex-col gap-3">
      <DigestEmailPreview digest={digest} {...options} className="border" />
      <Button size="sm" variant="outline" className="self-start" aria-expanded={frame} onClick={() => setFrame((o) => !o)}>
        {frame ? "Hide the HTML document" : "Show the HTML document"}
      </Button>
      {frame && <iframe title="Email document" srcDoc={html} sandbox="" className="block h-[30rem] w-full rounded-xl border bg-white" />}
    </div>
  )
}
```

## <DigestEmail>

Props:

- `brand: string`: The product name shown at the top.
- `why: string`: Why the person got this, e.g. "You get this every Monday. Change it in Settings."
- `preheader?: string | undefined`: Shown in the inbox preview. Defaults to the digest's period, else its title.
- `link?: { label: string; href: string } | undefined`: One button at the end, to the digest in the app: its words and the app's URL.
- `minPoints?: number | undefined`: The fewest numbers a health row's points need before its change is written, at least 2. Defaults to 4, as Digest's.
- `colors?: EmailColors | undefined`: Fixed hex colours to match your brand. Defaults to EmailLayout's, the default theme's light values.
- `strings?: Partial<Words> | undefined`: The email's fixed words, e.g. `{ health: "Santé" }`. Passed directly: emails render outside any LocaleProvider.
- `lang?: string | undefined`: The email's language, for `<html lang>`, numbers and plural forms. Defaults to "en", as is a value that isn't a language tag.
- `digest: DigestData`: What the digest shows: Digest's data.

## <DigestEmailPreview>

The digest email inline, for a settings page or the docs: no `<html>` document, its own fixed colours so it looks as it will in a mail client. Its title is the one heading it adds to your page, at the level your page gives it; the sections' names are drawn as in the email but aren't headings here.

Props:

- `brand: string`: The product name shown at the top.
- `why: string`: Why the person got this, e.g. "You get this every Monday. Change it in Settings."
- `preheader?: string | undefined`: Shown in the inbox preview. Defaults to the digest's period, else its title.
- `link?: { label: string; href: string } | undefined`: One button at the end, to the digest in the app: its words and the app's URL.
- `minPoints?: number | undefined`: The fewest numbers a health row's points need before its change is written, at least 2. Defaults to 4, as Digest's.
- `colors?: EmailColors | undefined`: Fixed hex colours to match your brand. Defaults to EmailLayout's, the default theme's light values.
- `strings?: Partial<Words> | undefined`: The email's fixed words, e.g. `{ health: "Santé" }`. Passed directly: emails render outside any LocaleProvider.
- `lang?: string | undefined`: The email's language, for `<html lang>`, numbers and plural forms. Defaults to "en", as is a value that isn't a language tag.
- `digest: DigestData`: What the digest shows: Digest's data.
- `headingLevel?: HeadingLevel | undefined`: The heading level of the email's title on your page. Default: one below the nearest `HeadingScope`, else 3.
- `label?: string | undefined`: The preview's accessible name. Default: "Email preview: " and the title.
- `className?: string | undefined`: Classes for the preview.

## Functions

### renderDigestEmail

```ts
renderDigestEmail(digest: DigestData, options: DigestEmailOptions)
```

The digest as a complete HTML email, ready for the mailer. It doesn't use react-dom/server, so it runs anywhere: a route handler, a server action, a worker or the browser.

## Strings (`digestEmail`)

The fixed words, English by default. Override any of them under `digestEmail` in `<LocaleProvider strings={{ digestEmail: { … } }}>`; the table is exported as `digestEmailStrings`.

- `needsYou`: Needs you
- `health`: Health
- `done`: What was done
- `notes`: Notes
- `nothing`: Nothing to report for this period.
- `notKnown`: Not known
- `notEnoughData`: { one: "Not enough data yet: {count} point", other: "Not enough data yet: {count} points" }
- `up`: Up {amount} {period}
- `down`: Down {amount} {period}
- `same`: No change {period}
- `things`: { one: "{count} thing", other: "{count} things" }
- `wrote`: Written by {name}
- `preview`: Email preview

## Types

### DigestEmailOptions

How DigestEmail writes a digest: who it's from, why it was sent, the one link out, and its words and colours.

```ts
{
  /** The product name shown at the top. */
  brand: string;
  /** Why the person got this, e.g. "You get this every Monday. Change it in Settings." */
  why: string;
  /** Shown in the inbox preview. Defaults to the digest's period, else its title. */
  preheader?: string | undefined;
  /** One button at the end, to the digest in the app: its words and the app's URL. */
  link?: {
    label: string;
    href: string;
  } | undefined;
  /** The fewest numbers a health row's points need before its change is written, at least 2. Defaults to 4, as Digest's. */
  minPoints?: number | undefined;
  /** Fixed hex colours to match your brand. Defaults to EmailLayout's, the default theme's light values. */
  colors?: EmailColors | undefined;
  /** The email's fixed words, e.g. `{ health: "Santé" }`. Passed directly: emails render outside any LocaleProvider. */
  strings?: Partial<Words> | undefined;
  /** The email's language, for `<html lang>`, numbers and plural forms. Defaults to "en", as is a value that isn't a language tag. */
  lang?: string | undefined;
}
```

## Types from other items

The props above take these. Each is defined by another item; import it from the path shown.

### HeadingLevel

Import: `import type { HeadingLevel } from "@/lib/candor/heading-level"` (item `heading-level`, https://candor.themethodic.com/docs/heading-level.md)

A heading's level in the page's outline: `1` is `<h1>`, `6` is `<h6>`. Every Candor item that draws a heading takes its `headingLevel` as this type; the level is where the heading sits in the outline, never how large it looks.

```ts
1 | 2 | 3 | 4 | 5 | 6
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
