-
Notifications
You must be signed in to change notification settings - Fork 10
feat(emails): shared house-style email layout wrapper (consistency pass) #784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7eee332
7ec6867
40df59d
e3fbd06
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { renderEmailLayout } from "@/lib/emails/renderEmailLayout"; | ||
|
|
||
| describe("renderEmailLayout", () => { | ||
| it("wraps the body HTML inside the layout", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Hello world</p>" }); | ||
| expect(html).toContain("<p>Hello world</p>"); | ||
| }); | ||
|
|
||
| it("includes the footer HTML when provided", () => { | ||
| const html = renderEmailLayout({ | ||
| bodyHtml: "<p>Body</p>", | ||
| footerHtml: "<div>Footer bits</div>", | ||
| }); | ||
| expect(html).toContain("<div>Footer bits</div>"); | ||
| }); | ||
|
|
||
| it("omits the footer region when no footer is provided", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| expect(html).not.toContain("Footer bits"); | ||
| }); | ||
|
|
||
| it("renders a CTA button with the given label and url when provided", () => { | ||
| const html = renderEmailLayout({ | ||
| bodyHtml: "<p>Body</p>", | ||
| cta: { label: "Open Recoup", url: "https://chat.recoupable.dev" }, | ||
| }); | ||
| expect(html).toContain("Open Recoup"); | ||
| expect(html).toContain('href="https://chat.recoupable.dev"'); | ||
| }); | ||
|
|
||
| it("does not render a CTA when none is provided", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| expect(html).not.toContain("<a "); | ||
| }); | ||
|
|
||
| it("carries the Recoup house style — wordmark, font stack, and shadow-as-border card", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
| // Header wordmark. | ||
| expect(html).toContain("Recoup"); | ||
| // Achromatic near-black brand ink (DESIGN.md --foreground). | ||
| expect(html).toContain("#0a0a0a"); | ||
| // Shadow-as-border card outline (DESIGN.md), not a CSS `border` on the card. | ||
| expect(html).toContain("box-shadow"); | ||
| // Brand font stack (Plus Jakarta Sans for UI, per DESIGN.md). | ||
| expect(html).toContain("Plus Jakarta Sans"); | ||
| // Constrained, centered container. | ||
| expect(html).toContain("max-width"); | ||
| }); | ||
|
|
||
| it("never breaks a style attribute with quotes in the font stack", () => { | ||
| const html = renderEmailLayout({ bodyHtml: "<p>Body</p>" }); | ||
|
|
||
| // Regression: double-quoted font names inside a double-quoted style="…" | ||
| // attribute terminate it early, silently dropping the font (clients fall | ||
| // back to serif) and every declaration after it. Font names must be | ||
| // single-quoted so the attribute stays intact. | ||
| expect(html).not.toContain('"Plus Jakarta Sans"'); | ||
| expect(html).toContain("'Plus Jakarta Sans'"); | ||
| // No style attribute may contain a stray double quote before its close. | ||
| for (const style of html.match(/style="[^"]*"/g) ?? []) { | ||
| expect(style).not.toContain('font-family:"'); | ||
| } | ||
| }); | ||
|
|
||
| it("returns a single HTML string", () => { | ||
| expect(typeof renderEmailLayout({ bodyHtml: "<p>x</p>" })).toBe("string"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| export type EmailLayoutCta = { | ||
| /** Button label. */ | ||
| label: string; | ||
| /** Absolute URL the button links to. */ | ||
| url: string; | ||
| }; | ||
|
|
||
| export type RenderEmailLayoutParams = { | ||
| /** The email's main content, already rendered to HTML. */ | ||
| bodyHtml: string; | ||
| /** Optional footer HTML (reply note, chat link, artist attribution). */ | ||
| footerHtml?: string; | ||
| /** Optional primary call-to-action rendered as a button below the body. */ | ||
| cta?: EmailLayoutCta; | ||
| }; | ||
|
|
||
| // Brand tokens (DESIGN.md §3) — kept literal because email clients can't read | ||
| // CSS custom properties. Achromatic chrome; color comes from content. | ||
| const INK = "#0a0a0a"; // --foreground | ||
| const MUTED_INK = "#6b6b6b"; // --muted-foreground | ||
| const PAGE_BG = "#f7f7f7"; // --muted (page canvas) | ||
| const CARD_BG = "#ffffff"; // --card | ||
| const BORDER = "#e8e8e8"; // --border | ||
| const CTA_BG = "#0a0a0a"; // --primary | ||
| const CTA_FG = "#ffffff"; // --primary-foreground | ||
|
|
||
| // UI font stack — Plus Jakarta Sans (DESIGN.md four-font system) with system | ||
| // fallbacks for clients that can't load a webfont. | ||
| // | ||
| // Font names are SINGLE-quoted on purpose: this stack is interpolated into | ||
| // double-quoted `style="…"` attributes, so double quotes here would terminate | ||
| // the attribute early — silently dropping the font (clients fall back to | ||
| // serif) and every declaration after it. | ||
| const FONT_STACK = | ||
| "'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; | ||
|
|
||
| // Shadow-as-border (DESIGN.md) — a hairline outline + subtle elevation via | ||
| // box-shadow instead of a CSS `border` on the card. | ||
| const CARD_SHADOW = `box-shadow: 0px 0px 0px 1px ${BORDER}, 0px 2px 4px rgba(0,0,0,0.04);`; | ||
|
|
||
| /** | ||
| * Shared house-style wrapper for all onboarding emails (welcome, valuation, | ||
| * weekly report — recoupable/chat#1885 consistency pass). Wraps a rendered | ||
| * body in one header + footer + CTA structure so every automated email reads | ||
| * as one family: achromatic chrome, shadow-as-border card, the Recoup wordmark | ||
| * header, and the DESIGN.md font stack. | ||
| * | ||
| * Email-client-safe: all styles inline, a centered fixed-max-width container, | ||
| * literal hex tokens (no CSS variables), and webfonts degrade to system fonts. | ||
| */ | ||
| export function renderEmailLayout({ bodyHtml, footerHtml, cta }: RenderEmailLayoutParams): string { | ||
| const header = ` | ||
| <div style="padding:0 0 20px;"> | ||
| <span style="font-family:${FONT_STACK};font-weight:700;font-size:18px;letter-spacing:-0.02em;color:${INK};">Recoup</span> | ||
| </div>`.trim(); | ||
|
|
||
| const ctaBlock = cta | ||
| ? ` | ||
| <div style="padding:24px 0 4px;"> | ||
| <a href="${cta.url}" target="_blank" rel="noopener noreferrer" | ||
| style="display:inline-block;background:${CTA_BG};color:${CTA_FG};font-family:${FONT_STACK};font-weight:600;font-size:14px;text-decoration:none;padding:12px 20px;border-radius:8px;"> | ||
| ${cta.label} | ||
| </a> | ||
| </div>`.trim() | ||
| : ""; | ||
|
|
||
| const footerBlock = footerHtml | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The layout's footer wrapper adds its own Prompt for AI agents |
||
| ? ` | ||
| <div style="padding-top:24px;margin-top:24px;"> | ||
| ${footerHtml} | ||
| </div>`.trim() | ||
| : ""; | ||
|
|
||
| return ` | ||
| <div style="background:${PAGE_BG};padding:32px 16px;font-family:${FONT_STACK};color:${INK};"> | ||
| <div style="max-width:560px;margin:0 auto;background:${CARD_BG};border-radius:12px;${CARD_SHADOW}padding:32px;"> | ||
| ${header} | ||
| <div style="font-family:${FONT_STACK};font-size:15px;line-height:1.6;color:${INK};"> | ||
| ${bodyHtml} | ||
| </div> | ||
| ${ctaBlock} | ||
| ${footerBlock} | ||
| </div> | ||
| <div style="max-width:560px;margin:16px auto 0;text-align:center;font-family:${FONT_STACK};font-size:11px;color:${MUTED_INK};"> | ||
| Recoup, the AI agent platform for the music industry | ||
| </div> | ||
| </div>`.trim(); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: CTA label and URL are not HTML-escaped before interpolation into the email template. If these values ever come from user input, scraper data, or a database field, an attacker could inject arbitrary markup or break the href attribute. The codebase already has escapeHtml in lib/emails/escapeHtml.ts — use it here to guard cta.label and cta.url.
Prompt for AI agents