feat: add custom brand colours to emails (#3005)

This commit is contained in:
Lucas Smith
2026-06-22 14:33:34 +10:00
committed by GitHub
parent dee3259088
commit 0bf58ca66e
71 changed files with 1599 additions and 192 deletions
+3 -2
View File
@@ -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",
+2
View File
@@ -0,0 +1,2 @@
/.react-router/
/build/
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
padding: 0;
}
@@ -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<string, string> = {
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<string, FieldConfig>;
defaultProps: Record<string, unknown>;
};
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<Theme>('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<ReturnType<typeof setTimeout>>(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<string, unknown>, 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 (
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
{/* Sidebar */}
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
<div className="border-neutral-200 border-b px-4 py-3">
<h1 className="font-semibold text-sm">Email Preview</h1>
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
</div>
<nav className="flex-1 px-2 py-2">
{groupedTemplates.map((section) => (
<div key={section.group} className="mb-3">
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
{section.group}
</div>
{section.entries.map(([id, def]) => (
<button
key={id}
type="button"
onClick={() => navigate(`/${id}`)}
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
}`}
>
{def.name}
</button>
))}
</div>
))}
</nav>
</aside>
{/* Props panel */}
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
<PropFields fields={fields} values={props} onChange={handlePropChange} />
</section>
{/* Main */}
<main className="flex h-full flex-1 flex-col overflow-hidden">
<Toolbar
theme={theme}
setTheme={setTheme}
viewportIndex={viewportIndex}
setViewportIndex={setViewportIndex}
lang={lang}
setLang={setLang}
brandingEnabled={brandingEnabled}
setBrandingEnabled={setBrandingEnabled}
colors={colors}
onColorChange={handleColorChange}
loading={loading}
/>
<div
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
}`}
>
<div
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
style={{ width: viewport.width }}
>
<iframe
title={`${viewport.name} ${theme}`}
srcDoc={displayHtml}
className="h-[calc(100vh-8rem)] w-full border-0"
style={{ colorScheme: theme }}
/>
</div>
</div>
</main>
</div>
);
};
type ToolbarProps = {
theme: Theme;
setTheme: (theme: Theme) => void;
viewportIndex: number;
setViewportIndex: (index: number) => void;
lang: string;
setLang: (lang: string) => void;
brandingEnabled: boolean;
setBrandingEnabled: (enabled: boolean) => void;
colors: typeof DEFAULT_COLORS;
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
loading: boolean;
};
const Toolbar = (props: ToolbarProps) => {
return (
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
<SegmentedControl
label="Theme"
value={props.theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
onChange={(value) => props.setTheme(value as Theme)}
/>
<SegmentedControl
label="Viewport"
value={String(props.viewportIndex)}
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
onChange={(value) => props.setViewportIndex(Number(value))}
/>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<span className="font-medium">Language</span>
<select
value={props.lang}
onChange={(event) => props.setLang(event.target.value)}
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
>
{SUPPORTED_LANGUAGE_CODES.map((code) => (
<option key={code} value={code}>
{LANGUAGE_LABELS[code] ?? code}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<input
type="checkbox"
checked={props.brandingEnabled}
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
/>
<span className="font-medium">Brand colours</span>
</label>
{props.brandingEnabled && (
<div className="flex items-center gap-3">
<ColorInput
label="Primary"
value={props.colors.primary}
onChange={(value) => props.onColorChange('primary', value)}
/>
<ColorInput
label="On primary"
value={props.colors.primaryForeground}
onChange={(value) => props.onColorChange('primaryForeground', value)}
/>
<ColorInput
label="Background"
value={props.colors.background}
onChange={(value) => props.onColorChange('background', value)}
/>
<ColorInput
label="Text"
value={props.colors.foreground}
onChange={(value) => props.onColorChange('foreground', value)}
/>
</div>
)}
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
</div>
);
};
type SegmentedControlProps = {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
};
const SegmentedControl = (props: SegmentedControlProps) => {
return (
<div className="flex items-center gap-1.5">
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
<div className="flex overflow-hidden rounded-md border border-neutral-300">
{props.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => props.onChange(option.value)}
className={`px-2.5 py-1 text-xs transition-colors ${
props.value === option.value
? 'bg-neutral-900 text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
};
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput = (props: ColorInputProps) => {
return (
<label className="flex items-center gap-1 text-neutral-600 text-xs">
<span>{props.label}</span>
<input
type="color"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
/>
</label>
);
};
@@ -0,0 +1,113 @@
import type { FieldConfig } from '../lib/templates';
type PropFieldsProps = {
fields: Record<string, FieldConfig>;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
};
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
const entries = Object.entries(fields);
if (entries.length === 0) {
return <p className="text-neutral-400 text-xs">No editable props.</p>;
}
return (
<div className="grid gap-3">
{entries.map(([key, field]) => (
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
))}
</div>
);
};
type PropFieldProps = {
name: string;
field: FieldConfig;
value: unknown;
onChange: (value: unknown) => void;
};
const inputClass =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
const id = `prop-${name}`;
return (
<div className="grid gap-1">
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
{field.label}
</label>
{field.type === 'text' && (
<input
id={id}
className={inputClass}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'textarea' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'number' && (
<input
id={id}
type="number"
className={inputClass}
value={value === undefined || value === null ? '' : String(value)}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
/>
)}
{field.type === 'boolean' && (
<input
id={id}
type="checkbox"
className="h-4 w-4"
checked={Boolean(value)}
onChange={(event) => onChange(event.target.checked)}
/>
)}
{field.type === 'list' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={Array.isArray(value) ? value.join('\n') : ''}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
/>
)}
{field.type === 'select' && field.options && (
<select
id={id}
className={inputClass}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
</div>
);
};
@@ -0,0 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
@@ -0,0 +1,56 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
});
setTimeout(abort, streamTimeout + 1000);
});
}
@@ -0,0 +1,407 @@
import type { ComponentType } from 'react';
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
import { ResetPasswordTemplate } from '../../../templates/reset-password';
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
export type FieldConfig = {
type: FieldType;
label: string;
description?: string;
placeholder?: string;
default: unknown;
options?: { label: string; value: string }[];
};
export type TemplateDefinition = {
/** Human label for the sidebar. */
name: string;
/** Loose grouping for the sidebar. */
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>;
/** Editable props surfaced in the preview UI. */
fields: Record<string, FieldConfig>;
};
// --- Reusable field presets ---
const documentNameField: FieldConfig = {
type: 'text',
label: 'Document name',
default: 'Open Source Pledge.pdf',
};
const recipientNameField: FieldConfig = {
type: 'text',
label: 'Recipient name',
default: 'Lucas Smith',
};
const roleField: FieldConfig = {
type: 'select',
label: 'Recipient role',
default: 'SIGNER',
options: [
{ label: 'Signer', value: 'SIGNER' },
{ label: 'Viewer', value: 'VIEWER' },
{ label: 'Approver', value: 'APPROVER' },
{ label: 'CC', value: 'CC' },
{ label: 'Assistant', value: 'ASSISTANT' },
],
};
/**
* Explicit template registry. Each entry maps a slug → component + editable
* `fields`. The slug is the route param (`/:slug`) and matches the source
* filename (sans extension).
*
* `fields` drives both the default preview values AND the editable inputs in
* the UI, so production templates stay free of preview-only defaults.
*/
export const templates: Record<string, TemplateDefinition> = {
// ---- Documents ----
'document-invite': {
name: 'Document invite',
group: 'Documents',
component: DocumentInviteEmailTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
documentName: documentNameField,
role: roleField,
customBody: {
type: 'textarea',
label: 'Custom message',
default: '',
description: 'Leave blank to use the default invite copy.',
},
},
},
'document-completed': {
name: 'Document completed',
group: 'Documents',
component: DocumentCompletedEmailTemplate,
fields: {
documentName: documentNameField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-self-signed': {
name: 'Document self-signed',
group: 'Documents',
component: DocumentSelfSignedEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-pending': {
name: 'Document pending',
group: 'Documents',
component: DocumentPendingEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-reminder': {
name: 'Document reminder',
group: 'Documents',
component: DocumentReminderEmailTemplate,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
role: roleField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-cancel': {
name: 'Document cancelled',
group: 'Documents',
component: DocumentCancelTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
documentName: documentNameField,
cancellationReason: {
type: 'textarea',
label: 'Cancellation reason',
default: '',
description: 'Optional. Blank renders no reason block.',
},
},
},
'document-rejected': {
name: 'Document rejected',
group: 'Documents',
component: DocumentRejectedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
rejectionReason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-rejection-confirmed': {
name: 'Document rejection confirmed',
group: 'Documents',
component: DocumentRejectionConfirmedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
reason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-created-from-direct-template': {
name: 'Document created (direct template)',
group: 'Documents',
component: DocumentCreatedFromDirectTemplateEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-super-delete': {
name: 'Document deleted (admin)',
group: 'Documents',
component: DocumentSuperDeleteEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'bulk-send-complete': {
name: 'Bulk send complete',
group: 'Documents',
component: BulkSendCompleteEmail,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
successCount: { type: 'number', label: 'Success count', default: 48 },
failedCount: { type: 'number', label: 'Failed count', default: 2 },
errors: {
type: 'list',
label: 'Errors',
default: ['Row 12: invalid email', 'Row 30: missing name'],
description: 'One error per line. Rendered when failed count > 0.',
},
},
},
// ---- Recipients ----
'document-recipient-signed': {
name: 'Recipient signed',
group: 'Recipients',
component: DocumentRecipientSignedEmailTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-expired': {
name: 'Recipient expired',
group: 'Recipients',
component: RecipientExpiredTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-removed-from-document': {
name: 'Recipient removed',
group: 'Recipients',
component: RecipientRemovedFromDocumentTemplate,
fields: {
documentName: documentNameField,
},
},
// ---- Organisations ----
'organisation-invite': {
name: 'Organisation invite',
group: 'Organisations',
component: OrganisationInviteEmailTemplate,
fields: {
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-join': {
name: 'Organisation join',
group: 'Organisations',
component: OrganisationJoinEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-leave': {
name: 'Organisation leave',
group: 'Organisations',
component: OrganisationLeaveEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-delete': {
name: 'Organisation delete',
group: 'Organisations',
component: OrganisationDeleteEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-limit-alert': {
name: 'Organisation limit alert',
group: 'Organisations',
component: OrganisationLimitAlertEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-account-link-confirmation': {
name: 'Account link confirmation',
group: 'Organisations',
component: OrganisationAccountLinkConfirmationTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
// ---- Teams ----
'confirm-team-email': {
name: 'Confirm team email',
group: 'Teams',
component: ConfirmTeamEmailTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
},
},
'team-delete': {
name: 'Team delete',
group: 'Teams',
component: TeamDeleteEmailTemplate,
fields: {},
},
'team-email-removed': {
name: 'Team email removed',
group: 'Teams',
component: TeamEmailRemovedTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
},
},
// ---- Account ----
'confirm-email': {
name: 'Confirm email',
group: 'Account',
component: ConfirmEmailTemplate,
fields: {
confirmationLink: {
type: 'text',
label: 'Confirmation link',
default: 'https://documenso.com/confirm',
},
},
},
'forgot-password': {
name: 'Forgot password',
group: 'Account',
component: ForgotPasswordTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
'reset-password': {
name: 'Reset password',
group: 'Account',
component: ResetPasswordTemplate,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
},
},
'access-auth-2fa': {
name: 'Access auth 2FA',
group: 'Account',
component: AccessAuth2FAEmailTemplate,
fields: {
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
code: { type: 'text', label: 'Code', default: '123456' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
},
},
// ---- Admin ----
'admin-user-created': {
name: 'Admin user created',
group: 'Admin',
component: AdminUserCreatedTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
};
export type TemplateId = keyof typeof templates;
/** Extract the default prop values from a template's field config. */
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
const props: Record<string, unknown> = {};
for (const [key, field] of Object.entries(fields)) {
props[key] = field.default;
}
return props;
};
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];
@@ -0,0 +1,10 @@
export type Viewport = {
name: string;
width: number;
};
export const viewports: Viewport[] = [
{ name: 'Mobile', width: 390 },
{ name: 'Tablet', width: 768 },
{ name: 'Desktop', width: 1024 },
];
+30
View File
@@ -0,0 +1,30 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
export const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
};
const App = () => {
return <Outlet />;
};
export default App;
+7
View File
@@ -0,0 +1,7 @@
import { index, type RouteConfig, route } from '@react-router/dev/routes';
export default [
index('routes/_index.tsx'),
route('api/render', 'routes/api.render.tsx'),
route(':slug', 'routes/$slug.tsx'),
] satisfies RouteConfig;
@@ -0,0 +1,35 @@
import { data } from 'react-router';
import { EmailPlayground } from '../components/playground';
import { getDefaultProps, getTemplate } from '../lib/templates';
import type { Route } from './+types/$slug';
export const loader = ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const template = getTemplate(slug);
if (!template) {
throw data(`Unknown template: ${slug}`, { status: 404 });
}
return {
slug,
templateName: template.name,
fields: template.fields,
defaultProps: getDefaultProps(template.fields),
};
};
export const meta = ({ data: loaderData }: Route.MetaArgs) => {
if (!loaderData) {
return [{ title: 'Not found — Email Preview' }];
}
return [{ title: `${loaderData.templateName} — Email Preview` }];
};
const TemplatePage = ({ loaderData }: Route.ComponentProps) => {
return <EmailPlayground slug={loaderData.slug} fields={loaderData.fields} defaultProps={loaderData.defaultProps} />;
};
export default TemplatePage;
@@ -0,0 +1,13 @@
import { redirect } from 'react-router';
import { templates } from '../lib/templates';
/**
* The index has no UI of its own — redirect to the first template so the
* preview always opens on something.
*/
export const loader = () => {
const firstSlug = Object.keys(templates)[0];
return redirect(`/${firstSlug}`);
};
@@ -0,0 +1,61 @@
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { getTemplate } from '../lib/templates';
import type { Route } from './+types/api.render';
type RenderRequestBody = {
slug: string;
props: Record<string, unknown>;
lang?: string;
colors?: Record<string, string> | null;
assetBaseUrl: string;
};
/**
* POST /api/render — render an email template to HTML via the REAL production
* pipeline (`renderEmailWithI18N`), so i18n and brand-colour injection match a
* live send. Returns `text/html` for the client to drop into an iframe srcDoc.
*/
export const action = async ({ request }: Route.ActionArgs) => {
const body = (await request.json()) as RenderRequestBody;
const template = getTemplate(body.slug);
if (!template) {
return new Response(JSON.stringify({ error: `Unknown template: ${body.slug}` }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Resolve brand colours through the same resolver production uses, so the
// preview applies the same per-token fallbacks as a live send.
const brandingColors =
body.colors && Object.keys(body.colors).length > 0 ? resolveEmailBrandingColors(body.colors) : null;
const Component = template.component;
const element = <Component {...body.props} assetBaseUrl={body.assetBaseUrl} />;
const html = await renderEmailWithI18N(element, {
lang: body.lang ?? 'en',
branding: brandingColors
? {
brandingEnabled: true,
brandingUrl: '',
brandingLogo: '',
brandingCompanyDetails: '',
brandingHidePoweredBy: false,
brandingColors,
}
: undefined,
});
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
};
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: { config: './tailwind.config.cjs' },
autoprefixer: {},
},
};
@@ -0,0 +1,6 @@
import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
ssr: true,
} satisfies Config;
@@ -0,0 +1,24 @@
const path = require('node:path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [path.join(__dirname, 'app/**/*.{ts,tsx}')],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
},
},
plugins: [],
};
+30
View File
@@ -0,0 +1,30 @@
{
"include": ["**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"@documenso/email/*": ["../*"],
"@documenso/lib": ["../../lib"],
"@documenso/lib/*": ["../../lib/*"],
"@documenso/prisma": ["../../prisma"],
"@documenso/tailwind-config": ["../../tailwind-config"],
"@documenso/ui": ["../../ui"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"strict": true,
"useUnknownInCatchVariables": false
}
}
+72
View File
@@ -0,0 +1,72 @@
import path from 'node:path';
import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import tsconfigPaths from 'vite-tsconfig-paths';
/**
* Standalone Vite app for previewing Documenso emails.
*
* Emails render server-side through the real `renderEmailWithI18N` pipeline
* (see `app/routes/preview.tsx`), so the SSR config mirrors the main Remix app:
* Prisma, the tailwind config, and native modules stay external.
*/
export default defineConfig({
root: __dirname,
css: {
postcss: {
plugins: [tailwindcss(path.join(__dirname, 'tailwind.config.cjs')), autoprefixer],
},
},
server: {
port: parseInt(process.env.PORT || '3002', 10),
strictPort: true,
},
plugins: [
// Serve the email static assets (logo, icons) under `/static` so templates'
// `assetBaseUrl="/static"` resolves to the same images production uses.
viteStaticCopy({
targets: [
{
src: path.join(__dirname, '../static') + '/*',
dest: 'static',
},
],
}),
reactRouter(),
macrosPlugin(),
lingui(),
tsconfigPaths(),
],
ssr: {
noExternal: ['@documenso/email'],
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
'pdfjs-dist',
'@google-cloud/kms',
'@google-cloud/secret-manager',
],
},
optimizeDeps: {
exclude: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'sharp',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
'lightningcss',
'fsevents',
],
},
});
+2
View File
@@ -1,3 +1,4 @@
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { createContext, useContext } from 'react';
type BrandingContextValue = {
@@ -6,6 +7,7 @@ type BrandingContextValue = {
brandingLogo: string;
brandingCompanyDetails: string;
brandingHidePoweredBy: boolean;
brandingColors?: EmailBrandingColors;
};
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
+52 -5
View File
@@ -1,4 +1,6 @@
import config from '@documenso/tailwind-config';
import { DEFAULT_BRAND_COLORS } from '@documenso/lib/constants/theme';
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import type { I18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import * as ReactEmail from '@react-email/render';
@@ -11,19 +13,62 @@ export type RenderOptions = ReactEmail.Options & {
i18n?: I18n;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
/**
* The default email token set: the shadcn theme tokens, sourced as hex from
* `DEFAULT_BRAND_COLORS` (which mirrors `theme.css`). Emails can't use CSS
* variables, so these are concrete hex values baked into the Tailwind config.
*
* Resolved through the same `resolveEmailBrandingColors` pipeline as tenant
* colours so the default values live in exactly one place (`DEFAULT_BRAND_COLORS`)
* and the default + tenant paths can't drift. Used when a tenant has no
* (entitled) brand colours.
*/
const DEFAULT_EMAIL_BRANDING_COLORS: EmailBrandingColors =
resolveEmailBrandingColors(DEFAULT_BRAND_COLORS) ?? DEFAULT_BRAND_COLORS;
/**
* Map the resolved colour set to flat semantic Tailwind tokens. Templates use
* these directly (`bg-primary`, `text-muted-foreground`, `border-border`, …),
* mirroring the app's shadcn tokens, instead of bespoke `slate-*`/`documenso-*`
* scale classes.
*
* Always defined: falls back to `DEFAULT_EMAIL_BRANDING_COLORS` when no tenant
* colours are supplied, so the tokens resolve whether or not custom branding is
* in play.
*/
const buildEmailColors = (brandingColors?: EmailBrandingColors): Record<string, string> => {
const c = brandingColors ?? DEFAULT_EMAIL_BRANDING_COLORS;
return {
background: c.background,
foreground: c.foreground,
muted: c.muted,
'muted-foreground': c.mutedForeground,
primary: c.primary,
'primary-foreground': c.primaryForeground,
secondary: c.secondary,
'secondary-foreground': c.secondaryForeground,
accent: c.accent,
'accent-foreground': c.accentForeground,
destructive: c.destructive,
'destructive-foreground': c.destructiveForeground,
warning: c.warning,
border: c.border,
};
};
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
const tailwindColors = buildEmailColors(branding?.brandingColors);
return ReactEmail.render(
<BrandingProvider branding={branding}>
<Tailwind
config={{
theme: {
extend: {
colors,
colors: tailwindColors,
},
},
}}
@@ -42,6 +87,8 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
throw new Error('i18n is required');
}
const tailwindColors = buildEmailColors(branding?.brandingColors);
return ReactEmail.render(
<I18nProvider i18n={i18n}>
<BrandingProvider branding={branding}>
@@ -49,7 +96,7 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
config={{
theme: {
extend: {
colors,
colors: tailwindColors,
},
},
}}
@@ -27,24 +27,24 @@ export const TemplateAccessAuth2FA = ({
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center font-semibold text-lg text-slate-900">
<Heading className="text-center font-semibold text-foreground text-lg">
<Trans>Verification Code Required</Trans>
</Heading>
<Text className="mt-2 text-center text-slate-700">
<Text className="mt-2 text-center text-foreground">
<Trans>
Hi {userName}, you need to enter a verification code to complete the document "{documentTitle}".
</Trans>
</Text>
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
<Text className="mb-2 font-medium text-slate-600 text-sm">
<Section className="mt-6 rounded-lg bg-muted p-6 text-center">
<Text className="mb-2 font-medium text-muted-foreground text-sm">
<Trans>Your verification code:</Trans>
</Text>
<Text className="font-bold text-2xl text-slate-900 tracking-wider">{code}</Text>
<Text className="font-bold text-2xl text-foreground tracking-wider">{code}</Text>
</Section>
<Text className="mt-4 text-center text-slate-600 text-sm">
<Text className="mt-4 text-center text-muted-foreground text-sm">
<Plural
value={expiresInMinutes}
one="This code will expire in # minute."
@@ -52,7 +52,7 @@ export const TemplateAccessAuth2FA = ({
/>
</Text>
<Text className="mt-4 text-center text-slate-500 text-sm">
<Text className="mt-4 text-center text-muted-foreground text-sm">
<Trans>If you didn't request this verification code, you can safely ignore this email.</Trans>
</Text>
</Section>
@@ -14,26 +14,26 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>An administrator has created a Documenso account for you.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>To get started, please set your password by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Set Password</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
</Trans>
@@ -41,10 +41,10 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
</Section>
<Section className="mt-8">
<Text className="text-center text-slate-400 text-sm">
<Text className="text-center text-muted-foreground text-sm">
<Trans>
If you didn't expect this account or have any questions, please{' '}
<Link href="mailto:support@documenso.com" className="text-documenso-500">
<Link href="mailto:support@documenso.com" className="text-primary">
contact support
</Link>
.
@@ -14,22 +14,22 @@ export const TemplateConfirmationEmail = ({ confirmationLink, assetBaseUrl }: Te
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Before you get started, please confirm your email address by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={confirmationLink}
>
<Trans>Confirm email</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)
</Trans>
@@ -18,7 +18,7 @@ export const TemplateCustomMessageBody = ({ text }: TemplateCustomMessageBodyPro
const paragraphs = normalized.split('\n\n');
return paragraphs.map((paragraph, i) => (
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-slate-400">
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-muted-foreground">
{paragraph.split('\n').map((line, j) => (
<React.Fragment key={`line-${i}-${j}`}>
{j > 0 && <br />}
@@ -22,18 +22,18 @@ export const TemplateDocumentCancel = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
{inviterName} has cancelled the document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>All signatures have been voided.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>You don't need to sign it anymore.</Trans>
</Text>
@@ -27,24 +27,24 @@ export const TemplateDocumentCompleted = ({
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
{customBody || <Trans>{documentName} was signed by all signers</Trans>}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Continue by downloading the document.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
@@ -40,7 +40,7 @@ export const TemplateDocumentInvite = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
{match({ selfSigner, organisationType, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans>
@@ -75,7 +75,7 @@ export const TemplateDocumentInvite = ({
))}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sbase no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
href={signDocumentLink}
>
{match(role)
@@ -20,18 +20,18 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-blue-500">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Waiting for others</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>{documentName} has been signed</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>
We're still waiting for other signers to sign this document.
<br />
@@ -29,20 +29,20 @@ export const TemplateDocumentRecipientSigned = ({
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>
{recipientReference} has signed "{documentName}"
</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>{recipientReference} has completed signing the document.</Trans>
</Text>
</Section>
@@ -17,7 +17,7 @@ export function TemplateDocumentRejected({
}: TemplateDocumentRejectedProps) {
return (
<div className="mt-4">
<Heading className="mb-4 text-center font-semibold text-2xl text-slate-800">
<Heading className="mb-4 text-center font-semibold text-2xl text-foreground">
<Trans>Document Rejected</Trans>
</Heading>
@@ -28,7 +28,7 @@ export function TemplateDocumentRejected({
</Text>
{rejectionReason && (
<Text className="mb-4 text-base text-slate-400">
<Text className="mb-4 text-base text-muted-foreground">
<Trans>Reason for rejection: {rejectionReason}</Trans>
</Text>
)}
@@ -39,7 +39,7 @@ export function TemplateDocumentRejected({
<Button
href={documentUrl}
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
>
<Trans>View Document</Trans>
</Button>
@@ -22,7 +22,7 @@ export function TemplateDocumentRejectionConfirmed({
<Trans>Rejection Confirmed</Trans>
</Heading>
<Text className="text-base text-primary">
<Text className="text-base text-foreground">
<Trans>
This email confirms that you have rejected the document{' '}
<strong className="font-bold">"{documentName}"</strong> sent by {documentOwnerName}.
@@ -30,7 +30,7 @@ export function TemplateDocumentRejectionConfirmed({
</Text>
{reason && (
<Text className="font-medium text-base text-slate-400">
<Text className="font-medium text-base text-muted-foreground">
<Trans>Rejection reason: {reason}</Trans>
</Text>
)}
@@ -31,18 +31,18 @@ export const TemplateDocumentReminder = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
Reminder: Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Hi {recipientName},</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
@@ -54,7 +54,7 @@ export const TemplateDocumentReminder = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={signDocumentLink}
>
{match(role)
@@ -25,25 +25,21 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section className="flex-row items-center justify-center">
<Section>
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mt-6 mb-0 text-center font-semibold text-lg text-primary">
<Text className="mt-6 mb-0 text-center font-semibold text-foreground text-lg">
<Trans>You have signed {documentName}</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>
Create a{' '}
<Link
href={signUpUrl}
target="_blank"
className="whitespace-nowrap text-documenso-700 hover:text-documenso-600"
>
<Link href={signUpUrl} target="_blank" className="whitespace-nowrap text-primary hover:text-primary">
free account
</Link>{' '}
to access your signed documents at any time.
@@ -53,14 +49,14 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section className="mt-8 mb-6 text-center">
<Button
href={signUpUrl}
className="mr-4 rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Trans>Create account</Trans>
</Button>
<Button
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
@@ -15,26 +15,26 @@ export const TemplateDocumentDelete = ({ reason, documentName, assetBaseUrl }: T
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mt-6 mb-0 text-left font-semibold text-lg text-primary">
<Text className="mt-6 mb-0 text-left font-semibold text-foreground text-lg">
<Trans>Your document has been deleted by an admin!</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
<Trans>"{documentName}" has been deleted by an admin.</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
<Trans>
This document can not be recovered, if you would like to dispute the reason for future documents please
contact support.
</Trans>
</Text>
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 text-left text-base text-muted-foreground">
<Trans>The reason provided for deletion is the following:</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400 italic">{reason}</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground italic">{reason}</Text>
</Section>
</>
);
@@ -18,10 +18,10 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
return (
<Section>
{reportUrl && (
<Text className="my-4 text-base text-slate-400">
<Text className="my-4 text-base text-muted-foreground">
<Trans>
Did not expect this email?{' '}
<Link className="text-[#7AC455]" href={reportUrl}>
<Link className="text-primary" href={reportUrl}>
Click here to report the sender
</Link>
. Never sign a document you don't recognize or weren't expecting.
@@ -30,10 +30,10 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{isDocument && !branding.brandingHidePoweredBy && (
<Text className="my-4 text-base text-slate-400">
<Text className="my-4 text-base text-muted-foreground">
<Trans>
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
<Link className="text-primary" href="https://documen.so/mail-footer">
Documenso
</Link>
.
@@ -42,7 +42,7 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{branding.brandingEnabled && branding.brandingCompanyDetails && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
{branding.brandingCompanyDetails.split('\n').map((line, idx) => {
return (
<Fragment key={idx}>
@@ -55,7 +55,7 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{branding.brandingEnabled && safeBrandingUrl && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
<Link href={safeBrandingUrl} target="_blank">
{safeBrandingUrl}
</Link>
@@ -63,7 +63,7 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{!branding.brandingEnabled && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
Documenso, Inc.
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
@@ -14,17 +14,17 @@ export const TemplateForgotPassword = ({ resetPasswordLink, assetBaseUrl }: Temp
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Forgot your password?</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>That's okay, it happens! Click the button below to reset your password.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Reset Password</Trans>
@@ -25,13 +25,13 @@ export const TemplateRecipientExpired = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
Signing window expired for "{displayName}" on "{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>
The signing window for {displayName} on document "{documentName}" has expired. You can resend the document
to extend their deadline or cancel the document.
@@ -40,7 +40,7 @@ export const TemplateRecipientExpired = ({
<Section className="my-4 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-sm text-white no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={documentLink}
>
<Trans>View Document</Trans>
@@ -18,17 +18,17 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Password updated!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Your password has been updated.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
<Trans>Sign In</Trans>
+2 -2
View File
@@ -32,9 +32,9 @@ export const AccessAuth2FAEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -22,9 +22,9 @@ export const AdminUserCreatedTemplate = ({
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
@@ -28,9 +28,9 @@ export const BulkSendCompleteEmail = ({
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Text className="text-sm">
<Trans>Hi {userName},</Trans>
@@ -56,7 +56,7 @@ export const BulkSendCompleteEmail = ({
</li>
</ul>
{failedCount > 0 && (
{errors && errors.length > 0 && (
<Section className="mt-4">
<Text className="font-semibold text-lg">
<Trans>The following errors occurred:</Trans>
@@ -64,7 +64,7 @@ export const BulkSendCompleteEmail = ({
<ul className="my-2 ml-4 list-inside list-disc">
{errors.map((error, index) => (
<li key={index} className="mt-1 text-destructive text-slate-400 text-sm">
<li key={index} className="mt-1 text-destructive text-sm">
{error}
</li>
))}
+2 -2
View File
@@ -19,9 +19,9 @@ export const ConfirmEmailTemplate = ({
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -33,16 +33,16 @@ export const ConfirmTeamEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Verify your team email address</Trans>
</Text>
@@ -53,7 +53,7 @@ export const ConfirmTeamEmailTemplate = ({
</Trans>
</Text>
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto mt-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
@@ -86,7 +86,7 @@ export const ConfirmTeamEmailTemplate = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${baseUrl}/team/verify/email/${token}`}
>
<Trans>Accept</Trans>
@@ -94,7 +94,7 @@ export const ConfirmTeamEmailTemplate = ({
</Section>
</Section>
<Text className="text-center text-slate-500 text-xs">
<Text className="text-center text-muted-foreground text-xs">
<Trans>Link expires in 1 hour.</Trans>
</Text>
</Container>
+2 -2
View File
@@ -25,9 +25,9 @@ export const DocumentCancelTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -29,8 +29,8 @@ export const DocumentCompletedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -36,27 +36,27 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>
{recipientName} {action} a document by using one of your direct links
</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-slate-600 text-sm">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 text-muted-foreground text-sm">
{documentName}
</div>
<Section className="my-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={documentLink}
>
<Trans>View document</Trans>
+4 -4
View File
@@ -58,9 +58,9 @@ export const DocumentInviteEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -85,14 +85,14 @@ export const DocumentInviteEmailTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
{inviterName}{' '}
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
({inviterEmail})
</Link>
</Trans>
</Text>
)}
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
{customBody ? (
<TemplateCustomMessageBody text={customBody} />
) : (
@@ -23,8 +23,8 @@ export const DocumentPendingEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -31,8 +31,8 @@ export const DocumentRecipientSignedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -30,9 +30,9 @@ export function DocumentRejectedEmail({
<Head />
<Preview>{previewText}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -30,9 +30,9 @@ export function DocumentRejectionConfirmedEmail({
<Head />
<Preview>{previewText}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -39,9 +39,9 @@ export const DocumentReminderEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -58,7 +58,7 @@ export const DocumentReminderEmailTemplate = ({
{customBody && (
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<TemplateCustomMessageBody text={customBody} />
</Text>
</Section>
@@ -23,8 +23,8 @@ export const DocumentSelfSignedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -25,9 +25,9 @@ export const DocumentSuperDeleteEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
+2 -2
View File
@@ -22,9 +22,9 @@ export const ForgotPasswordTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -32,16 +32,16 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto h-12 w-12" assetBaseUrl={assetBaseUrl} staticAsset="building-2.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{type === 'create' ? (
<Trans>Account creation request</Trans>
) : (
@@ -94,7 +94,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={confirmationLink}
>
<Trans>Review request</Trans>
@@ -102,7 +102,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
</Section>
</Section>
<Text className="text-center text-slate-500 text-xs">
<Text className="text-center text-muted-foreground text-xs">
<Trans>Link expires in 30 minutes.</Trans>
</Text>
</Container>
@@ -37,20 +37,20 @@ export const OrganisationDeleteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
</Section>
@@ -32,16 +32,16 @@ export const OrganisationInviteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Join {organisationName} on Documenso</Trans>
</Text>
@@ -49,25 +49,25 @@ export const OrganisationInviteEmailTemplate = ({
<Trans>You have been invited to join the following organisation</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
<Text className="my-1 text-center text-base">
<Trans>
by <span className="text-slate-900">{senderName}</span>
by <span className="text-foreground">{senderName}</span>
</Trans>
</Text>
<Section className="mt-6 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${baseUrl}/organisation/invite/${token}`}
>
<Trans>Accept</Trans>
</Button>
<Button
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center font-medium text-slate-600 text-sm no-underline"
className="ml-4 inline-flex items-center justify-center rounded-lg bg-muted px-6 py-3 text-center font-medium text-muted-foreground text-sm no-underline"
href={`${baseUrl}/organisation/decline/${token}`}
>
<Trans>Decline</Trans>
@@ -34,20 +34,20 @@ export const OrganisationJoinEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>A new member has joined your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{memberName || memberEmail}
</div>
</Section>
@@ -34,20 +34,20 @@ export const OrganisationLeaveEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>A member has left your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{memberName || memberEmail}
</div>
</Section>
@@ -32,12 +32,12 @@ export const OrganisationLimitAlertEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{kind === 'quotaNearing' ? (
<Trans>Approaching Your Plan Limits</Trans>
) : (
@@ -45,7 +45,7 @@ export const OrganisationLimitAlertEmailTemplate = ({
)}
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
@@ -25,9 +25,9 @@ export const RecipientExpiredTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -24,16 +24,16 @@ export const RecipientRemovedFromDocumentTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
{inviterName} has removed you from the document
<br />"{documentName}"
+6 -6
View File
@@ -24,9 +24,9 @@ export const ResetPasswordTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -39,19 +39,19 @@ export const ResetPasswordTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
Hi, {userName}{' '}
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
<Link className="font-normal text-muted-foreground" href={`mailto:${userEmail}`}>
({userEmail})
</Link>
</Trans>
</Text>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
</Text>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
Didn't request a password change? We are here to help you secure your account, just{' '}
<Link className="font-normal text-documenso-700" href="mailto:hi@documenso.com">
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
+5 -5
View File
@@ -32,20 +32,20 @@ export const TeamDeleteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
</Section>
@@ -33,16 +33,16 @@ export const TeamEmailRemovedTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open-alert.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Team email removed</Trans>
</Text>
@@ -52,7 +52,7 @@ export const TeamEmailRemovedTemplate = ({
</Trans>
</Text>
<div className="mx-auto mt-2 mb-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto mt-2 mb-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
</Section>
+1 -1
View File
@@ -4,5 +4,5 @@
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
"exclude": ["dist", "build", "node_modules"]
"exclude": ["dist", "build", "node_modules", "preview"]
}
@@ -12,6 +12,7 @@ import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSetti
import type { Transporter } from 'nodemailer';
import { match, P } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
@@ -108,7 +109,6 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
// "no transport". Surface it (alertable) before silently falling back to the
// system mailer + Documenso sender, so the degraded organisation is findable.
if (emailContext.claims.emailTransportId && !transportResolution) {
// Todo: Logging
logger.error({
msg: 'Configured email transport could not be resolved; falling back to the system mailer',
emailTransportId: emailContext.claims.emailTransportId,
@@ -217,13 +217,21 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
const allowedEmails = getAllowedEmails(organisation);
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
);
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
if (!allowBrandedEmailColors) {
branding.brandingColors = undefined;
}
return {
allowedEmails,
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
branding,
settings: organisation.organisationGlobalSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
@@ -273,9 +281,17 @@ const handleTeamEmailContext = async (teamId: number) => {
const teamSettings = extractDerivedTeamSettings(organisation.organisationGlobalSettings, team.teamGlobalSettings);
const branding = teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false);
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
if (!allowBrandedEmailColors) {
branding.brandingColors = undefined;
}
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
branding,
settings: teamSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
+105
View File
@@ -0,0 +1,105 @@
import { colord } from 'colord';
import { DEFAULT_BRAND_COLORS } from '../constants/theme';
import type { TCssVarsSchema } from '../types/css-vars';
/**
* The `brandingColors` tokens that emails actually render — the canonical
* subset of `TCssVarsSchema`. `TCssVarsSchema` is the single source of truth
* for token names; this tuple just selects the ones email templates use, and
* both the `EmailBrandingColors` type and the resolver below derive from it.
*/
export const EMAIL_BRANDING_COLOR_KEYS = [
'background',
'foreground',
'muted',
'mutedForeground',
'primary',
'primaryForeground',
'secondary',
'secondaryForeground',
'accent',
'accentForeground',
'destructive',
'destructiveForeground',
'warning',
'border',
] as const satisfies readonly (keyof TCssVarsSchema)[];
export type EmailBrandingColorKey = (typeof EMAIL_BRANDING_COLOR_KEYS)[number];
/**
* Resolved, email-ready brand colour set.
*
* Emails cannot use CSS variables, so every value here is a concrete hex
* string. This is the shape carried through the email branding context and
* injected into the per-render Tailwind config.
*
* Derived from `TCssVarsSchema` (the persisted shape) by narrowing to the
* email token subset and making every field required: the resolver fills every
* token (tenant value or Documenso default), so consumers never see `undefined`.
*
* Produced by `resolveEmailBrandingColors`, or `null` when the tenant has no
* usable/safe colour set (callers fall back to the default Documenso palette).
*/
export type EmailBrandingColors = Required<Pick<TCssVarsSchema, EmailBrandingColorKey>>;
/**
* Normalise an arbitrary stored colour value (hex or any colord-parseable
* string) to a hex string. Returns `null` for missing/invalid input.
*
* `brandingColors` is validated loosely (`z.string()`) so values are not
* guaranteed to be valid colours — parse defensively.
*/
export const normalizeColorToHex = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
const parsed = colord(value);
if (!parsed.isValid()) {
return null;
}
return parsed.toHex();
};
/**
* Resolve a tenant's stored `brandingColors` into an email-ready colour set.
*
* Each token is taken from the tenant value when it parses to a valid colour,
* otherwise the Documenso default. We do NOT enforce contrast or readability —
* if a tenant picks a low-contrast combination that is their choice; the
* preview UI can hint at it, but the renderer just applies what was set.
*
* Returns `null` (⇒ caller uses the default Documenso palette) only when there
* is no `brandingColors` object at all.
*/
export const resolveEmailBrandingColors = (
brandingColors: TCssVarsSchema | null | undefined,
): EmailBrandingColors | null => {
if (!brandingColors) {
return null;
}
const resolve = (value: string | null | undefined, fallback: string): string =>
normalizeColorToHex(value) ?? fallback;
return {
background: resolve(brandingColors.background, DEFAULT_BRAND_COLORS.background),
foreground: resolve(brandingColors.foreground, DEFAULT_BRAND_COLORS.foreground),
muted: resolve(brandingColors.muted, DEFAULT_BRAND_COLORS.muted),
mutedForeground: resolve(brandingColors.mutedForeground, DEFAULT_BRAND_COLORS.mutedForeground),
primary: resolve(brandingColors.primary, DEFAULT_BRAND_COLORS.primary),
primaryForeground: resolve(brandingColors.primaryForeground, DEFAULT_BRAND_COLORS.primaryForeground),
secondary: resolve(brandingColors.secondary, DEFAULT_BRAND_COLORS.secondary),
secondaryForeground: resolve(brandingColors.secondaryForeground, DEFAULT_BRAND_COLORS.secondaryForeground),
accent: resolve(brandingColors.accent, DEFAULT_BRAND_COLORS.accent),
accentForeground: resolve(brandingColors.accentForeground, DEFAULT_BRAND_COLORS.accentForeground),
destructive: resolve(brandingColors.destructive, DEFAULT_BRAND_COLORS.destructive),
destructiveForeground: resolve(brandingColors.destructiveForeground, DEFAULT_BRAND_COLORS.destructiveForeground),
warning: resolve(brandingColors.warning, DEFAULT_BRAND_COLORS.warning),
border: resolve(brandingColors.border, DEFAULT_BRAND_COLORS.border),
};
};
@@ -1,12 +1,17 @@
import type { OrganisationGlobalSettings } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { ZCssVarsSchema } from '../types/css-vars';
import { resolveEmailBrandingColors } from './email-branding-colors';
export const teamGlobalSettingsToBranding = (
settings: Omit<OrganisationGlobalSettings, 'id'>,
teamId: number,
hidePoweredBy: boolean,
) => {
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
const resolvedBrandingColors = resolveEmailBrandingColors(parsedColors?.success ? parsedColors.data : null);
return {
...settings,
brandingLogo:
@@ -14,6 +19,7 @@ export const teamGlobalSettingsToBranding = (
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${teamId}`
: '',
brandingHidePoweredBy: hidePoweredBy,
brandingColors: resolvedBrandingColors ?? undefined,
};
};
@@ -22,6 +28,9 @@ export const organisationGlobalSettingsToBranding = (
organisationId: string,
hidePoweredBy: boolean,
) => {
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
const resolvedBrandingColors = resolveEmailBrandingColors(parsedColors?.success ? parsedColors.data : null);
return {
...settings,
brandingLogo:
@@ -29,5 +38,6 @@ export const organisationGlobalSettingsToBranding = (
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisationId}`
: '',
brandingHidePoweredBy: hidePoweredBy,
brandingColors: resolvedBrandingColors ?? undefined,
};
};