Skip to content

feat(email): one shared layout for every Studio email - #463

Merged
ABB65 merged 3 commits into
mainfrom
feat/email-standardisation
Oct 16, 2026
Merged

ABB65 merged 3 commits into
mainfrom
feat/email-standardisation

Conversation

@ABB65

@ABB65 ABB65 commented Oct 16, 2026

Copy link
Copy Markdown
Member

What

Every email Studio sends now goes through one layout (server/utils/email-layout.ts, renderEmail(parts, chrome, env) -> { html, text }), matching the approved payment-received reference: 600px light-only table layout, hosted logo, heading, lead, facts, quote, button, notes, sign-off, footer. Each email also has a plain-text part.

  • Logo is a hosted asset, public/email/logo.png, loaded from <siteUrl>/email/logo.png (data URIs are blocked by Gmail/Outlook). Width/height/alt set. No hero images (none invented).
  • Copy stays in the content layer. The email-templates model is now structured (heading, lead, facts, quote, cta_label, cta_url, notes, about, preheader) instead of raw HTML; layout labels (sign-off, footer) are email.* keys in ui-strings. Note: the content files were edited directly, not through the MCP write tools (not available in this session); contentrain-query generate runs clean on them.
  • Escaping. All {params} are escaped at render time; the only markup in copy is **bold**, `code`, [label]({urlParam}). A user value is never read as markup or as a link; buttons only render for absolute http(s) URLs; subjects have line breaks stripped. The two callers that pre-escaped (comments, forms) now pass raw values. usage_alert.resets_on lost its <strong> tags for the same reason.
  • Reply-To. The copy says "reply to this email" in 5 places but Studio never set Reply-To. It is now set from emailReplyTo (NUXT_EMAIL_REPLY_TO, default support@contentrain.io, the same address migrate uses).
  • Supabase pair. supabase/templates/*.html are generated from the same layout (supabase-* slugs, {{ .SiteURL }}/{{ .ConfirmationURL }}); a test fails when a committed file drifts (pnpm email:supabase rewrites them). Invite keeps its 24 h expiry line (Supabase's own).
  • pnpm email:previews writes every email with sample data to .tmp/email-previews.

Needs founder

  • Legal name and address: none exists in the repo, so the footer omits that line.
  • Confirm support@contentrain.io is a monitored inbox, or set NUXT_EMAIL_REPLY_TO on Railway (not touched here).
  • GitHub (https://github.com/Contentrain) and privacy (https://contentrain.io/legal/privacy-policy) URLs are inferred from iterum; confirm. LinkedIn / X links of the reference are not in the footer.

Sender inventory

studio — transport: useEmailProvider() -> Resend (server/providers/resend-email.ts); copy: email-templates collection via emailTemplate(slug, params) (server/utils/content-strings.ts)

# Email (template slug) Trigger Recipient Sender code
1 Magic link (auth-magic-link) sign-in with email, managed auth the person signing in server/providers/managed-auth.ts:547-548
2 Account invitation (auth-invite) invite of a new user, managed auth invited address server/providers/managed-auth.ts:568-569
3 Magic link / invite / confirm (Supabase pair) Supabase Auth sends them itself over its SMTP the person static supabase/templates/{magic-link,invite,confirmation}.html + supabase/config.toml (now generated from the same layout; slugs supabase-*)
4 Added to workspace (invite-added) invite of a user who already has an account invited user server/utils/db.ts:209-222
5 Invite reminder (invite-reminder) admin presses "resend" on a pending member invited user server/api/workspaces/[workspaceId]/members/[memberId]/resend.post.ts:61-70
6 Trial ending (trial-ending) trial-reminder cron workspace owner server/plugins/trial-reminder.ts:108-121
7 Subscription activated (subscription-activated) billing webhook: first paid order / activation workspace owner server/api/billing/webhook/[provider].post.ts (sendBillingEmail, :182-230; callers :85, :431, :669, :700)
8 Payment recovered (payment-recovered) billing webhook: charge goes through after a failure workspace owner same (:90)
9 Payment failed (payment-failed) billing webhook: renewal charge fails workspace owner same (:557)
10 Cancel scheduled (subscription-cancel-scheduled) billing webhook workspace owner same (:568)
11 Subscription canceled (subscription-canceled) billing webhook workspace owner same (:606)
12 Usage warning 80% (usage-warning) usage-alerts cron workspace owner server/utils/usage-alerts.ts:190-213 via server/plugins/usage-alerts.ts:38
13 Usage limit reached (usage-limit-reached) usage-alerts cron workspace owner same
14 Overage started (usage-overage-started) usage-alerts cron workspace owner same
15 New form submission (form-submitted) public form submit workspace owner + admins server/utils/form-types.ts:116-138
16 New comment (comment-pending, comment-published) public comment submit workspace owner + admins server/utils/comment-notifications.ts:24-44
17 Webhook delivery failed (webhook-dlq-alert) outbound webhook lands in the DLQ (ee) workspace admins ee/enterprise/webhook-dispatch.ts:214-253

Not senders: account deletion (typed-email confirmation in the UI, no email, per CLAUDE.md); Polar/Stripe receipts (the payment provider's own, outside the repo).

Gates

  • pnpm lint: 0 errors (7 pre-existing warnings)
  • pnpm typecheck: clean
  • pnpm test:unit: 202 files passed, 1 skipped; 2323 tests passed, 2 skipped
  • pnpm test:integration: 53 files passed, 1 failed; 516 passed, 1 failed. The failure, billing-webhook ... a move onto a v2 product changes the unit through the converting call, is date-dependent (expects period key 2026-09-15, gets 2026-10-15) and unrelated to email. Same on untouched main.
  • pnpm test:nuxt: 52 files, 358 tests passed
  • node scripts/check-dictionary-keys.mjs: all literal keys defined

Every email the app sends (managed-auth magic link and invite, workspace
invites, billing, trial reminder, usage alerts, form and comment
notifications, webhook alerts) and the Supabase auth templates now render
through one layout in server/utils/email-layout.ts: 600px table layout,
hosted logo at /email/logo.png, heading, lead, facts, quote, button, notes,
sign-off and footer, with a plain-text part for each.

- Copy stays in the email-templates collection; the model is now structured
  (heading, lead, facts, quote, cta, notes) instead of raw HTML. Layout
  labels live in ui-strings (email.*).
- All params are escaped at render time; copy takes only bold, code and
  link markup, so a user value can never inject HTML or a link.
- Reply-To is set from emailReplyTo, so the 'reply to this email' lines
  are true.
- supabase/templates/*.html are generated from the same layout and a test
  fails when they drift.
- pnpm email:previews writes every email with sample data.
@ABB65

ABB65 commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

t3 read @b843b05e: OK. The notes below are non-blocking.

  1. Escaping: holds.
    • rich() (server/utils/email-layout.ts) escapes both the literal template text and every {param}. A param value is never re-parsed as **, ` or [link].
    • A link comes only from a template's [label]({param}) with an absolute http(s) value.
    • Comment text: quote is escapeHtml(...) plus <br> (renderEmail), so authorName/excerpt go in escaped.
    • Form fields: extras.facts puts the label through escapeHtml in factsTable and the value through rich('{value}', …).
    • Subjects are plain text, collapsed to one line (oneLine), so a value cannot add a header.
    • The CTA is only drawn for an ^https?:// URL and is escaped in button().
  2. Links and logo: absolute when public.siteUrl is. The logo is ${siteUrl}/email/logo.png; the footer comes from emailLinks (https defaults). Two notes:
    • (a) Nothing enforces https or non-empty. With siteUrl unset, the logo and the "Studio" link go relative. In billing and trial emails billingUrl is then relative, so the CTA is dropped silently, where main drew a relative button. Suggestion: one console.warn in emailEnv() when siteUrl is not https, outside localhost.
    • (b) The Supabase templates use {{ .SiteURL }} for the logo and home link, so GoTrue's site_url must be the public https URL. It's http://localhost:3000 in supabase/config.toml for local; worth one line next to the NUXT_EMAIL_REPLY_TO line in docs/DEPLOYMENT.md.
  3. Reply-To docs: partial. It's in .env.example and docs/DEPLOYMENT.md. It's missing from the env-key tables in the docs repo: studio-docs/docs/developer/environment-variables.md:140-141, where the NUXT_EMAIL_SENDER_* rows are, and self-hosting.md:318. Same for NUXT_EMAIL_LINKS_*. That's a studio-docs follow-up, not this PR.
  4. GoTrue variables intact. confirmation, invite and magic-link each keep exactly one {{ .ConfirmationURL }}, as on main, unescaped in href. {{ .SiteURL }} is added 3× (logo, home link). No other GoTrue variable existed on main.
  5. Dictionary: complete. The 11 email.* keys added to ui-strings are exactly the 11 that emailChrome() reads. pnpm check:strings passes. Every {param} in the 20 templates is supplied by its caller: I checked all 10 emailTemplate( call sites. resetDate is passed by usage-alerts but unused, which is harmless.

Checks (detached worktree at b843b05, after contentrain-query generate):

  • unit: 2323 passed / 2 skipped
  • nuxt: 358 passed
  • integration: 516 passed, 1 failed
  • lint 0, typecheck 0

The one integration red is the billing-webhook "v2 product" test. It is date-dependent, fails identically on untouched main 2438d9f, and is fixed separately in #464 (test-only).

@ABB65
ABB65 merged commit f536fcd into main Oct 16, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/email-standardisation branch October 16, 2026 13:38
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