mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 06:47:10 +10:00
feat: add custom brand colours to emails (#3005)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/.react-router/
|
||||
/build/
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user