From 0bf58ca66e842f9c7e93c78b04664c15180e8f77 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 22 Jun 2026 14:33:34 +1000 Subject: [PATCH] feat: add custom brand colours to emails (#3005) --- packages/email/package.json | 5 +- packages/email/preview/.gitignore | 2 + packages/email/preview/app/app.css | 9 + .../preview/app/components/playground.tsx | 337 +++++++++++++++ .../preview/app/components/prop-fields.tsx | 113 +++++ packages/email/preview/app/entry.client.tsx | 12 + packages/email/preview/app/entry.server.tsx | 56 +++ packages/email/preview/app/lib/templates.tsx | 407 ++++++++++++++++++ packages/email/preview/app/lib/viewports.ts | 10 + packages/email/preview/app/root.tsx | 30 ++ packages/email/preview/app/routes.ts | 7 + packages/email/preview/app/routes/$slug.tsx | 35 ++ packages/email/preview/app/routes/_index.tsx | 13 + .../email/preview/app/routes/api.render.tsx | 61 +++ packages/email/preview/postcss.config.cjs | 6 + packages/email/preview/react-router.config.ts | 6 + packages/email/preview/tailwind.config.cjs | 24 ++ packages/email/preview/tsconfig.json | 30 ++ packages/email/preview/vite.config.ts | 72 ++++ packages/email/providers/branding.tsx | 2 + packages/email/render.tsx | 57 ++- .../template-access-auth-2fa.tsx | 14 +- .../template-admin-user-created.tsx | 14 +- .../template-confirmation-email.tsx | 8 +- .../template-custom-message-body.tsx | 2 +- .../template-document-cancel.tsx | 6 +- .../template-document-completed.tsx | 8 +- .../template-document-invite.tsx | 6 +- .../template-document-pending.tsx | 6 +- .../template-document-recipient-signed.tsx | 6 +- .../template-document-rejected.tsx | 6 +- .../template-document-rejection-confirmed.tsx | 4 +- .../template-document-reminder.tsx | 8 +- .../template-document-self-signed.tsx | 16 +- .../template-document-super-delete.tsx | 10 +- .../template-components/template-footer.tsx | 14 +- .../template-forgot-password.tsx | 6 +- .../template-recipient-expired.tsx | 6 +- .../template-reset-password.tsx | 6 +- packages/email/templates/access-auth-2fa.tsx | 4 +- .../email/templates/admin-user-created.tsx | 4 +- .../email/templates/bulk-send-complete.tsx | 8 +- packages/email/templates/confirm-email.tsx | 4 +- .../email/templates/confirm-team-email.tsx | 14 +- packages/email/templates/document-cancel.tsx | 4 +- .../email/templates/document-completed.tsx | 4 +- .../document-created-from-direct-template.tsx | 10 +- packages/email/templates/document-invite.tsx | 8 +- packages/email/templates/document-pending.tsx | 4 +- .../templates/document-recipient-signed.tsx | 4 +- .../email/templates/document-rejected.tsx | 4 +- .../document-rejection-confirmed.tsx | 4 +- .../email/templates/document-reminder.tsx | 6 +- .../email/templates/document-self-signed.tsx | 4 +- .../email/templates/document-super-delete.tsx | 4 +- packages/email/templates/forgot-password.tsx | 4 +- ...organisation-account-link-confirmation.tsx | 12 +- .../email/templates/organisation-delete.tsx | 10 +- .../email/templates/organisation-invite.tsx | 16 +- .../email/templates/organisation-join.tsx | 10 +- .../email/templates/organisation-leave.tsx | 10 +- .../templates/organisation-limit-alert.tsx | 10 +- .../email/templates/recipient-expired.tsx | 4 +- .../recipient-removed-from-document.tsx | 6 +- packages/email/templates/reset-password.tsx | 12 +- packages/email/templates/team-delete.tsx | 10 +- .../email/templates/team-email-removed.tsx | 10 +- packages/email/tsconfig.json | 2 +- .../server-only/email/get-email-context.ts | 30 +- packages/lib/utils/email-branding-colors.ts | 105 +++++ .../utils/team-global-settings-to-branding.ts | 10 + 71 files changed, 1599 insertions(+), 192 deletions(-) create mode 100644 packages/email/preview/.gitignore create mode 100644 packages/email/preview/app/app.css create mode 100644 packages/email/preview/app/components/playground.tsx create mode 100644 packages/email/preview/app/components/prop-fields.tsx create mode 100644 packages/email/preview/app/entry.client.tsx create mode 100644 packages/email/preview/app/entry.server.tsx create mode 100644 packages/email/preview/app/lib/templates.tsx create mode 100644 packages/email/preview/app/lib/viewports.ts create mode 100644 packages/email/preview/app/root.tsx create mode 100644 packages/email/preview/app/routes.ts create mode 100644 packages/email/preview/app/routes/$slug.tsx create mode 100644 packages/email/preview/app/routes/_index.tsx create mode 100644 packages/email/preview/app/routes/api.render.tsx create mode 100644 packages/email/preview/postcss.config.cjs create mode 100644 packages/email/preview/react-router.config.ts create mode 100644 packages/email/preview/tailwind.config.cjs create mode 100644 packages/email/preview/tsconfig.json create mode 100644 packages/email/preview/vite.config.ts create mode 100644 packages/lib/utils/email-branding-colors.ts diff --git a/packages/email/package.json b/packages/email/package.json index f7791ef5b..bb549f70b 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -12,12 +12,13 @@ "index.ts" ], "scripts": { - "dev": "email dev --port 3002 --dir templates", + "dev": "react-router dev --config preview/vite.config.ts", + "preview:build": "react-router build --config preview/vite.config.ts", "clean": "rimraf node_modules" }, "dependencies": { - "@documenso/tailwind-config": "*", "@documenso/nodemailer-resend": "4.0.0", + "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", "@react-email/code-block": "0.2.0", diff --git a/packages/email/preview/.gitignore b/packages/email/preview/.gitignore new file mode 100644 index 000000000..d41162243 --- /dev/null +++ b/packages/email/preview/.gitignore @@ -0,0 +1,2 @@ +/.react-router/ +/build/ diff --git a/packages/email/preview/app/app.css b/packages/email/preview/app/app.css new file mode 100644 index 000000000..32dbdf8f8 --- /dev/null +++ b/packages/email/preview/app/app.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + margin: 0; + padding: 0; +} diff --git a/packages/email/preview/app/components/playground.tsx b/packages/email/preview/app/components/playground.tsx new file mode 100644 index 000000000..6cdbd53e2 --- /dev/null +++ b/packages/email/preview/app/components/playground.tsx @@ -0,0 +1,337 @@ +import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import type { FieldConfig } from '../lib/templates'; +import { templates } from '../lib/templates'; +import { viewports } from '../lib/viewports'; +import { PropFields } from './prop-fields'; + +type Theme = 'light' | 'dark'; + +const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const; + +const LANGUAGE_LABELS: Record = { + en: 'English', + de: 'German', + fr: 'French', + es: 'Spanish', + it: 'Italian', + nl: 'Dutch', + pl: 'Polish', + 'pt-BR': 'Portuguese (Brazil)', + ja: 'Japanese', + ko: 'Korean', + zh: 'Chinese', +}; + +const DEFAULT_COLORS = { + primary: '#a2e771', + primaryForeground: '#162c07', + background: '#ffffff', + foreground: '#0f172a', +}; + +type PlaygroundProps = { + slug: string; + fields: Record; + defaultProps: Record; +}; + +export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => { + const navigate = useNavigate(); + + const [props, setProps] = useState(defaultProps); + const [html, setHtml] = useState(''); + const [loading, setLoading] = useState(false); + + const [theme, setTheme] = useState('light'); + const [viewportIndex, setViewportIndex] = useState(2); + const [lang, setLang] = useState('en'); + + const [brandingEnabled, setBrandingEnabled] = useState(false); + const [colors, setColors] = useState(DEFAULT_COLORS); + + const debounceRef = useRef>(undefined); + + const groupedTemplates = useMemo(() => { + const entries = Object.entries(templates); + + return GROUP_ORDER.map((group) => ({ + group, + entries: entries.filter(([, def]) => def.group === group), + })).filter((section) => section.entries.length > 0); + }, []); + + const fetchHtml = useCallback( + async (currentProps: Record, currentLang: string, brandColors: typeof colors | null) => { + setLoading(true); + + try { + const response = await fetch('/api/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug, + props: currentProps, + lang: currentLang, + colors: brandColors, + assetBaseUrl: window.location.origin, + }), + }); + + if (response.ok) { + setHtml(await response.text()); + } + } finally { + setLoading(false); + } + }, + [slug], + ); + + // Reset props when navigating to a different template. + useEffect(() => { + setProps(defaultProps); + }, [defaultProps]); + + // Re-render on any input change (debounced). + useEffect(() => { + clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + void fetchHtml(props, lang, brandingEnabled ? colors : null); + }, 250); + + return () => clearTimeout(debounceRef.current); + }, [props, lang, brandingEnabled, colors, fetchHtml]); + + const handlePropChange = (key: string, value: unknown) => { + setProps((prev) => ({ ...prev, [key]: value })); + }; + + const handleColorChange = (key: keyof typeof colors, value: string) => { + setColors((prev) => ({ ...prev, [key]: value })); + }; + + // Force dark mode inside the iframe by neutralising the prefers-color-scheme + // media query (color-scheme alone doesn't trigger it inside an iframe). + const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html; + + const viewport = viewports[viewportIndex]; + + return ( +
+ {/* Sidebar */} + + + {/* Props panel */} +
+

Props

+ +
+ + {/* Main */} +
+ + +
+
+