Skip to content
Tier

Digest email

Pro

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.

Markdown
Version 5.4.0· other versions

This page is for 5.4.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0, 4.2.0, 4.3.0, 4.4.0, 4.5.0, 5.0.0, 5.1.0 and 5.2.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/digest-email

Brings in: bidi, digest, email-heading, email-layout, heading-level, tokens

Usage

TSX · The example above
"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>

PropTypeDefaultDescription
brandrequiredstring—The product name shown at the top.
whyrequiredstring—Why the person got this, e.g. "You get this every Monday. Change it in Settings."
preheaderstring | 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.
minPointsnumber | undefined—The fewest numbers a health row's points need before its change is written, at least 2. Defaults to 4, as Digest's.
colorsEmailColors | undefined—Fixed hex colours to match your brand. Defaults to EmailLayout's, the default theme's light values.
stringsPartial<Words> | undefined—The email's fixed words, e.g. `{ health: "Santé" }`. Passed directly: emails render outside any LocaleProvider.
langstring | 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.
digestrequiredDigestData—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.

PropTypeDefaultDescription
brandrequiredstring—The product name shown at the top.
whyrequiredstring—Why the person got this, e.g. "You get this every Monday. Change it in Settings."
preheaderstring | 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.
minPointsnumber | undefined—The fewest numbers a health row's points need before its change is written, at least 2. Defaults to 4, as Digest's.
colorsEmailColors | undefined—Fixed hex colours to match your brand. Defaults to EmailLayout's, the default theme's light values.
stringsPartial<Words> | undefined—The email's fixed words, e.g. `{ health: "Santé" }`. Passed directly: emails render outside any LocaleProvider.
langstring | 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.
digestrequiredDigestData—What the digest shows: Digest's data.
headingLevelHeadingLevel | undefined—The heading level of the email's title on your page. Default: one below the nearest `HeadingScope`, else 3.
labelstring | undefined—The preview's accessible name. Default: "Email preview: " and the title.
classNamestring | undefined—Classes for the preview.

Functions

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

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

KeyEnglish
needsYouNeeds you
healthHealth
doneWhat was done
notesNotes
nothingNothing to report for this period.
notKnownNot known
notEnoughData{ one: "Not enough data yet: {count} point", other: "Not enough data yet: {count} points" }
upUp {amount} {period}
downDown {amount} {period}
sameNo change {period}
things{ one: "{count} thing", other: "{count} things" }
wroteWritten by {name}
previewEmail 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.
{ /** 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 from @/lib/candor/heading-level
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.
1 | 2 | 3 | 4 | 5 | 6