Merge branch 'main' into feature/pdf-placeholder-selection-fields

This commit is contained in:
Catalin Pit
2026-06-25 09:02:29 +03:00
committed by GitHub
138 changed files with 7902 additions and 2442 deletions
@@ -0,0 +1,64 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Redistribute updates recipient send status', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const [recipient] = document.recipients;
// Simulate a recipient that is stuck at NOT_SENT on a pending document
// (e.g. the initial send did not dispatch an email for them).
await prisma.recipient.update({
where: { id: recipient.id },
data: {
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
sentAt: null,
},
});
const res = await request.post(`${baseUrl}/document/redistribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
recipients: [recipient.id],
},
});
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
expect(updatedRecipient.sentAt).not.toBeNull();
});
});
@@ -0,0 +1,260 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const rejectRecipient = (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipientId: number,
reason: string,
actAsEmail?: string,
) => {
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
recipientId,
reason,
actAsEmail,
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
});
};
test.describe('Reject recipient on behalf of', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-reject-recipient',
expiresIn: null,
}));
});
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.recipientId).toBe(recipient.id);
expect(auditData.recipientEmail).toBe(recipient.email);
expect(auditData.reason).toBe('Declined out of band');
expect(auditData.isExternal).toBe(true);
// No actAsEmail supplied - the rejection defaults to the API user.
expect(auditLog!.userId).toBe(user.id);
expect(auditLog!.email).toBe(user.email);
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
});
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
const member = await seedTeamMember({ teamId: team.id });
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
// The audit log actor must be the elected member, not the API user.
expect(auditLog.userId).toBe(member.id);
expect(auditLog.email).toBe(member.email);
const auditData = auditLog.data as Record<string, unknown>;
expect(auditData.isExternal).toBe(true);
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
});
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
// A user that exists but belongs to a different team.
const { user: outsider } = await seedUser();
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
token,
envelope.id,
recipient.id,
'Declined out of band',
outsider.email,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Reject once - succeeds.
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
expect(firstRes.ok()).toBeTruthy();
// Reject again - the recipient is no longer NOT_SIGNED.
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
// The original rejection reason must remain unchanged.
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.rejectionReason).toBe('First rejection');
});
test('should not allow rejecting a recipient in another team', async ({ request }) => {
// Seed a separate team/user that owns the document.
const { user: otherUser, team: otherTeam } = await seedUser();
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Use the original team's token - it must not be able to reject.
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should return 404 for a non-existent recipient', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
const recipient = targetEnvelope.recipients[0];
// Valid recipient ID, but paired with the wrong envelope ID.
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
const { team: visTeam, owner } = await seedTeam();
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
const { token: managerToken } = await createApiToken({
userId: manager.id,
teamId: visTeam.id,
tokenName: 'manager-reject-token',
expiresIn: null,
});
// ADMIN-visibility document owned by the team owner.
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
managerToken,
envelope.id,
recipient.id,
'Should be hidden by visibility',
);
// Visibility failure surfaces as not-found, matching the canonical checks.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
});
@@ -3,6 +3,7 @@ import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-recipients-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
await page.getByLabel('Include Recipients').click();
await expect(page.getByLabel('Include Fields')).toBeDisabled();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should have neither recipients nor fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(0);
expect(duplicate.fields).toHaveLength(0);
});
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-fields-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck only "Include Fields" (recipients stay included).
await page.getByLabel('Include Fields').click();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should keep the recipient but have no fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(1);
expect(duplicate.fields).toHaveLength(0);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
timeout: 10_000,
});
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expectToastTextToBeVisible(page, 'Document re-sent');
await expectToastTextToBeVisible(page, 'Document resent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -0,0 +1,105 @@
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
/**
* Reproduces the "Team has no internal team groups" bug.
*
* When a team has member inheritance turned OFF, organisation admins/managers are
* still inherited into the team as team admins (shown with the "Group" source).
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
* removed via the team members page - attempting to do so threw a 500 ("Team has no
* internal team groups").
*
* Instead of crashing, the delete dialog must explain why the inherited member can't
* be removed and not offer a confirm button.
*/
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
// Team created with member inheritance OFF.
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
// A second organisation admin is inherited into the team as a team admin (source "Group").
await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{
name: 'Inherited Admin',
email: inheritedAdminEmail,
organisationRole: OrganisationMemberRole.ADMIN,
},
],
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
// Sanity check: the member is inherited from a group, not a direct team member.
await expect(inheritedMemberRow).toBeVisible();
await expect(inheritedMemberRow).toContainText('Group');
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
// The action stays enabled - opening it shows a dialog explaining why the inherited
// member can't be removed, rather than triggering the 500.
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await expect(page.getByText('inherited from a group').first()).toBeVisible();
// No confirm button is offered, so the broken removal can never be triggered.
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
});
/**
* Guards against over-disabling the remove action: a direct team member (one that
* belongs to the team's INTERNAL_TEAM group) must still be removable.
*/
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
const directMember = await seedTeamMember({
teamId: team.id,
name: 'Direct Member',
role: TeamMemberRole.MEMBER,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
await expect(directMemberRow).toBeVisible();
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
// The "Remove" action is enabled for direct members and removing them succeeds.
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
// The member is actually gone after reloading the members list.
await page.reload();
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
});
@@ -1,6 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -474,9 +473,12 @@ export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<Execu
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({
id: { type: 'envelopeId', id: envelope.id },
recipientId: recipient.id,
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
+3 -2
View File
@@ -12,12 +12,13 @@
"index.ts"
],
"scripts": {
"dev": "email dev --port 3002 --dir templates",
"dev": "react-router dev --config preview/vite.config.ts",
"preview:build": "react-router build --config preview/vite.config.ts",
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/tailwind-config": "*",
"@documenso/nodemailer-resend": "4.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
+2
View File
@@ -0,0 +1,2 @@
/.react-router/
/build/
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
padding: 0;
}
@@ -0,0 +1,337 @@
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { FieldConfig } from '../lib/templates';
import { templates } from '../lib/templates';
import { viewports } from '../lib/viewports';
import { PropFields } from './prop-fields';
type Theme = 'light' | 'dark';
const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const;
const LANGUAGE_LABELS: Record<string, string> = {
en: 'English',
de: 'German',
fr: 'French',
es: 'Spanish',
it: 'Italian',
nl: 'Dutch',
pl: 'Polish',
'pt-BR': 'Portuguese (Brazil)',
ja: 'Japanese',
ko: 'Korean',
zh: 'Chinese',
};
const DEFAULT_COLORS = {
primary: '#a2e771',
primaryForeground: '#162c07',
background: '#ffffff',
foreground: '#0f172a',
};
type PlaygroundProps = {
slug: string;
fields: Record<string, FieldConfig>;
defaultProps: Record<string, unknown>;
};
export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => {
const navigate = useNavigate();
const [props, setProps] = useState(defaultProps);
const [html, setHtml] = useState('');
const [loading, setLoading] = useState(false);
const [theme, setTheme] = useState<Theme>('light');
const [viewportIndex, setViewportIndex] = useState(2);
const [lang, setLang] = useState('en');
const [brandingEnabled, setBrandingEnabled] = useState(false);
const [colors, setColors] = useState(DEFAULT_COLORS);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const groupedTemplates = useMemo(() => {
const entries = Object.entries(templates);
return GROUP_ORDER.map((group) => ({
group,
entries: entries.filter(([, def]) => def.group === group),
})).filter((section) => section.entries.length > 0);
}, []);
const fetchHtml = useCallback(
async (currentProps: Record<string, unknown>, currentLang: string, brandColors: typeof colors | null) => {
setLoading(true);
try {
const response = await fetch('/api/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
props: currentProps,
lang: currentLang,
colors: brandColors,
assetBaseUrl: window.location.origin,
}),
});
if (response.ok) {
setHtml(await response.text());
}
} finally {
setLoading(false);
}
},
[slug],
);
// Reset props when navigating to a different template.
useEffect(() => {
setProps(defaultProps);
}, [defaultProps]);
// Re-render on any input change (debounced).
useEffect(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void fetchHtml(props, lang, brandingEnabled ? colors : null);
}, 250);
return () => clearTimeout(debounceRef.current);
}, [props, lang, brandingEnabled, colors, fetchHtml]);
const handlePropChange = (key: string, value: unknown) => {
setProps((prev) => ({ ...prev, [key]: value }));
};
const handleColorChange = (key: keyof typeof colors, value: string) => {
setColors((prev) => ({ ...prev, [key]: value }));
};
// Force dark mode inside the iframe by neutralising the prefers-color-scheme
// media query (color-scheme alone doesn't trigger it inside an iframe).
const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html;
const viewport = viewports[viewportIndex];
return (
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
{/* Sidebar */}
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
<div className="border-neutral-200 border-b px-4 py-3">
<h1 className="font-semibold text-sm">Email Preview</h1>
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
</div>
<nav className="flex-1 px-2 py-2">
{groupedTemplates.map((section) => (
<div key={section.group} className="mb-3">
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
{section.group}
</div>
{section.entries.map(([id, def]) => (
<button
key={id}
type="button"
onClick={() => navigate(`/${id}`)}
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
}`}
>
{def.name}
</button>
))}
</div>
))}
</nav>
</aside>
{/* Props panel */}
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
<PropFields fields={fields} values={props} onChange={handlePropChange} />
</section>
{/* Main */}
<main className="flex h-full flex-1 flex-col overflow-hidden">
<Toolbar
theme={theme}
setTheme={setTheme}
viewportIndex={viewportIndex}
setViewportIndex={setViewportIndex}
lang={lang}
setLang={setLang}
brandingEnabled={brandingEnabled}
setBrandingEnabled={setBrandingEnabled}
colors={colors}
onColorChange={handleColorChange}
loading={loading}
/>
<div
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
}`}
>
<div
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
style={{ width: viewport.width }}
>
<iframe
title={`${viewport.name} ${theme}`}
srcDoc={displayHtml}
className="h-[calc(100vh-8rem)] w-full border-0"
style={{ colorScheme: theme }}
/>
</div>
</div>
</main>
</div>
);
};
type ToolbarProps = {
theme: Theme;
setTheme: (theme: Theme) => void;
viewportIndex: number;
setViewportIndex: (index: number) => void;
lang: string;
setLang: (lang: string) => void;
brandingEnabled: boolean;
setBrandingEnabled: (enabled: boolean) => void;
colors: typeof DEFAULT_COLORS;
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
loading: boolean;
};
const Toolbar = (props: ToolbarProps) => {
return (
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
<SegmentedControl
label="Theme"
value={props.theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
onChange={(value) => props.setTheme(value as Theme)}
/>
<SegmentedControl
label="Viewport"
value={String(props.viewportIndex)}
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
onChange={(value) => props.setViewportIndex(Number(value))}
/>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<span className="font-medium">Language</span>
<select
value={props.lang}
onChange={(event) => props.setLang(event.target.value)}
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
>
{SUPPORTED_LANGUAGE_CODES.map((code) => (
<option key={code} value={code}>
{LANGUAGE_LABELS[code] ?? code}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<input
type="checkbox"
checked={props.brandingEnabled}
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
/>
<span className="font-medium">Brand colours</span>
</label>
{props.brandingEnabled && (
<div className="flex items-center gap-3">
<ColorInput
label="Primary"
value={props.colors.primary}
onChange={(value) => props.onColorChange('primary', value)}
/>
<ColorInput
label="On primary"
value={props.colors.primaryForeground}
onChange={(value) => props.onColorChange('primaryForeground', value)}
/>
<ColorInput
label="Background"
value={props.colors.background}
onChange={(value) => props.onColorChange('background', value)}
/>
<ColorInput
label="Text"
value={props.colors.foreground}
onChange={(value) => props.onColorChange('foreground', value)}
/>
</div>
)}
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
</div>
);
};
type SegmentedControlProps = {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
};
const SegmentedControl = (props: SegmentedControlProps) => {
return (
<div className="flex items-center gap-1.5">
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
<div className="flex overflow-hidden rounded-md border border-neutral-300">
{props.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => props.onChange(option.value)}
className={`px-2.5 py-1 text-xs transition-colors ${
props.value === option.value
? 'bg-neutral-900 text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
};
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput = (props: ColorInputProps) => {
return (
<label className="flex items-center gap-1 text-neutral-600 text-xs">
<span>{props.label}</span>
<input
type="color"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
/>
</label>
);
};
@@ -0,0 +1,113 @@
import type { FieldConfig } from '../lib/templates';
type PropFieldsProps = {
fields: Record<string, FieldConfig>;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
};
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
const entries = Object.entries(fields);
if (entries.length === 0) {
return <p className="text-neutral-400 text-xs">No editable props.</p>;
}
return (
<div className="grid gap-3">
{entries.map(([key, field]) => (
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
))}
</div>
);
};
type PropFieldProps = {
name: string;
field: FieldConfig;
value: unknown;
onChange: (value: unknown) => void;
};
const inputClass =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
const id = `prop-${name}`;
return (
<div className="grid gap-1">
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
{field.label}
</label>
{field.type === 'text' && (
<input
id={id}
className={inputClass}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'textarea' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'number' && (
<input
id={id}
type="number"
className={inputClass}
value={value === undefined || value === null ? '' : String(value)}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
/>
)}
{field.type === 'boolean' && (
<input
id={id}
type="checkbox"
className="h-4 w-4"
checked={Boolean(value)}
onChange={(event) => onChange(event.target.checked)}
/>
)}
{field.type === 'list' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={Array.isArray(value) ? value.join('\n') : ''}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
/>
)}
{field.type === 'select' && field.options && (
<select
id={id}
className={inputClass}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
</div>
);
};
@@ -0,0 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
@@ -0,0 +1,56 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
});
setTimeout(abort, streamTimeout + 1000);
});
}
@@ -0,0 +1,407 @@
import type { ComponentType } from 'react';
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
import { ResetPasswordTemplate } from '../../../templates/reset-password';
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
export type FieldConfig = {
type: FieldType;
label: string;
description?: string;
placeholder?: string;
default: unknown;
options?: { label: string; value: string }[];
};
export type TemplateDefinition = {
/** Human label for the sidebar. */
name: string;
/** Loose grouping for the sidebar. */
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>;
/** Editable props surfaced in the preview UI. */
fields: Record<string, FieldConfig>;
};
// --- Reusable field presets ---
const documentNameField: FieldConfig = {
type: 'text',
label: 'Document name',
default: 'Open Source Pledge.pdf',
};
const recipientNameField: FieldConfig = {
type: 'text',
label: 'Recipient name',
default: 'Lucas Smith',
};
const roleField: FieldConfig = {
type: 'select',
label: 'Recipient role',
default: 'SIGNER',
options: [
{ label: 'Signer', value: 'SIGNER' },
{ label: 'Viewer', value: 'VIEWER' },
{ label: 'Approver', value: 'APPROVER' },
{ label: 'CC', value: 'CC' },
{ label: 'Assistant', value: 'ASSISTANT' },
],
};
/**
* Explicit template registry. Each entry maps a slug → component + editable
* `fields`. The slug is the route param (`/:slug`) and matches the source
* filename (sans extension).
*
* `fields` drives both the default preview values AND the editable inputs in
* the UI, so production templates stay free of preview-only defaults.
*/
export const templates: Record<string, TemplateDefinition> = {
// ---- Documents ----
'document-invite': {
name: 'Document invite',
group: 'Documents',
component: DocumentInviteEmailTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
documentName: documentNameField,
role: roleField,
customBody: {
type: 'textarea',
label: 'Custom message',
default: '',
description: 'Leave blank to use the default invite copy.',
},
},
},
'document-completed': {
name: 'Document completed',
group: 'Documents',
component: DocumentCompletedEmailTemplate,
fields: {
documentName: documentNameField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-self-signed': {
name: 'Document self-signed',
group: 'Documents',
component: DocumentSelfSignedEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-pending': {
name: 'Document pending',
group: 'Documents',
component: DocumentPendingEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-reminder': {
name: 'Document reminder',
group: 'Documents',
component: DocumentReminderEmailTemplate,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
role: roleField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-cancel': {
name: 'Document cancelled',
group: 'Documents',
component: DocumentCancelTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
documentName: documentNameField,
cancellationReason: {
type: 'textarea',
label: 'Cancellation reason',
default: '',
description: 'Optional. Blank renders no reason block.',
},
},
},
'document-rejected': {
name: 'Document rejected',
group: 'Documents',
component: DocumentRejectedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
rejectionReason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-rejection-confirmed': {
name: 'Document rejection confirmed',
group: 'Documents',
component: DocumentRejectionConfirmedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
reason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-created-from-direct-template': {
name: 'Document created (direct template)',
group: 'Documents',
component: DocumentCreatedFromDirectTemplateEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-super-delete': {
name: 'Document deleted (admin)',
group: 'Documents',
component: DocumentSuperDeleteEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'bulk-send-complete': {
name: 'Bulk send complete',
group: 'Documents',
component: BulkSendCompleteEmail,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
successCount: { type: 'number', label: 'Success count', default: 48 },
failedCount: { type: 'number', label: 'Failed count', default: 2 },
errors: {
type: 'list',
label: 'Errors',
default: ['Row 12: invalid email', 'Row 30: missing name'],
description: 'One error per line. Rendered when failed count > 0.',
},
},
},
// ---- Recipients ----
'document-recipient-signed': {
name: 'Recipient signed',
group: 'Recipients',
component: DocumentRecipientSignedEmailTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-expired': {
name: 'Recipient expired',
group: 'Recipients',
component: RecipientExpiredTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-removed-from-document': {
name: 'Recipient removed',
group: 'Recipients',
component: RecipientRemovedFromDocumentTemplate,
fields: {
documentName: documentNameField,
},
},
// ---- Organisations ----
'organisation-invite': {
name: 'Organisation invite',
group: 'Organisations',
component: OrganisationInviteEmailTemplate,
fields: {
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-join': {
name: 'Organisation join',
group: 'Organisations',
component: OrganisationJoinEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-leave': {
name: 'Organisation leave',
group: 'Organisations',
component: OrganisationLeaveEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-delete': {
name: 'Organisation delete',
group: 'Organisations',
component: OrganisationDeleteEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-limit-alert': {
name: 'Organisation limit alert',
group: 'Organisations',
component: OrganisationLimitAlertEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-account-link-confirmation': {
name: 'Account link confirmation',
group: 'Organisations',
component: OrganisationAccountLinkConfirmationTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
// ---- Teams ----
'confirm-team-email': {
name: 'Confirm team email',
group: 'Teams',
component: ConfirmTeamEmailTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
},
},
'team-delete': {
name: 'Team delete',
group: 'Teams',
component: TeamDeleteEmailTemplate,
fields: {},
},
'team-email-removed': {
name: 'Team email removed',
group: 'Teams',
component: TeamEmailRemovedTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
},
},
// ---- Account ----
'confirm-email': {
name: 'Confirm email',
group: 'Account',
component: ConfirmEmailTemplate,
fields: {
confirmationLink: {
type: 'text',
label: 'Confirmation link',
default: 'https://documenso.com/confirm',
},
},
},
'forgot-password': {
name: 'Forgot password',
group: 'Account',
component: ForgotPasswordTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
'reset-password': {
name: 'Reset password',
group: 'Account',
component: ResetPasswordTemplate,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
},
},
'access-auth-2fa': {
name: 'Access auth 2FA',
group: 'Account',
component: AccessAuth2FAEmailTemplate,
fields: {
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
code: { type: 'text', label: 'Code', default: '123456' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
},
},
// ---- Admin ----
'admin-user-created': {
name: 'Admin user created',
group: 'Admin',
component: AdminUserCreatedTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
};
export type TemplateId = keyof typeof templates;
/** Extract the default prop values from a template's field config. */
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
const props: Record<string, unknown> = {};
for (const [key, field] of Object.entries(fields)) {
props[key] = field.default;
}
return props;
};
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];
@@ -0,0 +1,10 @@
export type Viewport = {
name: string;
width: number;
};
export const viewports: Viewport[] = [
{ name: 'Mobile', width: 390 },
{ name: 'Tablet', width: 768 },
{ name: 'Desktop', width: 1024 },
];
+30
View File
@@ -0,0 +1,30 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
export const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
};
const App = () => {
return <Outlet />;
};
export default App;
+7
View File
@@ -0,0 +1,7 @@
import { index, type RouteConfig, route } from '@react-router/dev/routes';
export default [
index('routes/_index.tsx'),
route('api/render', 'routes/api.render.tsx'),
route(':slug', 'routes/$slug.tsx'),
] satisfies RouteConfig;
@@ -0,0 +1,35 @@
import { data } from 'react-router';
import { EmailPlayground } from '../components/playground';
import { getDefaultProps, getTemplate } from '../lib/templates';
import type { Route } from './+types/$slug';
export const loader = ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const template = getTemplate(slug);
if (!template) {
throw data(`Unknown template: ${slug}`, { status: 404 });
}
return {
slug,
templateName: template.name,
fields: template.fields,
defaultProps: getDefaultProps(template.fields),
};
};
export const meta = ({ data: loaderData }: Route.MetaArgs) => {
if (!loaderData) {
return [{ title: 'Not found — Email Preview' }];
}
return [{ title: `${loaderData.templateName} — Email Preview` }];
};
const TemplatePage = ({ loaderData }: Route.ComponentProps) => {
return <EmailPlayground slug={loaderData.slug} fields={loaderData.fields} defaultProps={loaderData.defaultProps} />;
};
export default TemplatePage;
@@ -0,0 +1,13 @@
import { redirect } from 'react-router';
import { templates } from '../lib/templates';
/**
* The index has no UI of its own — redirect to the first template so the
* preview always opens on something.
*/
export const loader = () => {
const firstSlug = Object.keys(templates)[0];
return redirect(`/${firstSlug}`);
};
@@ -0,0 +1,61 @@
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { getTemplate } from '../lib/templates';
import type { Route } from './+types/api.render';
type RenderRequestBody = {
slug: string;
props: Record<string, unknown>;
lang?: string;
colors?: Record<string, string> | null;
assetBaseUrl: string;
};
/**
* POST /api/render — render an email template to HTML via the REAL production
* pipeline (`renderEmailWithI18N`), so i18n and brand-colour injection match a
* live send. Returns `text/html` for the client to drop into an iframe srcDoc.
*/
export const action = async ({ request }: Route.ActionArgs) => {
const body = (await request.json()) as RenderRequestBody;
const template = getTemplate(body.slug);
if (!template) {
return new Response(JSON.stringify({ error: `Unknown template: ${body.slug}` }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Resolve brand colours through the same resolver production uses, so the
// preview applies the same per-token fallbacks as a live send.
const brandingColors =
body.colors && Object.keys(body.colors).length > 0 ? resolveEmailBrandingColors(body.colors) : null;
const Component = template.component;
const element = <Component {...body.props} assetBaseUrl={body.assetBaseUrl} />;
const html = await renderEmailWithI18N(element, {
lang: body.lang ?? 'en',
branding: brandingColors
? {
brandingEnabled: true,
brandingUrl: '',
brandingLogo: '',
brandingCompanyDetails: '',
brandingHidePoweredBy: false,
brandingColors,
}
: undefined,
});
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
};
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: { config: './tailwind.config.cjs' },
autoprefixer: {},
},
};
@@ -0,0 +1,6 @@
import type { Config } from '@react-router/dev/config';
export default {
appDirectory: 'app',
ssr: true,
} satisfies Config;
@@ -0,0 +1,24 @@
const path = require('node:path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [path.join(__dirname, 'app/**/*.{ts,tsx}')],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
},
},
},
plugins: [],
};
+30
View File
@@ -0,0 +1,30 @@
{
"include": ["**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"@documenso/email/*": ["../*"],
"@documenso/lib": ["../../lib"],
"@documenso/lib/*": ["../../lib/*"],
"@documenso/prisma": ["../../prisma"],
"@documenso/tailwind-config": ["../../tailwind-config"],
"@documenso/ui": ["../../ui"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"strict": true,
"useUnknownInCatchVariables": false
}
}
+72
View File
@@ -0,0 +1,72 @@
import path from 'node:path';
import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import tsconfigPaths from 'vite-tsconfig-paths';
/**
* Standalone Vite app for previewing Documenso emails.
*
* Emails render server-side through the real `renderEmailWithI18N` pipeline
* (see `app/routes/preview.tsx`), so the SSR config mirrors the main Remix app:
* Prisma, the tailwind config, and native modules stay external.
*/
export default defineConfig({
root: __dirname,
css: {
postcss: {
plugins: [tailwindcss(path.join(__dirname, 'tailwind.config.cjs')), autoprefixer],
},
},
server: {
port: parseInt(process.env.PORT || '3002', 10),
strictPort: true,
},
plugins: [
// Serve the email static assets (logo, icons) under `/static` so templates'
// `assetBaseUrl="/static"` resolves to the same images production uses.
viteStaticCopy({
targets: [
{
src: path.join(__dirname, '../static') + '/*',
dest: 'static',
},
],
}),
reactRouter(),
macrosPlugin(),
lingui(),
tsconfigPaths(),
],
ssr: {
noExternal: ['@documenso/email'],
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
'pdfjs-dist',
'@google-cloud/kms',
'@google-cloud/secret-manager',
],
},
optimizeDeps: {
exclude: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'sharp',
'playwright',
'playwright-core',
'@playwright/browser-chromium',
'lightningcss',
'fsevents',
],
},
});
+2
View File
@@ -1,3 +1,4 @@
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { createContext, useContext } from 'react';
type BrandingContextValue = {
@@ -6,6 +7,7 @@ type BrandingContextValue = {
brandingLogo: string;
brandingCompanyDetails: string;
brandingHidePoweredBy: boolean;
brandingColors?: EmailBrandingColors;
};
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
+52 -5
View File
@@ -1,4 +1,6 @@
import config from '@documenso/tailwind-config';
import { DEFAULT_BRAND_COLORS } from '@documenso/lib/constants/theme';
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import type { I18n } from '@lingui/core';
import { I18nProvider } from '@lingui/react';
import * as ReactEmail from '@react-email/render';
@@ -11,19 +13,62 @@ export type RenderOptions = ReactEmail.Options & {
i18n?: I18n;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
/**
* The default email token set: the shadcn theme tokens, sourced as hex from
* `DEFAULT_BRAND_COLORS` (which mirrors `theme.css`). Emails can't use CSS
* variables, so these are concrete hex values baked into the Tailwind config.
*
* Resolved through the same `resolveEmailBrandingColors` pipeline as tenant
* colours so the default values live in exactly one place (`DEFAULT_BRAND_COLORS`)
* and the default + tenant paths can't drift. Used when a tenant has no
* (entitled) brand colours.
*/
const DEFAULT_EMAIL_BRANDING_COLORS: EmailBrandingColors =
resolveEmailBrandingColors(DEFAULT_BRAND_COLORS) ?? DEFAULT_BRAND_COLORS;
/**
* Map the resolved colour set to flat semantic Tailwind tokens. Templates use
* these directly (`bg-primary`, `text-muted-foreground`, `border-border`, …),
* mirroring the app's shadcn tokens, instead of bespoke `slate-*`/`documenso-*`
* scale classes.
*
* Always defined: falls back to `DEFAULT_EMAIL_BRANDING_COLORS` when no tenant
* colours are supplied, so the tokens resolve whether or not custom branding is
* in play.
*/
const buildEmailColors = (brandingColors?: EmailBrandingColors): Record<string, string> => {
const c = brandingColors ?? DEFAULT_EMAIL_BRANDING_COLORS;
return {
background: c.background,
foreground: c.foreground,
muted: c.muted,
'muted-foreground': c.mutedForeground,
primary: c.primary,
'primary-foreground': c.primaryForeground,
secondary: c.secondary,
'secondary-foreground': c.secondaryForeground,
accent: c.accent,
'accent-foreground': c.accentForeground,
destructive: c.destructive,
'destructive-foreground': c.destructiveForeground,
warning: c.warning,
border: c.border,
};
};
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
const tailwindColors = buildEmailColors(branding?.brandingColors);
return ReactEmail.render(
<BrandingProvider branding={branding}>
<Tailwind
config={{
theme: {
extend: {
colors,
colors: tailwindColors,
},
},
}}
@@ -42,6 +87,8 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
throw new Error('i18n is required');
}
const tailwindColors = buildEmailColors(branding?.brandingColors);
return ReactEmail.render(
<I18nProvider i18n={i18n}>
<BrandingProvider branding={branding}>
@@ -49,7 +96,7 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
config={{
theme: {
extend: {
colors,
colors: tailwindColors,
},
},
}}
@@ -27,24 +27,24 @@ export const TemplateAccessAuth2FA = ({
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center font-semibold text-lg text-slate-900">
<Heading className="text-center font-semibold text-foreground text-lg">
<Trans>Verification Code Required</Trans>
</Heading>
<Text className="mt-2 text-center text-slate-700">
<Text className="mt-2 text-center text-foreground">
<Trans>
Hi {userName}, you need to enter a verification code to complete the document "{documentTitle}".
</Trans>
</Text>
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
<Text className="mb-2 font-medium text-slate-600 text-sm">
<Section className="mt-6 rounded-lg bg-muted p-6 text-center">
<Text className="mb-2 font-medium text-muted-foreground text-sm">
<Trans>Your verification code:</Trans>
</Text>
<Text className="font-bold text-2xl text-slate-900 tracking-wider">{code}</Text>
<Text className="font-bold text-2xl text-foreground tracking-wider">{code}</Text>
</Section>
<Text className="mt-4 text-center text-slate-600 text-sm">
<Text className="mt-4 text-center text-muted-foreground text-sm">
<Plural
value={expiresInMinutes}
one="This code will expire in # minute."
@@ -52,7 +52,7 @@ export const TemplateAccessAuth2FA = ({
/>
</Text>
<Text className="mt-4 text-center text-slate-500 text-sm">
<Text className="mt-4 text-center text-muted-foreground text-sm">
<Trans>If you didn't request this verification code, you can safely ignore this email.</Trans>
</Text>
</Section>
@@ -14,26 +14,26 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>An administrator has created a Documenso account for you.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>To get started, please set your password by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Set Password</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
</Trans>
@@ -41,10 +41,10 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
</Section>
<Section className="mt-8">
<Text className="text-center text-slate-400 text-sm">
<Text className="text-center text-muted-foreground text-sm">
<Trans>
If you didn't expect this account or have any questions, please{' '}
<Link href="mailto:support@documenso.com" className="text-documenso-500">
<Link href="mailto:support@documenso.com" className="text-primary">
contact support
</Link>
.
@@ -14,22 +14,22 @@ export const TemplateConfirmationEmail = ({ confirmationLink, assetBaseUrl }: Te
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Before you get started, please confirm your email address by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={confirmationLink}
>
<Trans>Confirm email</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)
</Trans>
@@ -18,7 +18,7 @@ export const TemplateCustomMessageBody = ({ text }: TemplateCustomMessageBodyPro
const paragraphs = normalized.split('\n\n');
return paragraphs.map((paragraph, i) => (
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-slate-400">
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-muted-foreground">
{paragraph.split('\n').map((line, j) => (
<React.Fragment key={`line-${i}-${j}`}>
{j > 0 && <br />}
@@ -22,18 +22,18 @@ export const TemplateDocumentCancel = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
{inviterName} has cancelled the document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>All signatures have been voided.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>You don't need to sign it anymore.</Trans>
</Text>
@@ -27,24 +27,24 @@ export const TemplateDocumentCompleted = ({
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
{customBody || <Trans>{documentName} was signed by all signers</Trans>}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Continue by downloading the document.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
@@ -40,7 +40,7 @@ export const TemplateDocumentInvite = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
{match({ selfSigner, organisationType, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans>
@@ -75,7 +75,7 @@ export const TemplateDocumentInvite = ({
))}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sbase no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
href={signDocumentLink}
>
{match(role)
@@ -20,18 +20,18 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-blue-500">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Waiting for others</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>{documentName} has been signed</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>
We're still waiting for other signers to sign this document.
<br />
@@ -29,20 +29,20 @@ export const TemplateDocumentRecipientSigned = ({
<Section>
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>
{recipientReference} has signed "{documentName}"
</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>{recipientReference} has completed signing the document.</Trans>
</Text>
</Section>
@@ -17,7 +17,7 @@ export function TemplateDocumentRejected({
}: TemplateDocumentRejectedProps) {
return (
<div className="mt-4">
<Heading className="mb-4 text-center font-semibold text-2xl text-slate-800">
<Heading className="mb-4 text-center font-semibold text-2xl text-foreground">
<Trans>Document Rejected</Trans>
</Heading>
@@ -28,7 +28,7 @@ export function TemplateDocumentRejected({
</Text>
{rejectionReason && (
<Text className="mb-4 text-base text-slate-400">
<Text className="mb-4 text-base text-muted-foreground">
<Trans>Reason for rejection: {rejectionReason}</Trans>
</Text>
)}
@@ -39,7 +39,7 @@ export function TemplateDocumentRejected({
<Button
href={documentUrl}
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
>
<Trans>View Document</Trans>
</Button>
@@ -22,7 +22,7 @@ export function TemplateDocumentRejectionConfirmed({
<Trans>Rejection Confirmed</Trans>
</Heading>
<Text className="text-base text-primary">
<Text className="text-base text-foreground">
<Trans>
This email confirms that you have rejected the document{' '}
<strong className="font-bold">"{documentName}"</strong> sent by {documentOwnerName}.
@@ -30,7 +30,7 @@ export function TemplateDocumentRejectionConfirmed({
</Text>
{reason && (
<Text className="font-medium text-base text-slate-400">
<Text className="font-medium text-base text-muted-foreground">
<Trans>Rejection reason: {reason}</Trans>
</Text>
)}
@@ -31,18 +31,18 @@ export const TemplateDocumentReminder = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
Reminder: Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Hi {recipientName},</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
@@ -54,7 +54,7 @@ export const TemplateDocumentReminder = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={signDocumentLink}
>
{match(role)
@@ -25,25 +25,21 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section className="flex-row items-center justify-center">
<Section>
<Column align="center">
<Text className="font-semibold text-[#7AC455] text-base">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
<Trans>Completed</Trans>
</Text>
</Column>
</Section>
<Text className="mt-6 mb-0 text-center font-semibold text-lg text-primary">
<Text className="mt-6 mb-0 text-center font-semibold text-foreground text-lg">
<Trans>You have signed {documentName}</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
<Trans>
Create a{' '}
<Link
href={signUpUrl}
target="_blank"
className="whitespace-nowrap text-documenso-700 hover:text-documenso-600"
>
<Link href={signUpUrl} target="_blank" className="whitespace-nowrap text-primary hover:text-primary">
free account
</Link>{' '}
to access your signed documents at any time.
@@ -53,14 +49,14 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Section className="mt-8 mb-6 text-center">
<Button
href={signUpUrl}
className="mr-4 rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
<Trans>Create account</Trans>
</Button>
<Button
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
@@ -15,26 +15,26 @@ export const TemplateDocumentDelete = ({ reason, documentName, assetBaseUrl }: T
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mt-6 mb-0 text-left font-semibold text-lg text-primary">
<Text className="mt-6 mb-0 text-left font-semibold text-foreground text-lg">
<Trans>Your document has been deleted by an admin!</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
<Trans>"{documentName}" has been deleted by an admin.</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
<Trans>
This document can not be recovered, if you would like to dispute the reason for future documents please
contact support.
</Trans>
</Text>
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
<Text className="mx-auto mt-1 text-left text-base text-muted-foreground">
<Trans>The reason provided for deletion is the following:</Trans>
</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400 italic">{reason}</Text>
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground italic">{reason}</Text>
</Section>
</>
);
@@ -1,4 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { Fragment } from 'react';
import { Link, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
@@ -17,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.
@@ -29,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>
.
@@ -41,20 +42,20 @@ 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}>
{idx > 0 && <br />}
{line}
</>
</Fragment>
);
})}
</Text>
)}
{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>
@@ -62,7 +63,7 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
)}
{!branding.brandingEnabled && (
<Text className="my-8 text-slate-400 text-sm">
<Text className="my-8 text-muted-foreground text-sm">
Documenso, Inc.
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
@@ -14,17 +14,17 @@ export const TemplateForgotPassword = ({ resetPasswordLink, assetBaseUrl }: Temp
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Forgot your password?</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>That's okay, it happens! Click the button below to reset your password.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Reset Password</Trans>
@@ -25,13 +25,13 @@ export const TemplateRecipientExpired = ({
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
Signing window expired for "{displayName}" on "{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>
The signing window for {displayName} on document "{documentName}" has expired. You can resend the document
to extend their deadline or cancel the document.
@@ -40,7 +40,7 @@ export const TemplateRecipientExpired = ({
<Section className="my-4 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-sm text-white no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={documentLink}
>
<Trans>View Document</Trans>
@@ -18,17 +18,17 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>Password updated!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Text className="my-1 text-center text-base text-muted-foreground">
<Trans>Your password has been updated.</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
<Trans>Sign In</Trans>
+2 -2
View File
@@ -32,9 +32,9 @@ export const AccessAuth2FAEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -22,9 +22,9 @@ export const AdminUserCreatedTemplate = ({
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
@@ -28,9 +28,9 @@ export const BulkSendCompleteEmail = ({
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Text className="text-sm">
<Trans>Hi {userName},</Trans>
@@ -56,7 +56,7 @@ export const BulkSendCompleteEmail = ({
</li>
</ul>
{failedCount > 0 && (
{errors && errors.length > 0 && (
<Section className="mt-4">
<Text className="font-semibold text-lg">
<Trans>The following errors occurred:</Trans>
@@ -64,7 +64,7 @@ export const BulkSendCompleteEmail = ({
<ul className="my-2 ml-4 list-inside list-disc">
{errors.map((error, index) => (
<li key={index} className="mt-1 text-destructive text-slate-400 text-sm">
<li key={index} className="mt-1 text-destructive text-sm">
{error}
</li>
))}
+2 -2
View File
@@ -19,9 +19,9 @@ export const ConfirmEmailTemplate = ({
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -33,16 +33,16 @@ export const ConfirmTeamEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Verify your team email address</Trans>
</Text>
@@ -53,7 +53,7 @@ export const ConfirmTeamEmailTemplate = ({
</Trans>
</Text>
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto mt-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
@@ -86,7 +86,7 @@ export const ConfirmTeamEmailTemplate = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${baseUrl}/team/verify/email/${token}`}
>
<Trans>Accept</Trans>
@@ -94,7 +94,7 @@ export const ConfirmTeamEmailTemplate = ({
</Section>
</Section>
<Text className="text-center text-slate-500 text-xs">
<Text className="text-center text-muted-foreground text-xs">
<Trans>Link expires in 1 hour.</Trans>
</Text>
</Container>
+2 -2
View File
@@ -25,9 +25,9 @@ export const DocumentCancelTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -29,8 +29,8 @@ export const DocumentCompletedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -36,27 +36,27 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mb-0 text-center font-semibold text-lg text-primary">
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
<Trans>
{recipientName} {action} a document by using one of your direct links
</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-slate-600 text-sm">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 text-muted-foreground text-sm">
{documentName}
</div>
<Section className="my-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={documentLink}
>
<Trans>View document</Trans>
+4 -4
View File
@@ -58,9 +58,9 @@ export const DocumentInviteEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -85,14 +85,14 @@ export const DocumentInviteEmailTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
{inviterName}{' '}
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
({inviterEmail})
</Link>
</Trans>
</Text>
)}
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
{customBody ? (
<TemplateCustomMessageBody text={customBody} />
) : (
@@ -23,8 +23,8 @@ export const DocumentPendingEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -31,8 +31,8 @@ export const DocumentRecipientSignedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -30,9 +30,9 @@ export function DocumentRejectedEmail({
<Head />
<Preview>{previewText}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -30,9 +30,9 @@ export function DocumentRejectionConfirmedEmail({
<Head />
<Preview>{previewText}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -39,9 +39,9 @@ export const DocumentReminderEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -58,7 +58,7 @@ export const DocumentReminderEmailTemplate = ({
{customBody && (
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<TemplateCustomMessageBody text={customBody} />
</Text>
</Section>
@@ -23,8 +23,8 @@ export const DocumentSelfSignedEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<Section className="p-2">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -25,9 +25,9 @@ export const DocumentSuperDeleteEmailTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
+2 -2
View File
@@ -22,9 +22,9 @@ export const ForgotPasswordTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -32,16 +32,16 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<Section className="bg-background">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto h-12 w-12" assetBaseUrl={assetBaseUrl} staticAsset="building-2.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{type === 'create' ? (
<Trans>Account creation request</Trans>
) : (
@@ -94,7 +94,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={confirmationLink}
>
<Trans>Review request</Trans>
@@ -102,7 +102,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
</Section>
</Section>
<Text className="text-center text-slate-500 text-xs">
<Text className="text-center text-muted-foreground text-xs">
<Trans>Link expires in 30 minutes.</Trans>
</Text>
</Container>
@@ -37,20 +37,20 @@ export const OrganisationDeleteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
</Section>
@@ -32,16 +32,16 @@ export const OrganisationInviteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Join {organisationName} on Documenso</Trans>
</Text>
@@ -49,25 +49,25 @@ export const OrganisationInviteEmailTemplate = ({
<Trans>You have been invited to join the following organisation</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
<Text className="my-1 text-center text-base">
<Trans>
by <span className="text-slate-900">{senderName}</span>
by <span className="text-foreground">{senderName}</span>
</Trans>
</Text>
<Section className="mt-6 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
href={`${baseUrl}/organisation/invite/${token}`}
>
<Trans>Accept</Trans>
</Button>
<Button
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center font-medium text-slate-600 text-sm no-underline"
className="ml-4 inline-flex items-center justify-center rounded-lg bg-muted px-6 py-3 text-center font-medium text-muted-foreground text-sm no-underline"
href={`${baseUrl}/organisation/decline/${token}`}
>
<Trans>Decline</Trans>
@@ -34,20 +34,20 @@ export const OrganisationJoinEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>A new member has joined your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{memberName || memberEmail}
</div>
</Section>
@@ -34,20 +34,20 @@ export const OrganisationLeaveEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-user.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>A member has left your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{memberName || memberEmail}
</div>
</Section>
@@ -32,12 +32,12 @@ export const OrganisationLimitAlertEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{kind === 'quotaNearing' ? (
<Trans>Approaching Your Plan Limits</Trans>
) : (
@@ -45,7 +45,7 @@ export const OrganisationLimitAlertEmailTemplate = ({
)}
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
@@ -25,9 +25,9 @@ export const RecipientExpiredTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -24,16 +24,16 @@ export const RecipientRemovedFromDocumentTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
<Trans>
{inviterName} has removed you from the document
<br />"{documentName}"
+6 -6
View File
@@ -24,9 +24,9 @@ export const ResetPasswordTemplate = ({
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Body className="mx-auto my-auto bg-background font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
@@ -39,19 +39,19 @@ export const ResetPasswordTemplate = ({
<Text className="my-4 font-semibold text-base">
<Trans>
Hi, {userName}{' '}
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
<Link className="font-normal text-muted-foreground" href={`mailto:${userEmail}`}>
({userEmail})
</Link>
</Trans>
</Text>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
</Text>
<Text className="mt-2 text-base text-slate-400">
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
Didn't request a password change? We are here to help you secure your account, just{' '}
<Link className="font-normal text-documenso-700" href="mailto:hi@documenso.com">
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
+5 -5
View File
@@ -32,20 +32,20 @@ export const TeamDeleteEmailTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
</Section>
@@ -33,16 +33,16 @@ export const TeamEmailRemovedTemplate = ({
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open-alert.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
<Trans>Team email removed</Trans>
</Text>
@@ -52,7 +52,7 @@ export const TeamEmailRemovedTemplate = ({
</Trans>
</Text>
<div className="mx-auto mt-2 mb-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
<div className="mx-auto mt-2 mb-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
</Section>
+1 -1
View File
@@ -4,5 +4,5 @@
"types": ["@documenso/tsconfig/process-env.d.ts"]
},
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
"exclude": ["dist", "build", "node_modules"]
"exclude": ["dist", "build", "node_modules", "preview"]
}
+15 -4
View File
@@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
*/
export const MAX_REMINDER_WINDOW_DAYS = 30;
/**
* Maximum number of automated reminders sent to a recipient before reminders
* stop. A manual resend resets the count, re-arming reminders.
*/
export const MAX_REMINDERS_BEFORE_RESEND = 5;
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
day: 'days',
week: 'weeks',
@@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
*
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
* any computed reminder beyond that point returns null so reminders stop.
* Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS`
* from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent.
*
* `sentAt` is when the signing request was sent to this specific recipient.
*
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
* Returns the next Date the reminder should be sent, or null if none.
*/
export const resolveNextReminderAt = (options: {
config: TEnvelopeReminderSettings | null;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderCount: number;
}): Date | null => {
const { config, sentAt, lastReminderSentAt } = options;
const { config, sentAt, lastReminderSentAt, reminderCount } = options;
if (!config) {
return null;
}
if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) {
return null;
}
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
let candidate: Date;
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { getSignatureFontFamily } from './pdf';
describe('getSignatureFontFamily', () => {
const expectCaveat = (family: string) => expect(family).toBe('Caveat');
const expectNotoChain = (family: string) => {
expect(family).toContain('"Noto Sans"');
expect(family).toContain('"Noto Sans Chinese"');
expect(family).toContain('"Noto Sans Japanese"');
expect(family).toContain('"Noto Sans Korean"');
expect(family).toContain('sans-serif');
expect(family).not.toContain('Caveat');
};
it('returns Caveat for ASCII-only text', () => {
expectCaveat(getSignatureFontFamily('John Doe'));
expectCaveat(getSignatureFontFamily(''));
});
it('returns the Noto chain for any non-ASCII character', () => {
expectNotoChain(getSignatureFontFamily('François'));
expectNotoChain(getSignatureFontFamily('Müller'));
expectNotoChain(getSignatureFontFamily('Søren'));
expectNotoChain(getSignatureFontFamily('Иванов'));
expectNotoChain(getSignatureFontFamily('Ελληνικά'));
expectNotoChain(getSignatureFontFamily('عربي'));
expectNotoChain(getSignatureFontFamily('עברית'));
expectNotoChain(getSignatureFontFamily('도큐멘소'));
expectNotoChain(getSignatureFontFamily('中文签名'));
expectNotoChain(getSignatureFontFamily('こんにちは'));
});
it('returns the Noto chain for mixed ASCII + non-ASCII input', () => {
expectNotoChain(getSignatureFontFamily('Hello 안녕'));
expectNotoChain(getSignatureFontFamily('Ivan Ωmega'));
});
it('returns the Noto chain for scripts not covered by a dedicated Noto file', () => {
expectNotoChain(getSignatureFontFamily('ሰላም')); // Ethiopic
expectNotoChain(getSignatureFontFamily('សួស្ដី')); // Khmer
expectNotoChain(getSignatureFontFamily('ᠮᠣᠩᠭᠣᠯ')); // Mongolian
});
});
+14
View File
@@ -9,6 +9,20 @@ export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
const SIGNATURE_FONT_FAMILY_CAVEAT = 'Caveat';
// CN-before-JP: the JP Noto file's Han glyphs use JP shapes, so pure-CN
// text would otherwise render with JP forms. Family names sync with
// apps/remix/app/app.css and packages/lib/server-only/pdf/helpers.ts.
const SIGNATURE_FONT_FAMILY_NOTO =
'"Noto Sans", "Noto Sans Chinese", "Noto Sans Japanese", "Noto Sans Korean", sans-serif';
const isASCII = (str: string) => /^\p{ASCII}*$/u.test(str);
// Deliberately never mix handwriting + sans-serif within one signature.
export const getSignatureFontFamily = (typedSignatureText: string): string =>
isASCII(typedSignatureText) ? SIGNATURE_FONT_FAMILY_CAVEAT : SIGNATURE_FONT_FAMILY_NOTO;
export const PDF_SIZE_A4_72PPI = {
width: 595,
height: 842,
+6
View File
@@ -4,11 +4,14 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email';
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
@@ -44,9 +47,12 @@ export const jobsClient = new JobClient([
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
@@ -0,0 +1,70 @@
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentDeletedEmailsJobDefinition } from './send-document-deleted-emails';
export const run = async ({ payload, io }: { payload: TSendDocumentDeletedEmailsJobDefinition; io: JobRunIO }) => {
const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload;
if (recipients.length === 0) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta,
});
// Don't send cancellation emails if the organisation has email sending
// disabled. Re-checked here (not just at enqueue time) because the org can be
// disabled between the delete request and this job running.
if (emailsDisabled) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const i18n = await getI18nInstance(emailLanguage);
for (const recipient of recipients) {
await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => {
if (!isRecipientEmailValidForSending(recipient)) {
return;
}
const template = createElement(DocumentCancelTemplate, {
documentName,
inviterName: inviterName || undefined,
inviterEmail,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
});
}
};
@@ -0,0 +1,52 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails';
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
documentName: z.string(),
inviterName: z.string().optional(),
inviterEmail: z.string(),
/**
* The document's email meta (sender, reply-to, language). Captured before the
* envelope is hard-deleted so `getEmailContext` resolves the exact same
* sender/reply-to/language the inline send used.
*/
meta: z
.object({
emailId: z.string().nullable().optional(),
emailReplyTo: z.string().nullable().optional(),
language: z.string().optional(),
})
.nullable(),
recipients: z
.object({
email: z.string(),
name: z.string(),
})
.array(),
});
export type TSendDocumentDeletedEmailsJobDefinition = z.infer<
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = {
id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Document Deleted Emails',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-deleted-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
TSendDocumentDeletedEmailsJobDefinition
>;
@@ -1,27 +1,24 @@
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => {
const { envelopeId, recipientId } = payload;
export interface SendPendingEmailOptions {
id: EnvelopeIdOptions;
recipientId: number;
}
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT),
recipients: {
some: {
id: recipientId,
@@ -38,12 +35,8 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
},
});
if (!envelope) {
throw new Error('Document not found');
}
if (envelope.recipients.length === 0) {
throw new Error('Document has no recipients');
if (!envelope || envelope.recipients.length === 0) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
@@ -0,0 +1,30 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendDocumentPendingEmailJobDefinition = z.infer<typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Pending Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-pending-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
TSendDocumentPendingEmailJobDefinition
>;
@@ -0,0 +1,105 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email';
export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => {
const { envelopeId, recipientEmail, recipientName, inviterName } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
documentMeta: true,
},
});
// The envelope may have been deleted between the recipient removal and this
// job running. Treat as a no-op so the job doesn't retry forever.
if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) {
return;
}
// Re-checked at send time (not just at enqueue) so the email honors the
// document's current "recipient removed" setting.
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
if (!isRecipientRemovedEmailEnabled) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails outside
// the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: inviterName || undefined,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
await io.runTask('send-recipient-removed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipientEmail,
name: recipientName,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email';
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientEmail: z.string(),
recipientName: z.string(),
inviterName: z.string().optional(),
});
export type TSendRecipientRemovedEmailJobDefinition = z.infer<
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = {
id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Recipient Removed Email',
version: '1.0.0',
trigger: {
name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-recipient-removed-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
TSendRecipientRemovedEmailJobDefinition
>;
@@ -52,6 +52,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
data: {
lastReminderSentAt: now,
nextReminderAt: null,
reminderCount: { increment: 1 },
},
});
@@ -243,13 +244,15 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
});
}
// Compute the next reminder time (repeat interval).
// reminderCount was incremented in the atomic claim above, so the value read
// here includes the reminder we just sent and gates the next one.
if (recipient.sentAt) {
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: recipient.sentAt,
lastReminderSentAt: now,
reminderCount: recipient.reminderCount,
});
}
};
@@ -31,9 +31,13 @@ export const loadRecipientBrandingByTeamId = async ({
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
]);
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
let allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
if (!settings.brandingEnabled) {
allowCustomBranding = false;
}
if (!allowCustomBranding) {
return {
allowCustomBranding: false,
@@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -389,7 +388,13 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
@@ -1,13 +1,9 @@
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
@@ -16,7 +12,6 @@ import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -125,7 +120,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
const { emailLanguage, emailsDisabled } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -192,50 +187,40 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return deletedEnvelope;
}
// Send cancellation emails to recipients.
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientEmailValidForSending(recipient) ||
recipient.role === RecipientRole.CC
) {
return;
}
// Enqueue cancellation emails as a background job. The envelope (and its
// documentMeta) is hard-deleted above, so the job can't look it up later —
// pass a self-contained payload with the recipients to notify.
const recipientsToNotify = envelope.recipients
.filter(
(recipient) =>
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC &&
isRecipientEmailValidForSending(recipient),
)
.map((recipient) => ({
email: recipient.email,
name: recipient.name,
}));
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
if (recipientsToNotify.length > 0) {
await jobs.triggerJob({
name: 'send.document.deleted.emails',
payload: {
teamId: envelope.teamId,
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
meta: envelope.documentMeta
? {
emailId: envelope.documentMeta.emailId,
emailReplyTo: envelope.documentMeta.emailReplyTo,
language: emailLanguage,
}
: null,
recipients: recipientsToNotify,
},
});
}
return deletedEnvelope;
};
@@ -0,0 +1,215 @@
// This is closely related to `reject-document-with-token.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from an API/programmatic perspective (an authenticated API user acting
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
// from a recipient perspective (the recipient rejecting via their token).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type RejectDocumentOnBehalfOfOptions = {
/**
* The ID of the envelope the recipient belongs to. Required so the caller
* targets an explicit envelope/recipient combination rather than resolving the
* envelope implicitly from the recipient ID.
*/
envelopeId: string;
recipientId: number;
userId: number;
teamId: number;
reason: string;
/**
* The email of a team member to attribute the rejection to. Must be a member
* of the team. When omitted the rejection is attributed to the API user that
* owns the token (`userId`).
*
* This exists so external applications can elect which team member is acting
* on behalf of the recipient, rather than always defaulting to the API user.
*/
actAsEmail?: string;
requestMetadata: ApiRequestMetadata;
};
/**
* Reject a document on behalf of a recipient as an authenticated API user.
*
* This is used to programmatically record a rejection for cases where the
* recipient declined to sign outside of the platform (e.g. before ever
* reaching it). The rejection is flagged as `isExternal` in the audit log to
* distinguish it from a rejection performed by the recipient directly.
*
* The action can optionally be attributed to a specific team member via
* `actAsEmail`; otherwise it is attributed to the API user.
*/
export async function rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId,
teamId,
reason,
actAsEmail,
requestMetadata,
}: RejectDocumentOnBehalfOfOptions) {
// Build the access-controlled envelope query. This enforces team membership
// AND document visibility (and owner / team-email access), mirroring the
// canonical envelope access checks used across the app.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
envelope: true,
},
});
const envelope = recipient?.envelope;
if (!recipient || !envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} has already actioned this document`,
});
}
assertRecipientNotExpired(recipient);
// Resolve the user the rejection should be attributed to. When `actAsEmail`
// is supplied it must resolve to a member of the team; otherwise the rejection
// is attributed to the API user that owns the token.
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
// Update the recipient status to rejected and record an external rejection
// audit log within the same transaction.
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
// Always attribute the audit log to a concrete user: the elected team
// member when supplied, otherwise the API user that owns the token.
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
metadata: requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
isExternal: true,
// Only set when a member was explicitly elected via `actAsEmail`.
onBehalfOfUserEmail: electedUser?.email,
onBehalfOfUserName: electedUser?.name,
},
}),
}),
]);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Trigger the seal document job to process the document asynchronously.
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: legacyDocumentId,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Send email notifications to the rejecting recipient.
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId: legacyDocumentId,
},
});
// Send cancellation emails to other recipients.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
return updatedRecipient;
}
/**
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
* user). Throws when the email does not resolve to a member of the team.
*/
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
if (!actAsEmail) {
return null;
}
const electedUser = await prisma.user.findFirst({
where: {
email: actAsEmail,
},
});
if (!electedUser) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
const isTeamMember = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
});
if (!isTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
return electedUser;
};
@@ -1,3 +1,11 @@
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from a recipient perspective (the recipient rejecting via their token),
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
// perspective (an authenticated API user acting on behalf of a recipient).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
@@ -13,6 +13,7 @@ import {
EnvelopeType,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
@@ -30,6 +31,7 @@ import { buildEnvelopeEmailHeaders } from '../email/build-envelope-email-headers
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder';
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -117,7 +119,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
});
}
// Refresh the expiresAt on each resent recipient.
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
const recipientsToRemind = envelope.recipients.filter(
@@ -127,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
recipient.role !== RecipientRole.CC,
);
// Extend the expiration deadline for recipients being resent.
if (expiresAt && recipientsToRemind.length > 0) {
await prisma.recipient.updateMany({
where: {
@@ -142,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
});
}
// A manual resend restarts the reminder cycle from scratch, mirroring the
// initial send, so a recipient that hit the threshold can be reminded again.
const resentAt = new Date();
await Promise.all(
recipientsToRemind.map((recipient) =>
updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: resentAt,
lastReminderSentAt: null,
resetReminderCount: true,
}),
),
);
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
@@ -276,6 +292,18 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
}),
});
// Mark the recipient as sent if they were not already sent.
await prisma.recipient.updateMany({
where: {
id: recipient.id,
sendStatus: SendStatus.NOT_SENT,
},
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
@@ -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,
@@ -14,6 +14,7 @@ import { renderSVG } from 'uqr';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import { getSignatureFontFamily } from '../../constants/pdf';
import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { svgToPng } from '../../utils/images/svg-to-png';
@@ -302,7 +303,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
x: 2,
text: recipient.signatureField?.signature?.typedSignature,
padding: 4,
fontFamily: 'Caveat',
fontFamily: getSignatureFontFamily(recipient.signatureField?.signature?.typedSignature),
fontSize: 16,
align: 'center',
verticalAlign: 'middle',
@@ -1,24 +1,16 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface DeleteEnvelopeRecipientOptions {
userId: number;
@@ -148,75 +140,16 @@ export const deleteEnvelopeRecipient = async ({
envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: envelope.team?.name || user.name || undefined,
assetBaseUrl,
});
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return deletedRecipient;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails
// outside the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipientToDelete.id,
// Enqueue the "removed from document" email as a background job so a
// transient mail outage doesn't fail the request and the send is retried.
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
});
return deletedRecipient;
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
inviterName: envelope.team?.name || user.name || undefined,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}
@@ -1,4 +1,3 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
@@ -7,25 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { isDeepEqual } from 'remeda';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface SetDocumentRecipientsOptions {
userId: number;
@@ -88,16 +80,6 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@@ -290,67 +272,29 @@ export const setDocumentRecipients = async ({
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
// Send emails to deleted recipients who have emails.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
emailsDisabled ||
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
if (isRecipientRemovedEmailEnabled) {
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
// Meter against the organisation email quota/stats so add/remove churn
// can't be used to send unsolicited "removed" emails outside the limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
inviterName: user.name || undefined,
},
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);
}),
);
}
}
// Filter out recipients that have been removed or have been updated.
@@ -6,22 +6,20 @@ import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constant
/**
* Compute and store `nextReminderAt` for a single recipient.
*
* Call this after:
* - Sending the signing email (sentAt is set)
* - Sending a reminder (lastReminderSentAt is updated)
*
* If `reminderSettings` is provided it's used directly, avoiding a query.
* Otherwise it's read from the envelope's documentMeta (already resolved
* from the org/team cascade at envelope creation time).
* Pass `resetReminderCount: true` to restart the reminder cycle (e.g. on a
* manual resend): the count is zeroed and the schedule recomputed as if the
* request was freshly sent at `sentAt`.
*/
export const updateRecipientNextReminder = async (options: {
recipientId: number;
envelopeId: string;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderCount?: number;
resetReminderCount?: boolean;
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
}) => {
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
const { recipientId, envelopeId, sentAt, lastReminderSentAt, reminderCount = 0, resetReminderCount } = options;
let settings = options.reminderSettings;
@@ -40,11 +38,15 @@ export const updateRecipientNextReminder = async (options: {
config: settings,
sentAt,
lastReminderSentAt,
reminderCount: resetReminderCount ? 0 : reminderCount,
});
await prisma.recipient.update({
where: { id: recipientId },
data: { nextReminderAt },
data: {
nextReminderAt,
...(resetReminderCount ? { reminderCount: 0 } : {}),
},
});
};
@@ -82,7 +84,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
// Don't reschedule reminders for recipients whose deadline has passed.
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true, sentAt: true, lastReminderSentAt: true },
select: { id: true, sentAt: true, lastReminderSentAt: true, reminderCount: true },
});
await Promise.all(
@@ -95,6 +97,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
config: settings,
sentAt: recipient.sentAt,
lastReminderSentAt: recipient.lastReminderSentAt,
reminderCount: recipient.reminderCount,
});
await prisma.recipient.update({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More