Skip to content

feat(emails): shared house-style email layout wrapper (consistency pass) - #784

Merged
sweetmantech merged 4 commits into
mainfrom
feat/shared-email-layout
Jul 27, 2026
Merged

feat(emails): shared house-style email layout wrapper (consistency pass)#784
sweetmantech merged 4 commits into
mainfrom
feat/shared-email-layout

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

Welcome, valuation, and weekly-report emails should share one visual language (DESIGN.md — four-font system, shadow-as-border, achromatic chrome), but each outbound email was assembled ad hoc (rendered body + a bare footer). This is the consistency pass from recoupable/chat#1885 — it establishes the shared email template.

Changes

  • renderEmailLayout (lib/emails/) — one shared header + footer + optional-CTA wrapper:
    • Recoup wordmark header
    • achromatic shadow-as-border card (box-shadow, not a CSS border, per DESIGN.md)
    • the DESIGN.md font stack (Plus Jakarta Sans → system fallbacks)
    • literal hex brand tokens (email clients can't read CSS variables), centered fixed-max-width container
    • email-client-safe: all styles inline, webfonts degrade to system fonts.
  • processAndSendEmail — adopts the wrapper as the proof point. This is the path the live weekly-report email flows through (agent send_emailprocessAndSendEmail), so the already-live weekly report now renders in the shared house style, with the existing getEmailFooter output carried in as the layout footer.

Note

The welcome (api#774) and valuation (api#773) emails will adopt this same renderEmailLayout wrapper once merged — this PR establishes the shared template they align to, so all three read as one family.

Verification

  • TDD RED→GREEN:
    • renderEmailLayout — wraps body; includes footer when given / omits when not; renders CTA button (label + href) when given / none otherwise; carries house-style markers (wordmark, #0a0a0a ink, box-shadow card, Plus Jakarta Sans, max-width).
    • processAndSendEmail — the sent HTML preserves the body and is wrapped in the shared layout (wordmark + shadow-as-border + font stack).
  • Full lib/emails domain suite green — 159 tests passing (existing processAndSendEmail behavior unchanged).
  • tsc --noEmit: no errors in changed files (1 pre-existing baseline error in processAndSendEmail.test.ts is unchanged, only line-shifted).
  • eslint + prettier clean on all changed files.

Targets main.

Relates to recoupable/chat#1885 (consistency pass across onboarding emails).

🤖 Generated with Claude Code


Summary by cubic

Add a shared house‑style email layout and adopt it across weekly report, welcome, and valuation emails. This completes the consistency pass so onboarding emails use one visual language (recoupable/chat#1885).

  • New Features

    • lib/emails/renderEmailLayout: shared wrapper (header, footer, optional CTA) with Recoup wordmark, shadow‑as‑border card (box-shadow), DESIGN.md font stack, centered max‑width container, inline styles, and literal hex tokens.
    • Adopted in lib/emails/processAndSendEmail, lib/emails/buildWelcomeEmail, and lib/emails/valuationReport/renderValuationReportHtml; these now provide only body/CTA/footer and render through the new layout.
  • Bug Fixes

    • Single‑quoted font names in the font stack to avoid breaking style="…" in email clients; added a regression test in renderEmailLayout.test.ts.

Written for commit e3fbd06. Summary will update on new commits.

Review in cubic

Welcome, valuation, and weekly-report emails should share one visual language
(DESIGN.md — four-font system, shadow-as-border, achromatic chrome), but each
outbound email was assembled ad hoc (body + bare footer). This establishes the
shared template (recoupable/chat#1885 consistency pass).

- `renderEmailLayout` (`lib/emails/`) — one header + footer + optional-CTA
  wrapper: Recoup wordmark header, achromatic shadow-as-border card, the
  DESIGN.md font stack, centered fixed-max-width container. Email-client-safe
  (inline styles, literal hex tokens, webfonts degrade to system fonts).
- `processAndSendEmail` — adopts the wrapper as the proof point. This is the
  path the live weekly-report email flows through (agent `send_email` →
  `processAndSendEmail`), so the already-live weekly report now renders in the
  shared house style, with the existing footer carried in as the layout footer.

Welcome (api#774) and valuation (api#773) emails adopt the same wrapper once
merged — this PR establishes the shared template.

TDD: RED→GREEN for the layout renderer (body/footer/CTA + house-style markers)
and for the processAndSendEmail adoption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 24, 2026 1:20am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f8fc2c49-9e82-456c-b18e-428fcfc331c8

📥 Commits

Reviewing files that changed from the base of the PR and between 96959eb and e3fbd06.

⛔ Files ignored due to path filters (4)
  • lib/emails/__tests__/buildWelcomeEmail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/emails/__tests__/processAndSendEmail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/emails/__tests__/renderEmailLayout.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (4)
  • lib/emails/buildWelcomeEmail.ts
  • lib/emails/processAndSendEmail.ts
  • lib/emails/renderEmailLayout.ts
  • lib/emails/valuationReport/renderValuationReportHtml.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shared-email-layout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 4 files

Confidence score: 3/5

  • In lib/emails/renderEmailLayout.ts, renderEmailLayout interpolates CTA label/URL without HTML escaping, so untrusted values could inject markup into email content and create a phishing or content-integrity risk—escape or sanitize both fields before template insertion.
  • In lib/emails/renderEmailLayout.ts, the footer wrapper adds top spacing on top of the <hr> margin already included by getEmailFooter(), which can cause inconsistent footer rendering and excess whitespace across email clients—remove one of the top-spacing sources to keep layout spacing predictable.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/emails/renderEmailLayout.ts">

<violation number="1" location="lib/emails/renderEmailLayout.ts:55">
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.</violation>

<violation number="2" location="lib/emails/renderEmailLayout.ts:62">
P2: The layout's footer wrapper adds its own `padding-top:24px;margin-top:24px` around the footer HTML, but `getEmailFooter()` already ships an `<hr>` with `margin-top:24px`. This doubles the visual gap above the footer in the final email. Consider removing `padding-top` and `margin-top` from the footer wrapper div in `renderEmailLayout` and letting the footer's own `<hr>` margin control the spacing, or removing the `<hr>`'s top margin if the wrapper is the sole spacer.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Agent as Agent (send_email)
    participant API as API Route (processAndSendEmail)
    participant Layout as renderEmailLayout
    participant Footer as getEmailFooter
    participant DB as Database
    participant Resend as Resend API
    participant Client as Email Client

    Note over Agent,Client: Weekly Report Email Flow (Proof Point)

    Agent->>API: POST /process (subject, html, room_id)
    API->>DB: selectRoomWithArtist(room_id)
    DB-->>API: Room data
    API->>Footer: getEmailFooter(room_id, artist_name)
    Footer-->>API: Footer HTML
    API->>Layout: renderEmailLayout({ bodyHtml, footerHtml })
    Note over Layout: Wraps body + footer in house style<br/>Recoup wordmark header<br/>Shadow-as-border card<br/>Plus Jakarta Sans font stack<br/>Achromatic brand tokens<br/>Centered max-width container
    Layout-->>API: Full HTML with layout
    API->>Resend: sendEmailWithResend({ html: htmlWithLayout })
    Resend-->>API: Email sent (id)
    API-->>Agent: Success response

    Note over Client: Email Rendered
    Client->>Client: Render inline styles<br/>Wordmark header<br/>Body content (preserved)<br/>Optional footer<br/>All styles literal hex (no CSS vars)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const ctaBlock = cta
? `
<div style="padding:24px 0 4px;">
<a href="${cta.url}" target="_blank" rel="noopener noreferrer"

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/renderEmailLayout.ts, line 55:

<comment>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.</comment>

<file context>
@@ -0,0 +1,83 @@
+  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}
</file context>

</div>`.trim()
: "";

const footerBlock = footerHtml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The layout's footer wrapper adds its own padding-top:24px;margin-top:24px around the footer HTML, but getEmailFooter() already ships an <hr> with margin-top:24px. This doubles the visual gap above the footer in the final email. Consider removing padding-top and margin-top from the footer wrapper div in renderEmailLayout and letting the footer's own <hr> margin control the spacing, or removing the <hr>'s top margin if the wrapper is the sole spacer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/renderEmailLayout.ts, line 62:

<comment>The layout's footer wrapper adds its own `padding-top:24px;margin-top:24px` around the footer HTML, but `getEmailFooter()` already ships an `<hr>` with `margin-top:24px`. This doubles the visual gap above the footer in the final email. Consider removing `padding-top` and `margin-top` from the footer wrapper div in `renderEmailLayout` and letting the footer's own `<hr>` margin control the spacing, or removing the `<hr>`'s top margin if the wrapper is the sole spacer.</comment>

<file context>
@@ -0,0 +1,83 @@
+</div>`.trim()
+    : "";
+
+  const footerBlock = footerHtml
+    ? `
+<div style="padding-top:24px;margin-top:24px;">
</file context>

sweetmantech and others added 2 commits July 23, 2026 18:59
…t-stack quoting

Completes the consistency pass — previously the wrapper was only adopted in the
weekly-report path, so welcome/valuation still shipped their own chrome.

- buildWelcomeEmail + renderValuationReportHtml now render through
  renderEmailLayout (body + cta + footer), dropping their duplicated outer
  page/card chrome; each keeps only its own content.
- Fix: FONT_STACK used double-quoted font names inside double-quoted
  style="…" attributes, which terminated the attribute early — silently
  dropping the font (clients fell back to serif) AND every declaration after
  it (the CTA button lost its background/padding/radius). Single-quote the
  font names; add a regression test.
- Layout footer tagline: em dash -> comma (house copy rule).
- Consistency assertions added to both email tests.

199 lib/emails tests pass; lint + format clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 6 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 2 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Completed the consistency pass + fixed a layout bug (e3fbd069)

Synced with main (was BEHIND) and finished what the PR set out to do. Two findings worth calling out.

1. The pass was only half-applied

The wrapper was adopted only in processAndSendEmail (weekly-report). The now-merged welcome (api#774) and valuation (api#773) emails still shipped their own duplicated outer chrome — so "one family" wasn't actually true. Both now render through renderEmailLayout (body + cta + footerHtml), dropping their own page/card markup and keeping only their content.

2. 🐛 The shared layout's styles were silently broken

FONT_STACK used double-quoted font names interpolated into double-quoted style="…" attributes:

style="…font-family:"Plus Jakarta Sans", -apple-system, …;color:#0a0a0a;"

The inner " terminates the attribute early — so font-family never applied (clients fell back to serif) and every declaration after it was dropped, including color and the CTA button's background/padding/border-radius (it rendered as a plain underlined link). This affected every email using the layout, including the weekly report already migrated here. Fixed by single-quoting the font names, with a regression test that scans every style="…" for the pattern.

Also: layout footer tagline em dash → comma (house copy rule — it would otherwise fail the welcome email's no-em-dash test).

Rendered result (after the fix)

Welcome — shared header/card/CTA/footer, steps + art intact:
welcome

Valuation — same chrome, artist header + band + stats + release table intact:
valuation

Verification

  • 199 lib/emails tests pass (+2 consistency assertions, +1 font-stack regression test); CI format · lint · test all green.
  • Live send on the preview (api-git-feat-shared-email-layout): POST /api/accounts with a fresh alias → 200, email_send_log status=sent, resend_id 3f91552a — the welcome email delivered rendering through the shared layout.
  • Valuation render verified from the real renderer locally (screenshot above); its live path is unchanged apart from the wrapper.

Note on the one pre-existing tsc error in processAndSendEmail.test.ts (result.error narrowing): it's baseline — identical on main, one of 236 repo-wide test-file errors. Not touched here.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-27

Preview: https://api-aixyt7llf-recoup.vercel.app
Built from: e3fbd069 — PR head, confirmed via sha-filtered deployment 5582292833, state success
Live send: one test email through the preview, POST /api/emails, self-addressed (to omitted → defaults to the authenticated account's own address). Resend id baa34a04-c3cd-48af-ae52-77ae2a2a7644, email_send_log row 88569abb status sent.

Results

# Check Expected Actual Result
1 POST /api/emails, no auth 401 401"Exactly one of x-api-key or Authorization must be provided"
2 POST /api/emails, no body 4xx 400missing_fields:["html"] ⚠️ see note
3 GET /api/emails (POST-only) 405 405
4 Live send, Privy Bearer on preview 200 + send 200 {"success":true,…}, Resend id returned
5 Send recorded row with status=sent email_send_log 88569abb, status=sent, resend_id matches
6 Delivered HTML carries the wrapper header + card + footer rendered below — inbox confirmation still outstanding 🟡

The layout, before and after

processAndSendEmail on main sends `${bodyHtml}\n\n${footer}` — raw markdown output, no chrome. This PR wraps it. Both images below are produced by running the exact pipeline for each ref (marked(text) → footer → send-path HTML) against the identical input used in the live send — 498 bytes on main, 1,665 bytes on this branch:

main today

main — unwrapped

This branch

this branch — house layout

Recoup wordmark header, white card on the #f7f7f7 canvas, shadow-as-border hairline + elevation (no CSS border, per DESIGN.md), Plus Jakarta Sans with system fallbacks, the existing reply-note footer relocated below a divider, and the platform line outside the card.

Note on row 2 — validation precedes auth (pre-existing, not this PR)

An unauthenticated POST /api/emails {} returns 400 with missing_fields, not 401 — body validation runs before validateAuthContext in validateSendEmailBody. Low severity (it leaks required field names, nothing more) and it predates this PR, so not a blocker here. Flagging it because it also means a 400 can't be read as "authenticated but malformed."

Note on row 6 — what I could not verify, and why

I could not independently inspect the delivered HTML. email_send_log.raw_body turns out to hold the inbound request body, not the rendered outbound HTML:

{"subject":"[api#784 preview test] shared email layout wrapper","text":"## Layout wrapper check…"}

So substring checks against it are worthless here — worse, they produce false positives (raw_body LIKE '%Plus Jakarta Sans%' returned true purely because my own request text contained that phrase). Ground truth for delivered HTML is the recipient inbox or GET https://api.resend.com/emails/{id}, which needs RESEND_API_KEY.

What is established: the send went through the preview built from this PR's head, returned 200, and logged sent; and the wrapper output is deterministic and unit-tested. What remains is a two-second eyeball: @sweetmantech — the email is in your inbox, subject [api#784 preview test] shared email layout wrapper. Confirm it looks like the second screenshot and row 6 closes.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Gap closed: the other two surfaces (welcome + valuation)

My previous comment only evidenced one of the three surfaces this PR touches. The chat#1889 matrix row reads "Shared renderEmailLayout adopted across welcome + valuation + weekly-report" — the live send exercised processAndSendEmail (the generic path the weekly report and agent emails use), and I never showed the welcome or valuation emails. Rendering both here.

Each is produced by calling this branch's actual exported renderer, so what you see is byte-for-byte what the deployed code emits.

Welcome — buildWelcomeEmail()

Subject: Welcome to Recoup · 6,128 bytes

welcome email under the shared layout

Wordmark header, eyebrow + display headline, the five step rows with artwork, black CTA button (Confirm your roster →${CHAT_APP_URL}/setup), reply-note footer below the divider, platform line outside the card.

Valuation — renderValuationReportHtml()

Subject: Your catalog valuation is ready · 6,876 bytes. Rendered with representative params (artist + followers, low/mid/high band, stat row, three release rows, deep link).

valuation email under the shared layout

Same chrome, with the valuation block and release table inside the card, and its own footer ("You're receiving this because you ran a catalog valuation on Recoup.") rather than the reply note — confirming footerHtml is per-email, not hardcoded.

All three surfaces, consolidated

Surface Entry point How verified Result
Weekly report / agent emails processAndSendEmail Live send through the preview (e3fbd069) → Resend baa34a04, email_send_log 88569abb status=sent
Welcome buildWelcomeEmail Rendered from the branch, screenshot above
Valuation renderValuationReportHtml Rendered from the branch, screenshot above

One shared wrapper across all three: #f7f7f7 canvas, white card with shadow-as-border (no CSS border), Plus Jakarta Sans + system fallbacks, wordmark header, per-email footer below a divider, platform line outside the card. The font-stack quoting fix in this PR is load-bearing for every one of them — single-quoted family names, because the stack is interpolated into double-quoted style="…" attributes and double quotes would terminate the attribute early, silently dropping the font and every declaration after it.

Unrelated observation, for api#787

The welcome email above still renders five numbered steps, including "4. See your baseline valuation." That's the count api#787 (PR D) reduces to four so app and email agree, presenting the valuation as the payoff instead. Nothing to change here — noting it since this screenshot is the current-state baseline that PR will be diffed against.

@sweetmantech
sweetmantech merged commit c0a7c7d into main Jul 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant