# CRAiD HTML E-Mail Design

Email-safe HTML templates for transactional CRAiD mails — approval flows, status updates, confirmations. Built table-first, inline-styled, no external assets, DSGVO-clean.

---

## Available templates

| Template               | File                                                                   | Purpose                                                              |
| ---------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Task-Done · Single · DE | [`templates/task-done-1button.de.html`](./templates/task-done-1button.de.html) + `.de.txt` | Doro pings about **one** finished task. One click marks it done. Internal, Doro-voice. |
| Task-Done · Single · EN | [`templates/task-done-1button.en.html`](./templates/task-done-1button.en.html) + `.en.txt` | Same, English. Own voice line, not a literal translation. |
| Task-Done · Digest · DE | [`templates/task-done-digest.de.html`](./templates/task-done-digest.de.html) + `.de.txt` | Multiple tasks bundled in one mail. Each task has its own Done button. Loop block. |
| Task-Done · Digest · EN | [`templates/task-done-digest.en.html`](./templates/task-done-digest.en.html) + `.en.txt` | Same, English. |
| System-Mail · Shell DE  | [`templates/system-mail.de.html`](./templates/system-mail.de.html) + `.de.txt` | Generic shell for transactional crons (briefings, audits, digests). Body composed from [`components.js`](./components.js). |
| System-Mail · Shell EN  | [`templates/system-mail.en.html`](./templates/system-mail.en.html) + `.en.txt` | Same, English. |
| Approval DE             | [`templates/approval-de.html`](./templates/approval-de.html) | Recipient approves or declines an external request. Two opposed buttons, formal voice. |
| Approval EN             | [`templates/approval-en.html`](./templates/approval-en.html) | Same, English. |

Each HTML template has a **plain-text twin** (`.txt`) at the same path. Send both as `multipart/alternative` — Spam filters punish HTML-only mail, and a clean text fallback is the cheapest deliverability win.

Browser preview with filled-in sample data: [`index.html`](./index.html).

---

## Variables

Placeholders are `{{TOKENS}}`. Replace before send (string replace is fine — these don't appear anywhere else in the file).

### Task-Done · Single template

| Token                       | Example                                            | Notes                                                  |
| --------------------------- | -------------------------------------------------- | ------------------------------------------------------ |
| `{{RECIPIENT_FIRST_NAME}}`  | `Daniel`                                           | First name only. Doro-voice expects familiarity.      |
| `{{TASK_TITLE}}`            | `Notion-Page rebrief'd — Q3 Roadmap`               | Headline. Short, declarative.                          |
| `{{TASK_SUMMARY}}`          | `Brandon hat …`                                   | Body, 1–3 sentences, Doro-voice. Backend-filled.       |
| `{{TASK_CONTEXT}}`          | `<a href="https://notion.so/…">In Notion öffnen</a> · 11. Mai 2026` | Optional. Empty string hides the line. Inline HTML allowed. |
| `{{DONE_URL}}`              | `https://forms.craid.de/api/task-action?t=<token>`   | Signed, single-use, time-boxed token URL.              |
| `{{FOOTER_NOTE}}`           | `Diese Mail kommt vom CRAiD-Backend. Klick markiert intern, kein Auto-Tracking.` | Privacy/disclosure line. Backend can swap per case. |
| `{{SENT_AT}}`               | `11. Mai 2026, 09:42 CET`                          | Human-readable timestamp, footer-visible.              |

### Task-Done · Digest template

Top-level slots fill **once** per mail. The task-row slots fill **N times** — once per task — inside the loop block.

**Top-level (fill once):**

| Token                       | Example                                              | Notes                                                  |
| --------------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
| `{{RECIPIENT_FIRST_NAME}}`  | `Daniel`                                             | First name                                             |
| `{{INTRO_LINE}}`            | `3 Sachen warten auf dich.`                          | Doro-voice intro with count. Backend-filled.           |
| `{{FOOTER_NOTE}}`           | Privacy line                                         | Same as single                                         |
| `{{SENT_AT}}`               | Timestamp                                            | Same as single                                         |

**Loop block (fill once per task):**

| Token                          | Example                                              | Notes                                                  |
| ------------------------------ | ---------------------------------------------------- | ------------------------------------------------------ |
| `{{TASK_TITLE}}`               | `Q3-Roadmap Review`                                  | Short                                                  |
| `{{TASK_ASSIGNEE_NAME}}`       | `Daniel`                                             | Who has to do the task (assignee, not requester)       |
| `{{TASK_ASSIGNEE_AVATAR_URL}}` | `https://cdn.sanity.io/images/v2qn2ahe/production/…` | 32×32px or larger PNG/JPG. Backend always supplies a real URL — for users without a stored avatar, backend pre-generates and uploads an initials placeholder. |
| `{{TASK_SUMMARY}}`             | `Brandon hat die fünf Initiativen mit Budget …`     | 1–2 sentences                                          |
| `{{TASK_CONTEXT}}`             | `<a href="https://notion.so/…">In Notion öffnen</a> · 11. Mai 2026` | Optional. Empty collapses cleanly. |
| `{{DONE_URL}}`                 | `https://forms.craid.de/api/task-action?t=<token>`     | One per task — each with its own token                 |

**Loop semantics for the backend:**

The HTML contains an example task block flanked by HTML comments:

```html
<!-- TASK_LOOP_BEGIN -->
<table>… one task block, with {{TASK_TITLE}} / {{TASK_SUMMARY}} / {{TASK_CONTEXT}} / {{DONE_URL}} …</table>
<!-- TASK_LOOP_END -->
```

Backend renders by:

1. Extracting everything between `<!-- TASK_LOOP_BEGIN -->` and `<!-- TASK_LOOP_END -->`.
2. For each task in the digest, cloning the block and filling its four placeholders.
3. Concatenating, replacing the original block (markers included) with the joined output.
4. Filling the outer top-level placeholders (`{{INTRO_LINE}}` etc.) the usual way.

Plain-text twin uses the same marker convention:

```
--- TASK_LOOP_BEGIN ---
• {{TASK_TITLE}}
  {{TASK_SUMMARY}}
  ...
--- TASK_LOOP_END ---
```

### System-Mail · shell

Generic transactional shell. The body is composed from [`components.js`](./components.js) and dropped into `{{BODY_HTML}}` (HTML) / `{{BODY_TEXT}}` (plain-text twin).

| Token                       | Example                                              | Notes                                                  |
| --------------------------- | ---------------------------------------------------- | ------------------------------------------------------ |
| `{{SUBJECT}}`               | `Abend-Summary · 11. Mai 2026`                       | `<title>` and inbox subject                            |
| `{{PREHEADER}}`             | `Heute · 14 erledigt, 3 offen.`                      | Hidden inbox preview                                   |
| `{{RECIPIENT_FIRST_NAME}}`  | `Daniel`                                             | First name                                             |
| `{{INTRO_LINE}}`            | `Abend-Summary · 11. Mai`                            | H1                                                     |
| `{{BODY_HTML}}`             | `<div>…</div><table>…</table>`                       | HTML output of `joinBody([...])`                       |
| `{{BODY_TEXT}}`             | `TASKS\n…`                                           | Plain-text twin output                                 |
| `{{FOOTER_NOTE}}`           | `Tägliche System-Mail vom CRAiD-Backend, 20:00 CET.` | Per-cron disclosure                                    |
| `{{SENT_AT}}`               | `11. Mai 2026, 20:00 CET`                            | Footer timestamp                                       |

### Components

`email/components.js` exports composable functions. Each returns `{ html, text }`. Compose with `joinBody([...])`:

```js
import { section, bullet, divider, metricRow, statusBadge, personRow,
         callout, linkButton, progressBar, keyValueTable, joinBody }
  from '@craid/design-system/email/components.js'
import { crewAvatarUrl, initialAvatarDataUri } from '@craid/design-system/email/initial-avatar.js'

const body = joinBody([
  section({ eyebrow: 'Tasks', title: 'Heute offen', items: [
    bullet({ icon: '◉', label: 'Q3-Roadmap', link: '…', sub: 'fällig heute' }),
  ]}),
  divider(),
  metricRow({ label: 'Tasks erledigt', value: '14', delta: '+5', deltaTone: 'ok' }),
  divider(),
  personRow({ avatar: crewAvatarUrl('brandon'), name: 'Brandon', line: '3 Notes' }),
  callout({ tone: 'warn', title: 'Imprint', body: 'Eine Section muss noch durch.' }),
  progressBar({ value: 14, max: 20, label: 'Sprint-Backlog' }),
  linkButton({ label: '▶ Briefing öffnen', url: '…', primary: true }),
])

// body.html → fills {{BODY_HTML}}
// body.text → fills {{BODY_TEXT}}
```

**Components reference:**

| Function                                  | Use                                                  |
| ----------------------------------------- | ---------------------------------------------------- |
| `section({eyebrow, title, items})`        | Section wrapper with overline + H2 + list of items   |
| `divider()`                               | Horizontal hairline between sections                 |
| `taskRow({title, summary, doneUrl, sub?, link?, assignee?})` | **Actionable** task row — Done-circle on left clicks to `doneUrl`, same layout as the Digest pattern. Use whenever the recipient should tick a task off without leaving the mail. |
| `approvalRow({title, body, approveUrl, rejectUrl, sub?, labelApprove?, labelReject?})` | **Actionable** approval card with inline Approve + Decline pill buttons. Lighter than the standalone Approval template — use inside system-mail digests where multiple approvals stack. |
| `bullet({icon, label, link?, sub?})`      | Non-actionable bullet (info-only). Use only when there's no per-item action. |
| `metricRow({label, value, delta?, deltaTone?})` | Stat: label + big number + colored delta-pill |
| `statusBadge({label, tone})`              | Inline pill: `ok` / `alert` / `pending` / `info`     |
| `personRow({avatar, name, line, link?})`  | Avatar + name + one-liner. Reuses initial-avatar.js  |
| `callout({tone, title?, body})`           | Boxed note: `info` / `success` / `warn` / `danger`   |
| `linkButton({label, url, primary?})`      | Pill CTA with MSO/VML fallback                       |
| `progressBar({value, max, label?, tone?})`| Slim HTML/CSS bar (no SVG, email-safe)               |
| `keyValueTable({rows})`                   | Simple two-column key-value list                     |
| `joinBody(parts)`                         | Concatenates components, returns `{html, text}`      |

**Dark-mode flips automatically** — components emit the standard override classes (`.bg-card`, `.text-ink`, `.text-mute`, `.text-soft`, `.border-h`, `.badge-*`, `.callout-*`, `.progress-track`) that the shell stylesheet's `@media (prefers-color-scheme: dark)` block targets. No per-component logic needed.

**Reference sample:** [`preview/_build-system-samples.mjs`](./preview/_build-system-samples.mjs) — pure-Node build script that composes a realistic evening-summary (DE) and morning-briefing (EN). Run it with `node email/preview/_build-system-samples.mjs` from the repo root. Output goes to `preview/system-mail-{de,en}-sample.html`.

### Approval templates

| Token                 | Example                                                                       | Notes                                                  |
| --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------ |
| `{{SUBJECT}}`         | `Freigabe benötigt: Q3 Roadmap`                                              | Used in `<title>` and shown in inbox                   |
| `{{PREHEADER}}`       | `Daniel hat dir die Q3 Roadmap zur Freigabe geschickt.`                       | Hidden preview text, 80–150 chars                      |
| `{{RECIPIENT_NAME}}`  | `Daniel`                                                                      | First name                                             |
| `{{RECIPIENT_EMAIL}}` | `daniel@craid.de`                                                             | Used in the footer disclosure line                     |
| `{{REQUESTER_NAME}}`  | `Maja`                                                                        | Who's asking for sign-off                              |
| `{{ITEM_TITLE}}`      | `Q3 Roadmap — Final v3`                                                       | The thing being approved                               |
| `{{ITEM_DESCRIPTION}}`| `Roadmap mit fünf Initiativen, Budget …`                                      | Short context, 1–3 sentences                           |
| `{{APPROVE_URL}}`     | `https://forms.craid.de/api/approve?t=<token>`                                | One-click approve link                                 |
| `{{REJECT_URL}}`      | `https://forms.craid.de/api/reject?t=<token>`                                 | One-click decline link                                 |
| `{{DETAILS_URL}}`     | `https://www.craid.de/approvals/<id>`                                         | Optional deep-link to the full item                    |
| `{{EXPIRES_AT}}`      | `15. Mai 2026, 18:00 CET`                                                    | Human-readable deadline                                |
| `{{SECURITY_CODE}}`   | `XKLP-9F2A`                                                                   | Last segment of permanent security code, for trust    |

---

## Brand application

The templates inline the CRAiD visual system using literal hex values — no CSS variables (most email clients strip them).

| Token             | Hex       | Used for                                       |
| ----------------- | --------- | ---------------------------------------------- |
| Primary pink      | `#EC16F5` | Overline, primary button bg, links, code chip  |
| Ink               | `#131F22` | Headlines, body emphasis, decline-btn border   |
| Slate (muted)     | `#304953` | Lead paragraph, footer text                    |
| Paper             | `#F6FAFB` | Background, context box bg                     |
| Card              | `#FFFFFF` | Card surface                                   |
| Border (lavender) | `#E4D8F5` | Card border, dividers                          |

Font stack: `'DM Sans', 'Helvetica Neue', Helvetica, Arial, 'Segoe UI', sans-serif`. DM Sans loads only for clients that have it locally (rare); all others get the system fallback. Don't @font-face — most clients strip it and it triples send weight.

---

## Email-safety rules

These templates follow the rules that matter in 2026:

- **Table-based layout** (`role="presentation"` for a11y) — flex/grid still partial.
- **Inline styles only** for visual properties — Gmail strips `<style>` in some views, Outlook strips half of CSS.
- **No external CSS, no @font-face, no @import.** No web fonts. No JS.
- **MSO/VML fallbacks** for Outlook 2007–2019 desktop — without them, buttons render as square left-aligned text.
- **600px max-width** container, centred. Mobile breakpoint at 480px stacks button row.
- **No background images on the body** — most clients ignore them. Use solid fills.
- **No tracking pixels.** CRAiD ships cookie-free everywhere; emails follow the same posture.
- **No data: URIs** — many clients block them. Host images on `craid.de` if you need them.
- **Alt text on every image** if you add one later.
- **Hidden preheader** at the top so the inbox preview line is intentional, not whatever copy happens to lead.
- **Dark mode via `prefers-color-scheme`.** Honoured by Apple Mail, iOS Mail, Outlook for Mac / Web, Gmail iOS+Android. Outlook Desktop and Gmail Web stay light. The override classes (`.bg-paper`, `.bg-card`, `.text-ink`, `.text-mute`, …) sit on every element that needs to flip — inline styles win in light mode, the `@media` block flips them in dark.
- **Plain-text twin** ships beside every HTML template. Send as `multipart/alternative`. Without it Gmail and Outlook 365 dock you spam points.

---

## Avatars (DSGVO-clean, no third-party)

Two sources, picked by the backend per recipient:

**Crew members** → stable PNG on the Design System host:

```
https://design.craid.de/assets/agents/<slug>.png
```

Slugs: `doro`, `brandon`, `carlo`, `maja`, `jason`, `chris`, `pace`, `admin`.

**Everyone else** (Daniel, hires, external) → inline SVG data-URI generated on-the-fly. Zero network request, zero third-party server log — matches the cookie-free posture of the rest of the site. **Do not** use `ui-avatars.com` or similar services for this — they log every render-time IP and break the no-tracker line.

Helper: [`initial-avatar.js`](./initial-avatar.js). Drop-in API:

```js
import { resolveAvatarUrl } from '@craid/design-system/email/initial-avatar.js'

// Crew lookup first, then initials fallback. Always returns a real URL.
const url = resolveAvatarUrl({ slug: 'brandon', name: 'Brandon' })

// Direct paths if you already know the type:
import { crewAvatarUrl, initialAvatarDataUri } from '@craid/design-system/email/initial-avatar.js'
crewAvatarUrl('maja')               // → https://design.craid.de/assets/agents/maja.png
initialAvatarDataUri('Daniel Simon')// → data:image/svg+xml;utf8,<svg…>DS</svg>
```

The data-URI variant uses `image/svg+xml;utf8,` (not base64) — human-readable in mail-client previews, ~30 % smaller than base64. Initials are auto-extracted ("Daniel Simon" → "DS"). Colour is deterministically hashed from the name so the same person always renders with the same hue.

## Voice — when to use which template

CRAiD ships two distinct email voices that live side-by-side in this folder. Don't conflate them.

**Doro-voice** (Task-Done templates). Recipient is internal — crew, Daniel, future hires. Tone is direct, short, slightly dry, no filler, first-name greeting. Doro knows the house; she doesn't introduce herself, she just hands you what's done. Body sentences are filled by the backend at send time — the template doesn't dictate copy, only frame.

**Formal voice** (Approval templates). Recipient is external — a client signing off on a proposal, a partner approving a contract. Tone is professional, neutral, no buzzwords. Still no filler. The Approval template carries explicit context (deadline, security code, audit trail) the recipient may need to forward.

When in doubt: internal pings = Task-Done. External sign-offs = Approval. If the recipient might forward the mail to legal, you want Approval.

## Approval workflow architecture

The HTML email is the surface — the backend lives on `forms.craid.de` (VPS-Claude). Round-trip:

1. **Trigger** — internal CRAiD process or Sanity webhook creates an approval record.
2. **Token** — backend generates a single-use, time-boxed token (HMAC-signed, scoped to the approval ID + action).
3. **Send** — backend fills the template, sends via existing `_mailer.mjs`.
4. **Click** — recipient clicks Approve or Decline. URL goes to `forms.craid.de/api/approve?t=<token>` (or `/reject`).
5. **Verify** — backend validates token, marks record approved/declined, fires Slack notification.
6. **Land** — recipient lands on a CRAiD-branded confirmation page (`/approval/done`).

Backend details (token format, expiry, retry semantics) live with VPS-Claude. See `vps/` in the main repo once committed.

---

## Testing

Before going live with a new template variant:

1. **Browser preview** — open the template directly in Chrome/Safari (substitute placeholders manually).
2. **Send to yourself** at gmail.com, outlook.com (or 365), apple iCloud, GMX/Web.de.
3. **Litmus or Email-on-Acid** if available — they snapshot ~50 client/version combos.
4. **Lighthouse a11y check** on the HTML — colour contrast on buttons, alt text on images.
5. **Plain-text fallback** — when sending, attach a hand-written text version alongside (most ESP libraries do this automatically). Don't ship HTML-only — Spam filters punish that.

---

## Adding a new template

Pattern:

```
email/templates/<name>-<lang>.html
```

Start by copying `approval-de.html`, rename the variables, swap the CTA labels, keep the same scaffolding (logo / card / context box / button row / security note / footer). Resist the urge to "design email more". Email is not the web. The constraints are the design.

When adding a new template type, also:
- Append a row to the "Available templates" table above
- Add a preview block to `index.html`
- Run all five tests in the section above

---

## License & contact

Internal CRAiD design system. For questions: `daniel@craid.de`.
