Every email leaving the monorepo (marketing site, workspace, partners portal, memo) renders
through one package, @matters/email (packages/email). It implements the design spec in
EMAIL_DESIGN.md at the repo
root: a centered 600px column on near black, opening directly on a bright blue panel styled as
a folded, creased sheet of paper with near black type "printed" on it, brief cyan-labelled text
blocks below, and a full compliance footer.
Templates in each app are thin content-model builders. They decide what an email says; the
package decides how it looks, produces the HTML, produces an equivalent text/plain part from
the same model, and appends the footer. Nothing in an app writes email HTML by hand.
Before this, the repo carried three unrelated email stacks: raw template literals with two
diverging copies of a layout helper in the root app, @react-email/components templates on a
light theme in partners, and text-only Resend sends in memo. Four templates shipped with no text
part, none carried the postal address CAN-SPAM requires, and the subscription opt-in was an
unstyled <div>. Consolidating on one renderer fixes all of that structurally: a template cannot
ship without a footer or a text part, because the renderer emits both.
| File | Responsibility |
|---|---|
packages/email/src/tokens.ts | EMAIL_COLORS, EMAIL_FONTS, EMAIL_LAYOUT, BRAND_COLORS (the canonical brand kit), EMAIL_BRAND (name, legal entity, postal address, privacy URL, social links, presenter line) and the inline SVG grain data URI. |
packages/email/src/model.ts | The EmailDocument content model (TypeScript interfaces), the Zod schema, normalizeDocument (empty optional strings mean "absent") and validateEmailDocument (throws ValidationError from @matters/lib listing every failing path). |
packages/email/src/variants/index.ts | The design variant registry: EMAIL_VARIANTS, resolveEmailVariant, defaultEmailVariantId, setDefaultEmailVariant. One entry per design. |
packages/email/src/variants/shared.ts | Chrome shared by every variant: the document shell, dark-mode colour locks, the bulletproof button, and footerParts — the normalised compliance footer content four designs cannot disagree about. |
packages/email/src/variants/folded-paper.ts | The default design (EMAIL_DESIGN.md) and its light twin — one renderer, two FoldedSurround themes, threaded through every poster-internal renderer. |
packages/email/src/variants/editorial-receipt.ts | EMAIL_DESIGN.v2.md. |
packages/email/src/variants/product-launch.ts | EMAIL_DESIGN.v3.md, brand-recoloured. |
packages/email/src/variants/technical-blueprint.ts | EMAIL_DESIGN.v4.md, brand-recoloured. |
packages/email/src/render-text.ts | text/plain renderer from the same model. Never emits markup. |
packages/email/src/render.ts | renderEmail(doc, options?) → { html, text, variant }. The one entry point templates use. |
packages/email/src/calendar.ts | buildCalendarLinks(event) → Google, Outlook, Yahoo compose URLs (moved here from the webinar templates so any app can use them). |
packages/email/src/escape.ts | escapeHtml, escapeHtmlMultiline, htmlToText, firstNameOf. |
lib/email/mail-types.ts | The canonical registry of mail types (MAIL_TYPE_IDS) — what counts as "a kind of email" for the settings panel and the resolver. Replaces two hand-copied lists. |
lib/email/mail-samples.ts | buildSampleMail(mailType, options) — one realistic sample per mail type, rendered through the real builder, for the settings preview. |
lib/schemas/email-design.ts | emailDesignSettingsSchema — a global default plus per-mail-type overrides, validated against the live variant registry. |
lib/services/email-design.service.ts | The settings singleton: getEmailDesignSettings, updateEmailDesignSettings, resolveVariantForMailType, renderOptionsForMailType, revalidateEmailDesign. |
Consumers: root app (package.json), apps/workspace, apps/partners, apps/memo, all via
"@matters/email": "workspace:*". The Next apps list it in transpilePackages; memo's Vite
build transpiles linked workspace packages automatically. The package depends only on zod and
@matters/lib (for ValidationError).
Consumers: root app (package.json), apps/workspace, apps/partners, apps/memo, all via
"@matters/email": "workspace:*". The Next apps list it in transpilePackages; memo's Vite
build transpiles linked workspace packages automatically. The package depends only on zod and
@matters/lib (for ValidationError).
A variant owns the HTML presentation of a document and nothing else. Content, compliance and
the text/plain part are variant-independent, so every registered design carries the same copy,
the same links and the same footer — only the look differs. That invariant is enforced by tests
parameterised over the registry, so a new variant is covered the moment it lands.
| id | Spec | Personality | Type pairing |
|---|---|---|---|
folded-paper | EMAIL_DESIGN.md | Default. Creased bright-blue paper flyer on near black, flat near-black type "printed" on it, one serif italic accent. | Space Grotesk / DM Sans / DM Serif Display |
editorial-receipt | EMAIL_DESIGN.v2.md | Quiet-luxury transactional. Warm paper, condensed all-caps wordmark, serif headline, itemised rows joined to their values by dotted leader lines. | Oswald / Spectral |
product-launch | EMAIL_DESIGN.v3.md | Modern announcement. Heavy headline, a brand-gradient hero card framing a product panel, stacked feature cards, full-width dark closing band. | Sora / Inter |
technical-blueprint | EMAIL_DESIGN.v4.md | Flat and structural. Mosaic blueprint ground, 1px hairline grid, monospaced labels, indexed bento cells, L-shaped corner markers, zero radius, zero shadow. | Space Grotesk / DM Sans / JetBrains Mono |
folded-paper and folded-paper-light are one renderer in one file, not two
designs — a FoldedSurround object parameterises everything outside, and one
thing inside, the folded-paper poster. The paper's own blue, its crease grid
and grain are identical in both; only the surround, and the poster's
printed ink, change:
| Role | folded-paper | folded-paper-light |
|---|---|---|
| Canvas behind the column | #050505 | brand anti-flash-white #f2f6f8 |
| Column surface | #0a0a0a | #ffffff |
| Detail-block copy | white/60 #9d9d9d | black/70 #4d4d4d |
| Footer reason / fine print | white/40 #6b6b6b | black/55 #737373 |
| Legal line | white/30 #545454 | black/45 #8c8c8c |
| Hairlines | #1e1e1e | #e6e6e6 |
| Eyebrows and inline links | brand sky-blue #5ad3e6 | brand blue #211be9 |
| Poster printed ink (inside the paper) | near-black #131316 | brand anti-flash-white #f2f6f8 |
| Poster button fill | near-black #131316 | brand anti-flash-white #f2f6f8 |
The accent had to move. Cyan reaches only 1.77:1 against white, which is unreadable as text; brand blue reaches 8.76:1. Both were measured.
The poster ink is the one place the flyer itself is not byte-identical
between the two surrounds — everything else inside the paper (markup shape,
rotation, the crease grid, the grain, the credits layout) is shared, and a
test normalises the ink out and asserts the rest matches exactly. The dark
surround keeps EMAIL_DESIGN.md's near-black "printed on paper" ink,
unchanged, per spec. The light surround prints in brand anti-flash-white
instead: near-black-on-blue measures only 2.12:1, and shipping that same low
contrast inside an otherwise light-themed email reads as a bug rather than a
deliberate choice. Anti-flash-white on the same blue reaches 7.5 to 9.1:1
depending on the fold shade, comfortably past both the 4.5:1 body-text and
3:1 large-text WCAG thresholds. The poster button's background follows the
same rule (near-black on dark, anti-flash-white on light); its label stays
brand blue on both, since blue reads clearly against either fill.
Both surrounds emit the full dark-mode colour lock set. The light one needs it more, in fact: a client "helpfully" darkening a light email is the common failure, not the reverse.
The v3 and v4 specs shipped with palettes of their own; both were recoloured to
the brand kit. BRAND_COLORS in tokens.ts holds the canonical seven, verified
against tailwind.config.ts, the theme package's shared.css and its
bridge.css, which all agree. The theme package also defines a
--color-non-brand-* set; it is labelled non-brand deliberately, so no email
may use it.
| Spec colour | Replaced with |
|---|---|
v3 light grey body #e9eaee | brand anti-flash-white #f2f6f8 |
v3 ink #161616 | brand black #000000 and its composites |
v3 mist panel #f4f5f7 | brand anti-flash-white #f2f6f8 |
| v3 coral→pink hero gradient | brand blue → brand plum, #211be9 → #f4a4f5 |
v3 cobalt accent #2f5bff | brand blue #211be9 |
| v3 macOS traffic-light dots | brand plum, cream, sky-blue |
v4 paper #f7f7f5 | brand anti-flash-white #f2f6f8 |
v4 forest green #1a3c2b | brand blue #211be9 |
v4 grid #3a3a38 | brand black at 20% / 12%, #c2c5c6 / #d5d8da |
v4 coral #ff8c69 | brand plum #f4a4f5 |
v4 mint #9effbf | brand sky-blue #5ad3e6 |
v4 gold #f4d35e | brand cream #f9fccd |
editorial-receipt deliberately keeps the v2 spec's warm paper and deep navy,
because the recolour brief covered v3 and v4 only. Its palette is one object at
the top of its file, so moving it onto BRAND_COLORS is a one-place edit if
that changes.
Email clients cannot be trusted with rgba, so every opacity step in the specs
is pre-composited against the surface it sits on and shipped as opaque hex. The
composites were computed, not eyeballed, and the three brand accents were
checked by relative luminance to confirm they need black type rather than white.
A variant must render any document, or switching designs would break emails. So each design's anatomy is mapped onto the shared model rather than requiring fields only one design supplies:
| Model field | folded-paper | editorial-receipt | product-launch | technical-blueprint |
|---|---|---|---|---|
flyer.kicker | tracked-caps line on the fold | line under the wordmark | chip inside the hero panel | status badge |
flyer.headline | 60px uppercase, serif italic accent | serif headline, italic accent | 34px Sora, accent in brand blue | 48px Space Grotesk in brand blue |
flyer.overline | rotated italic parenthetical | serif greeting | subhead | mono subtext behind a rule |
flyer.credits | rotated fold cells | itemised rows with dotted leaders | label/value rows in the hero panel | mono metadata grid |
flyer.note | rotated caps line | ship-status subhead | dark closing band's line | line in the corner-marked box |
blocks | 2-up cyan-eyebrow blocks | customer-information grid | stacked feature cards | indexed bento grid |
Three spec elements are deliberately not reproduced, in every case to avoid fabricating content:
mix-blend-luminosity imagery. Email has
no reliable animation, and this system ships no images at all.Also medium-forced: v4's headline scales to 48px rather than 64-96px because the column is 600px wide, its fixed nav becomes a header row, and product thumbnails in v2 become numbered tiles. v4's spec asks for General Sans, which Google Fonts does not host, so it uses DM Sans — the brand body face and the closest available substitute.
Precedence, highest first:
renderEmail(doc, { variant }). Every root template takes an optional trailing
options argument forwarded straight to renderEmail, so one send can use a chosen design
without touching the template: buildReminderEmail(params, { variant }).variant on the EmailDocument, for a template inherently tied to one
design. Leave it unset so the email follows the settings above.EMAIL_DESIGN_VARIANT=<id>. Still honoured, and still the only mechanism for
the partners and memo apps, which do not read the Mongo settings (see below). An unrecognised
value is ignored in favour of folded-paper: a typo must never stop mail from sending.setDefaultEmailVariant(id) once at startup.DEFAULT_EMAIL_VARIANT (folded-paper).Layers 2 and 3 are resolved by resolveVariantForMailType / renderOptionsForMailType in
lib/services/email-design.service.ts, and layers 4 to 7 by renderEmail itself.
Settings → Email Design (/settings?view=email-design) sets one global default and,
independently, an override per mail type. No deploy and no environment variable.
Permissions. org:marketing:view to read, org:marketing:edit to save — the same pair the
Overture settings panel uses, because email design is a marketing surface spanning every mail type
rather than a webinar-only one. A user without the read permission gets an explanatory panel rather
than a broken one.
Storage. Mongo mattersai.email_design_settings, singleton _id: "global", holding
defaultVariant, overrides, updated_at and updated_by. Cleared and unregistered overrides are
dropped on write, so the document is always a faithful picture of what is actually overridden.
Propagation. The workspace and the marketing site are separate deployments with separate
caches. A save busts the email-design tag locally and then pings the site's /api/revalidate
with the same tag, exactly as revalidateOvertures does. That tag had to be added to
ALLOWED_WEBHOOK_TAGS in app/api/revalidate/route.ts, which is a strict allowlist. If the
webhook secret is unset the ping is skipped and the read's 300 second revalidate window is the
guaranteed upper bound, so a change takes effect within five minutes at worst.
A design lookup must never stop an email being sent. That rule shapes the service: the read
returns defaults on any database failure, an override naming a variant that is no longer registered
is logged and ignored rather than passed to renderEmail (which rejects unknown ids), and the
resolver never throws into a send path. Tests cover each of those paths.
lib/email/mail-types.ts is the single registry of what counts as "a kind of email", replacing two
hand-copied lists — the six webinar keys were duplicated between the preview route and
EmailsView.tsx, and the datasheet, subscription and sales-export mails were in neither.
| id | Group | Audience |
|---|---|---|
registration | Webinars | transactional |
reminder | Webinars | list |
auto_invite | Webinars | list |
bulk_invite | Webinars | list |
custom | Webinars | list |
recording_ready | Webinars | transactional |
unsubscribe | Webinars | transactional |
datasheet_delivery | Resources | transactional |
subscription_confirm | Subscriptions | transactional |
sales_export | Internal | internal |
Ids are stored in Mongo, so treat them as a stable wire format. Adding a mail type means adding an entry here and passing its id at the send site; the settings panel and the resolver pick it up with no further change.
Two caveats the panel states in place:
custom in raw-HTML mode ignores the setting. The admin authored a whole document, so their
markup wins. Simple mode honours it.EMAIL_DESIGN_VARIANT or the package
default. Wiring them in would mean either giving them Mongo access or having every partner email
make a cross-app HTTP call, and neither is worth it for operational mail. This is a deliberate
boundary, not an oversight.An explicitly named unknown id throws ValidationError, at every layer (render option,
document field, setter) — a caller passes ids it controls, and silently rendering the wrong design
is worse than a loud failure. Only the environment default degrades quietly, for the reason above.
Partners and memo templates follow the resolved default and take no per-send variant argument, because neither app has a surface that would choose one. Adding it is one forwarded argument per builder if that changes.
Two preview surfaces, for two different jobs.
Settings → Email Design → Preview (/settings?view=email-design) is the sample-content
picker: choose any mail type and any registered design independently, and it renders a real
sample of that mail type through the actual template in that design. This is what to reach for to
answer "what would a datasheet email look like in Technical Blueprint" without touching the saved
settings. Backed by POST /api/workspace/email-design/preview (org:marketing:view, read-only),
which calls buildSampleMail(mailType, { variant }) from lib/email/mail-samples.ts — one sample
per mail type, each rendered through the same builder a real send uses, with placeholder data
instead of a live record. Omitting variant in the request previews whatever a real send of that
mail type would currently use (its override, or the global default), which is what the panel does
before you touch either picker.
Workspace → Webinars → Emails has a narrower Design picker beside the webinar template list,
for previewing the six webinar templates with real webinar data selected from the page. It is
populated from GET /api/workspace/webinars/email/preview, which returns the registry plus the
active default, so a new variant appears in the UI with no front-end change. "App default" names
the design currently in force.
To eyeball one locally instead, render it to a file and screenshot it:
import { EMAIL_VARIANT_IDS, renderEmail } from "@matters/email";
for (const id of EMAIL_VARIANT_IDS) {
writeFileSync(`v-${id}.html`, renderEmail(doc, { variant: id }).html);
}
Serve the directory over HTTP (a file: URL is blocked by the Playwright MCP
browser) and screenshot at 680px and 380px.
Two steps, mirroring @matters/theme:
packages/email/src/variants/<id>.ts exporting (doc: EmailDocument) => string. Read the
whole document model and honour every field — most importantly the footer, since compliance is
not optional in any design.EMAIL_VARIANTS in variants/index.ts with an id, a label, a one-line
description (both shown in the picker) and the renderer.No template, transport, route or UI changes. The shared renderEmailText gives the new design its
text part for free, and the parameterised tests start asserting the compliance invariants against it
immediately.
Note that the further a design departs structurally from folded paper, the more the model may need new optional fields. That is additive: a field one variant ignores costs the others nothing.
interface EmailDocument {
preheader: string; // hidden preview text, 1 to 160 chars
title?: string; // <title>; defaults to the headline
viewInBrowserUrl?: string; // renders the 11px "View in browser" line when set
flyer: EmailFlyer;
blocks?: EmailBlock[]; // up to 6, rendered 2-up
footer: EmailFooter;
year?: number; // copyright year; defaults to now, pass for deterministic tests
variant?: EmailVariantId; // pin this email to one design; unset follows the app default
}
interface EmailFlyer {
overline?: string; // italic serif, rotated -2deg: "(you're on the list)"
kicker: string; // tracked caps: "Registration confirmed"
presenter?: string; // small tracked caps; default "Presented by Matters.AI"
headline: string[]; // 1 to 4 stacked lines; wrap ONE phrase in *asterisks* for the serif italic accent
credits?: EmailCredit[]; // up to 6 { label, value }, laid out 2-up as fold cells
note?: string; // tracked caps, rotated +1deg, under the credits
cta?: EmailCta; // near black button with a blue label
secondaryCta?: EmailCta; // underlined near black text link under the button
}
interface EmailBlock {
eyebrow: string; // cyan tracked caps
body?: string | string[]; // paragraphs, escaped; newlines become <br/>
html?: string; // TRUSTED pre-escaped rich content only (admin authored emails)
links?: EmailCta[]; // inline cyan text links
}
interface EmailFooter {
reason: string; // "You're receiving this because ..."
unsubscribeUrl?: string; // required for any list or marketing send
preferencesUrl?: string; // rendered only when a preference centre exists
fineprint?: string; // e.g. "Reference ID: PL-2026-0142"
}
Rules enforced by the schema:
href must be an absolute http(s) URL. Relative paths and mailto: fail validation.description: datasheet.excerpt) without guarding them.ValidationError whose message lists every path and reason, e.g.
Email document failed validation (flyer.cta.href: Email links must be absolute http(s) URLs).Canvas. <body> is #050505; the 600px column is #0a0a0a. There is no brand header row:
the flyer is the opening. A hidden preheader <div> (with zero-width filler so clients do not
pull body copy into the preview) precedes everything.
Folded paper flyer. Built entirely from table cells so it survives every client:
<td> with its own solid blue shade (paper #211be9, paperLight
#2a25ec, paperDark #1c16d4) so adjacent rectangles catch light differently.#120d9e over light ridge #5b57f1) between fold rows
and two 1px columns between the left and right cells. No borders, no box shadows, so Outlook
renders them.feTurbulence tile applied as background-image on each cell
over its background-color. Gmail and Outlook drop data URIs and fall back to the flat shade,
which is the documented degradation path.transform: rotate() on display:inline-block spans. Outlook and
Gmail ignore it and render flat. Do not add VML to fight this.Type. Space Grotesk 700 uppercase, 60px, letter-spacing: -0.03em, line-height: 0.95 for
the headline (40px under 600px viewports); the *accent* phrase switches to DM Serif Display
italic 400 with case preserved. Labels are Space Grotesk tracked caps. Body is DM Sans. Fonts
load from Google Fonts via a <link> wrapped in <!--[if !mso]> with fallbacks Helvetica Neue,
Arial and Georgia. Ink on paper is #131316 everywhere.
Button. A padded <td bgcolor="#131316"> containing the <a> (bulletproof, never an image),
label in Space Grotesk tracked caps coloured #211be9. mso-padding-alt keeps Outlook padding.
Detail blocks. Two per row on desktop (class="mai-stack" collapses them to one column
under 600px). Eyebrow is cyan #5ad3e6 tracked caps; body is 13px DM Sans in white/60
(#9d9d9d, pre-composited on the column colour so Outlook, which drops rgba, matches Apple
Mail). Links are cyan underlined tracked caps. Cyan is the only accent on dark surfaces; blue
appears only as the paper.
Footer (never trimmed). Three rows separated by a 1px #1e1e1e hairline:
EMAIL_BRAND.socials.reason sentence, optional fineprint, then Unsubscribe,
Manage preferences (only when a URL is supplied) and Privacy Policy, underlined in
white/60.© {year} Matters.AI, a product of Aegislock AI Pvt Ltd. All rights reserved. and
the postal address on its own line. The address is mandatory for CAN-SPAM; the renderer
always emits it.Dark mode. <meta name="color-scheme" content="light dark"> plus
supported-color-schemes, and every colour is re-asserted with !important under
@media (prefers-color-scheme: dark), [data-ogsc] and [data-ogsb] (Outlook.com), so no
client inverts the blue paper or the near black type.
renderEmailText walks the same model: kicker, presenter, overline, headline (asterisks
stripped), Label: value for every credit, note, Label: url for every CTA, each block's
eyebrow, paragraphs and links, then the footer reason, unsubscribe and preferences URLs, privacy
policy, every social link, the legal line and the address. html blocks pass through
htmlToText. The output never contains <. Because both parts come from one model, the text
part cannot drift from the HTML part, which matters for spam scoring and for text-only readers
who need the unsubscribe URL.
| Client | Grain | Rotation | Web fonts | Creases and shades | Result |
|---|---|---|---|---|---|
| Apple Mail, iOS Mail | yes | yes | yes | yes | Full design |
| Gmail web and apps | no | no | no (Arial) | yes | Flat, unrotated, still folded paper |
| Outlook desktop (Word engine) | no | no | no | yes | Flat blue cells with crease lines, Arial |
| Outlook.com | no | partial | yes | yes | Locked colours via [data-ogsc] |
Everything the recipient must act on (host, date, join link, code, button, unsubscribe) is live HTML text. Nothing is rasterised into an image.
Matters.AI (capital AI, dot). "Matters Memo" is allowed only in owner-facing memo
mail; visitor-facing memo mail (OTP codes) says Matters.AI and sends from
Matters.AI <memo@matters.ai>.buildCustomEmail truncates admin subjects to that length).*accent* phrase, e.g.
["You're", "*in.*"], ["Seven", "days", "*left.*"].reason states honestly why the recipient got the mail. Transactional mail with
no list (OTP, datasheet delivery, opt-in confirmation, partner notifications) omits
unsubscribeUrl; every list or marketing send must supply it.lib/email/index.ts, SES with Resend fallback)#| Builder | File | Trigger | Unsubscribe |
|---|---|---|---|
buildRegistrationConfirmationEmail | lib/email/webinar-emails.ts | Webinar registration, subscribe form, join authorize | yes |
buildReminderEmail | same | T-24h cron reminder, workspace calendar invite | yes |
buildAutoInviteEmail | same | Publish of a new webinar to opted-in past attendees | yes |
buildBulkInviteEmail | same | Workspace bulk invite to subscribers (optional admin note block) | yes |
buildCustomEmail | same | Workspace custom send, SIMPLE mode (admin body as trusted html block; subject becomes the kicker) | yes |
buildRecordingReadyEmail | same | Past-webinar recording access request | yes |
buildUnsubscribeConfirmationEmail | same | Preview only today | no |
buildDatasheetDeliveryEmail | components/template-emails/datasheet-delivery.ts | Gated datasheet form | no (transactional) |
buildSubscriptionConfirmEmail | components/template-emails/subscription-confirm.ts | POST /api/subscriptions/initiate double opt-in | no (transactional) |
Workspace admin previews live at POST /api/workspace/webinars/email/preview with templates
registration, reminder, auto_invite, bulk_invite, recording_ready, unsubscribe, and
in the Emails tab of the webinars module.
apps/partners/lib/email.ts, Resend)#All builders are pure functions in apps/partners/lib/email-templates.ts; lib/email.ts
resolves portal URLs and sends through deliverPartnerEmail, which normalises Resend's
non-throwing error shape into one log line and { ok, error }.
| Builder | Trigger |
|---|---|
buildInviteEmail | Org team invite |
buildDealRegisteredEmail | Deal registration acknowledgement |
buildAccessStatusEmail | Access request approved or rejected (rejections render no button) |
buildDealStatusEmail | Salesforce status callout |
buildActivityReminderEmail | 14, 30, 60 day inactivity nudges (escalating headline and subject) |
buildCancellationWarningEmail | Day 83 warning |
buildDealAutoCancelledEmail | Day 90 auto cancel |
buildExtensionGrantedEmail | 30 day extension approved |
buildGenericNotificationEmail | Notification engine fallback (lib/notifications/channels/email.ts) |
The former apps/partners/emails/*.tsx React Email templates and the
@react-email/components dependency were removed.
apps/memo/src/lib/email/resend.ts, Resend)#| Sender | Audience | Notes |
|---|---|---|
sendOtpEmail | Visitor | Code is the headline; expiry and link slug are credits. Matters.AI identity, no Memo branding. |
sendProjectInviteEmail | Collaborator | Sign up or sign in CTA depending on isNewUser. |
sendHotLeadEmail | Deck owner | Takes the { subject, text, html } returned by formatHotLeadEmail (lib/alerts/hot-lead.ts), which stays pure and unit tested. The concise text is also what goes to Slack. |
lib/email/… in root, lib/email-templates.ts
in partners, the module that owns the copy in memo). It takes typed params and returns
{ subject, html, text } from renderEmail(...).*accent*, credits for the vital facts, a cta with an absolute URL, one or two blocks, and
an honest footer reason. Add unsubscribeUrl if this is a list send.sendEmail({ to, subject, html, text })). Never send
HTML alone.<.
Root tests live in tests/lib/, partners in __tests__/lib/, memo beside the module.tsx script that imports the builder and writes the HTML,
then playwright screenshot --viewport-size=640,1000 --full-page file://… (Playwright is a
partners dev dependency).EmailsView.tsx.tests/lib/email-design-system.test.ts: footer always present, unsubscribe and preferences
controls, socials as text links, accent markup, escaping, URL validation, empty optional
handling, no markup in text. Plus a describe.each over the variant registry asserting the
compliance invariants and an identical text part for every registered design, and the
variant precedence rules (caller, document, env, programmatic default, unknown-id handling).tests/lib/webinar-invite-email.test.ts, tests/lib/recording-ready-email.test.ts: webinar
template contracts (both CTAs, omission of the Zoom link when unset, text mirrors links).apps/partners/__tests__/lib/email-templates.test.ts: every partner template carries the
footer and a real text part; status labels; reminder tiers; ARR formatting.apps/memo/src/lib/alerts/hot-lead.test.ts: subject and text contracts plus the HTML part.Manage preferences is only rendered when a
builder supplies preferencesUrl. Building one is tracked in PENDING_TASKS.md./api/workspace/email-design and
/api/workspace/email-design/preview in its route manifest, but neither has been click-tested
in a browser against a live Mongo, since that needs Clerk auth.editorial-receipt is the one design still off the brand kit, by instruction rather than
oversight.EMAIL_DESIGN.md (repo root): the design spec this system implements.packages/email/src/*: the renderer; variants/ holds the registry and one file per design.lib/email/webinar-emails.ts, components/template-emails/*: root templates.apps/partners/lib/email-templates.ts, apps/partners/lib/email.ts: partner templates and transport.apps/memo/src/lib/email/resend.ts, apps/memo/src/lib/alerts/hot-lead.ts: memo templates.apps/workspace/app/api/workspace/webinars/email/preview/route.ts: webinar admin preview.apps/workspace/app/api/workspace/email-design/route.ts (settings) and .../email-design/preview/route.ts
(sample preview): the workspace design-settings API.apps/workspace/app/(workspace)/settings/components/EmailDesignSettings.tsx: the settings + preview panel.lib/email/mail-types.ts, lib/email/mail-samples.ts, lib/schemas/email-design.ts,
lib/services/email-design.service.ts: the mail-type registry, preview samples, schema and settings service.