@@ -86,3 +93,28 @@ export function EmailLayout({ preview, children, footer }: EmailLayoutProps) {
);
}
+
+function BrandHeader({ brand }: { brand: EmailBrand }) {
+ const [bold, light] = brand.wordmark;
+ return (
+
+
+
+
+
+ |
+
+ {bold}
+ {light}
+ |
+
+
+
+ );
+}
diff --git a/packages/email/src/kit/parts.tsx b/packages/email/src/kit/parts.tsx
index ae321877..a6d728e9 100644
--- a/packages/email/src/kit/parts.tsx
+++ b/packages/email/src/kit/parts.tsx
@@ -1,5 +1,6 @@
import type * as React from "react";
import { emailColors, emailFonts } from "./theme";
+import { displayUrl } from "./words";
export function EmailLabel({ children }: { children: React.ReactNode }) {
return (
@@ -55,27 +56,38 @@ export function EmailText({
export function EmailButton({ href, children }: { href: string; children: React.ReactNode }) {
return (
-
+ <>
+
+
+ Or open:{" "}
+
+ {displayUrl(href)}
+
+
+ >
);
}
@@ -117,3 +129,11 @@ export function EmailRows({ rows }: { rows: [label: string, value: string][] })
);
}
+
+export function EmailLink({ href, children }: { href: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/email/src/kit/text/define-email.tsx b/packages/email/src/kit/text/define-email.tsx
new file mode 100644
index 00000000..e1800e3c
--- /dev/null
+++ b/packages/email/src/kit/text/define-email.tsx
@@ -0,0 +1,83 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import type { RenderedEmail } from "../../render";
+import { brandOf } from "../brand";
+import { EmailLayout } from "../email-layout";
+import { EmailButton, EmailHeading, EmailLabel, EmailLink, EmailRows, EmailText } from "../parts";
+import { displayUrl } from "../words";
+import { type DefaultText, resolveText, type WithOverrides } from "./overrides";
+import { plainTextOf, type ResolvedEmail } from "./plain-text";
+
+type Row = [label: string, value: string | undefined];
+
+export type EmailDefinition = {
+ text: DefaultText;
+ actionUrl?: (props: Data) => string;
+ rows?: (props: Data) => Row[];
+ noteUrl?: (props: Data) => string;
+};
+
+function hasValue(row: Row): row is [string, string] {
+ return row[1] !== undefined;
+}
+
+function resolveEmail(
+ definition: EmailDefinition,
+ props: WithOverrides
+): ResolvedEmail {
+ const brandName = brandOf(props.product, props.brand).name;
+ return {
+ text: resolveText(definition.text, props, brandName),
+ brandName,
+ actionUrl: definition.actionUrl?.(props),
+ rows: (definition.rows?.(props) ?? []).filter(hasValue),
+ noteUrl: definition.noteUrl?.(props),
+ };
+}
+
+function EmailBody({ email }: { email: ResolvedEmail }) {
+ const { text, actionUrl, rows, noteUrl } = email;
+ return (
+ <>
+ {text.label}
+ {text.heading}
+ {text.body}
+ {rows.length > 0 && }
+ {actionUrl && text.button && {text.button}}
+ {text.note && (
+
+ {text.note}
+ {noteUrl && (
+ <>
+ {" "}
+ {displayUrl(noteUrl)}
+ >
+ )}
+
+ )}
+ >
+ );
+}
+
+export function defineEmail(definition: EmailDefinition) {
+ function Component(props: WithOverrides) {
+ const email = resolveEmail(definition, props);
+ return (
+
+
+
+ );
+ }
+
+ function render(props: WithOverrides): RenderedEmail {
+ const email = resolveEmail(definition, props);
+ const html = `${renderToStaticMarkup()}`;
+ return { subject: email.text.subject, html, text: plainTextOf(email) };
+ }
+
+ return { Component, render };
+}
diff --git a/packages/email/src/kit/text/overrides.ts b/packages/email/src/kit/text/overrides.ts
new file mode 100644
index 00000000..7ea69120
--- /dev/null
+++ b/packages/email/src/kit/text/overrides.ts
@@ -0,0 +1,50 @@
+import type { WithProduct } from "../brand";
+
+export type TextSlot =
+ | "label"
+ | "heading"
+ | "body"
+ | "button"
+ | "note"
+ | "footer"
+ | "subject"
+ | "preview";
+
+const textSlots: TextSlot[] = [
+ "label",
+ "heading",
+ "body",
+ "button",
+ "note",
+ "footer",
+ "subject",
+ "preview",
+];
+
+export type TextContext = Data & { brandName: string };
+
+export type SlotText = string | ((props: TextContext) => string);
+
+export type EmailOverrides = Partial>>;
+
+type RequiredSlot = "label" | "heading" | "body" | "subject" | "preview";
+
+export type DefaultText = EmailOverrides & Record>;
+
+export type WithOverrides = Data & WithProduct & { overrides?: EmailOverrides };
+
+export type ResolvedText = Record;
+
+export function resolveText(
+ defaults: DefaultText,
+ props: WithOverrides,
+ brandName: string
+): ResolvedText {
+ const context = { ...props, brandName } as TextContext;
+ const slots: EmailOverrides = { ...defaults, ...props.overrides };
+ const entries = textSlots.map((slot) => {
+ const value = slots[slot];
+ return [slot, typeof value === "function" ? value(context) : (value ?? "")];
+ });
+ return Object.fromEntries(entries) as ResolvedText;
+}
diff --git a/packages/email/src/kit/text/plain-text.ts b/packages/email/src/kit/text/plain-text.ts
new file mode 100644
index 00000000..55ad6c83
--- /dev/null
+++ b/packages/email/src/kit/text/plain-text.ts
@@ -0,0 +1,31 @@
+import type { ResolvedText } from "./overrides";
+
+export type ResolvedEmail = {
+ text: ResolvedText;
+ brandName: string;
+ actionUrl?: string;
+ rows: [label: string, value: string][];
+ noteUrl?: string;
+};
+
+function actionLine({ text, actionUrl }: ResolvedEmail) {
+ return actionUrl && text.button ? `${text.button}: ${actionUrl}` : "";
+}
+
+function noteLine({ text, noteUrl }: ResolvedEmail) {
+ return text.note ? [text.note, noteUrl].filter(Boolean).join(" ") : "";
+}
+
+export function plainTextOf(email: ResolvedEmail): string {
+ return [
+ email.text.heading,
+ email.text.body,
+ actionLine(email),
+ email.rows.map(([label, value]) => `${label}: ${value}`).join("\n"),
+ noteLine(email),
+ email.text.footer,
+ `—\n${email.brandName}`,
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+}
diff --git a/packages/email/src/kit/text/security.ts b/packages/email/src/kit/text/security.ts
new file mode 100644
index 00000000..81872ca1
--- /dev/null
+++ b/packages/email/src/kit/text/security.ts
@@ -0,0 +1,3 @@
+export function securityFooter({ brandName }: { brandName: string }) {
+ return `If you did not expect this, reply to this email to reach ${brandName} support.`;
+}
diff --git a/packages/email/src/kit/words.ts b/packages/email/src/kit/words.ts
new file mode 100644
index 00000000..06223ef9
--- /dev/null
+++ b/packages/email/src/kit/words.ts
@@ -0,0 +1,9 @@
+export function withIndefiniteArticle(word: string) {
+ return /^[aeiou]/i.test(word) ? `an ${word}` : `a ${word}`;
+}
+
+export function displayUrl(href: string) {
+ if (!URL.canParse(href)) return href;
+ const url = new URL(href);
+ return `${url.host}${url.pathname}`.replace(/\/$/, "");
+}
diff --git a/packages/email/src/render.ts b/packages/email/src/render.ts
index a317e07a..34b0d226 100644
--- a/packages/email/src/render.ts
+++ b/packages/email/src/render.ts
@@ -6,6 +6,7 @@ export type RenderedEmail = { subject: string; html: string; text: string };
function plainText(html: string): string {
return html
.replace(/