Merge branch 'main' into feat/document-2fa-redo

This commit is contained in:
Ephraim Atta-Duncan
2025-08-01 09:00:30 +00:00
167 changed files with 6918 additions and 1245 deletions

View File

@ -1,5 +1,10 @@
import { initContract } from '@ts-rest/core';
import {
ZCreateTemplateV2RequestSchema,
ZCreateTemplateV2ResponseSchema,
} from '@documenso/trpc/server/template-router/schema';
import {
ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema,
@ -87,6 +92,18 @@ export const ApiContractV1 = c.router(
summary: 'Upload a new document and get a presigned URL',
},
createTemplate: {
method: 'POST',
path: '/api/v1/templates',
body: ZCreateTemplateV2RequestSchema,
responses: {
200: ZCreateTemplateV2ResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Create a new template and get a presigned URL',
},
deleteTemplate: {
method: 'DELETE',
path: '/api/v1/templates/:id',

View File

@ -30,6 +30,7 @@ import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
@ -400,6 +401,109 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}
}),
createTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body } = args;
const {
title,
folderId,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
publicTitle,
publicDescription,
type,
meta,
} = body;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Create template is not available without S3 transport.',
},
};
}
const dateFormat = meta?.dateFormat
? DATE_FORMATS.find((format) => format.value === meta?.dateFormat)
: DATE_FORMATS.find((format) => format.value === DEFAULT_DOCUMENT_DATE_FORMAT);
if (meta?.dateFormat && !dateFormat) {
return {
status: 400,
body: {
message: 'Invalid date format. Please provide a valid date format',
},
};
}
const timezone = meta?.timezone
? TIME_ZONES.find((tz) => tz === meta?.timezone)
: DEFAULT_DOCUMENT_TIME_ZONE;
const isTimeZoneValid = meta?.timezone ? TIME_ZONES.includes(String(timezone)) : true;
if (!isTimeZoneValid) {
return {
status: 400,
body: {
message: 'Invalid timezone. Please provide a valid timezone',
},
};
}
const fileName = title?.endsWith('.pdf') ? title : `${title}.pdf`;
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
const templateDocumentData = await createDocumentData({
data: key,
type: DocumentDataType.S3_PATH,
});
const createdTemplate = await createTemplate({
userId: user.id,
teamId: team.id,
templateDocumentDataId: templateDocumentData.id,
data: {
title,
folderId,
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
publicTitle,
publicDescription,
type,
},
meta,
});
const fullTemplate = await getTemplateById({
id: createdTemplate.id,
userId: user.id,
teamId: team.id,
});
return {
status: 200,
body: {
uploadUrl: url,
template: fullTemplate,
},
};
} catch (err) {
return {
status: 404,
body: {
message: 'An error has occured while creating the template',
},
};
}
}),
deleteTemplate: authenticatedMiddleware(async (args, user, team, { logger }) => {
const { id: templateId } = args.params;

View File

@ -244,7 +244,7 @@ test.describe('Signing Certificate Tests', () => {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
await page

View File

@ -8,7 +8,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
@ -16,7 +16,7 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/preferences`,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Update document preferences.
@ -24,26 +24,25 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Only managers and above can' }).click();
await page.getByRole('combobox').filter({ hasText: 'English' }).click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByTestId('signature-types-combobox').click();
// Set default timezone
await page.getByRole('combobox').filter({ hasText: 'Local timezone' }).click();
await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await page.getByTestId('signature-types-trigger').click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Upload' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByTestId('include-sender-details-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('combobox').filter({ hasText: 'Yes' }).click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Update branding.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).nth(1).click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
@ -51,34 +50,30 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
// Check that the team settings have inherited these values.
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
expect(teamSettings.documentLanguage).toEqual('de');
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(teamSettings.includeSenderDetails).toEqual(false);
expect(teamSettings.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team settings
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
await page
.getByRole('group')
.locator('div')
.filter({
hasText: 'Default Document Visibility',
})
.getByRole('combobox')
.click();
await page.getByTestId('document-visibility-trigger').click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Default Document Language' })
.getByRole('combobox')
.click();
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: 'Polish' }).click();
// Override team timezone settings
await page.getByTestId('document-timezone-trigger').click();
await page.getByRole('option', { name: 'Europe/London' }).click();
// Override team date format settings
await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@ -89,6 +84,8 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
// Check that the team settings have inherited/overriden the correct values.
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
@ -110,4 +107,228 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Update branding preferences.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team branding settings
await page.goto(`/t/${team.url}/settings/branding`);
// Override team settings with different values
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have overridden the organisation values.
expect(updatedTeamSettings.brandingEnabled).toEqual(true);
expect(updatedTeamSettings.brandingUrl).toEqual('https://example.com');
expect(updatedTeamSettings.brandingCompanyDetails).toEqual('UpdatedBrandDetails');
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(2000);
const inheritedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings now inherit from organisation again.
expect(inheritedTeamSettings.brandingEnabled).toEqual(true);
expect(inheritedTeamSettings.brandingUrl).toEqual('https://documenso.com');
expect(inheritedTeamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Verify that a document can be created successfully with the branding settings
const document = await seedTeamDocumentWithMeta(team);
// Confirm the document was created successfully with the team's branding settings
expect(document).toBeDefined();
expect(document.teamId).toEqual(team.id);
});
test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/email`,
});
// Update email preferences at organisation level.
// Set reply to email
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
// Update email document settings by enabling/disabling some checkboxes
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.emailReplyTo).toEqual('organisation@documenso.com');
expect(teamSettings.emailDocumentSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false, // unchecked
documentPending: false, // unchecked
documentCompleted: true,
documentDeleted: false, // unchecked
ownerDocumentCompleted: true,
});
// Edit the team email settings
await page.goto(`/t/${team.url}/settings/email`);
// Override team settings with different values
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com');
// Change email document settings inheritance to controlled
await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
.uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have overridden the organisation values.
expect(updatedTeamSettings.emailReplyTo).toEqual('team@example.com');
expect(updatedTeamSettings.emailDocumentSettings).toEqual({
recipientSigned: true,
recipientSigningRequest: false,
recipientRemoved: true,
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerDocumentCompleted: false,
});
// Verify that a document can be created successfully with the team email settings
const teamOverrideDocument = await seedTeamDocumentWithMeta(team);
const teamOverrideDocumentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: teamOverrideDocument.id,
},
});
expect(teamOverrideDocumentMeta.emailReplyTo).toEqual('team@example.com');
expect(teamOverrideDocumentMeta.emailSettings).toEqual({
recipientSigned: true,
recipientSigningRequest: false,
recipientRemoved: true,
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerDocumentCompleted: false,
});
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(1000);
const inheritedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings now inherit from organisation again.
expect(inheritedTeamSettings.emailReplyTo).toEqual('organisation@documenso.com');
expect(inheritedTeamSettings.emailDocumentSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false,
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerDocumentCompleted: true,
});
// Verify that a document can be created successfully with the email settings
const document = await seedTeamDocumentWithMeta(team);
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: document.id,
},
});
expect(documentMeta.emailReplyTo).toEqual('organisation@documenso.com');
expect(documentMeta.emailSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false,
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerDocumentCompleted: true,
});
});

View File

@ -15,7 +15,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const document = await seedTeamDocumentWithMeta(team);
@ -45,17 +45,17 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await page.getByTestId('signature-types-trigger').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
@ -112,17 +112,17 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await page.getByTestId('signature-types-trigger').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();

View File

@ -0,0 +1,154 @@
import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { generateKeyPair } from 'crypto';
import { promisify } from 'util';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
export const getSesClient = () => {
const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY');
const region = env('NEXT_PRIVATE_SES_REGION');
if (!accessKeyId || !secretAccessKey || !region) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing AWS SES credentials',
});
}
return new SESv2Client({
region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
};
/**
* Removes first and last line, then removes all newlines
*/
const flattenKey = (key: string) => {
return key.trim().split('\n').slice(1, -1).join('');
};
export async function verifyDomainWithDKIM(domain: string, selector: string, privateKey: string) {
const command = new CreateEmailIdentityCommand({
EmailIdentity: domain,
DkimSigningAttributes: {
DomainSigningSelector: selector,
DomainSigningPrivateKey: privateKey,
},
});
return await getSesClient().send(command);
}
type CreateEmailDomainOptions = {
domain: string;
organisationId: string;
};
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const createEmailDomain = async ({ domain, organisationId }: CreateEmailDomainOptions) => {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const selector = `documenso-${organisationId}`.replace(/[_.]/g, '-');
const recordName = `${selector}._domainkey.${domain}`;
// Check if domain already exists
const existingDomain = await prisma.emailDomain.findUnique({
where: {
domain,
},
});
if (existingDomain) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in database',
});
}
// Generate DKIM key pair
const generateKeyPairAsync = promisify(generateKeyPair);
const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
// Format public key for DNS record
const publicKeyFlattened = flattenKey(publicKey);
const privateKeyFlattened = flattenKey(privateKey);
// Create DNS records
const records: DomainRecord[] = generateEmailDomainRecords(recordName, publicKeyFlattened);
const encryptedPrivateKey = symmetricEncrypt({
key: encryptionKey,
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
emails: true,
},
});
});
return {
emailDomain,
records,
};
};

View File

@ -0,0 +1,52 @@
import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getSesClient } from './create-email-domain';
type DeleteEmailDomainOptions = {
emailDomainId: string;
};
/**
* Delete the email domain and SES email identity.
*
* Permission is assumed to be checked in the caller.
*/
export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions) => {
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
await sesClient
.send(
new DeleteEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
)
.catch((err) => {
console.error(err);
// Do nothing if it no longer exists in SES.
if (err.name === 'NotFoundException') {
return;
}
});
await prisma.emailDomain.delete({
where: {
id: emailDomainId,
},
});
};

View File

@ -0,0 +1,45 @@
import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getSesClient } from './create-email-domain';
export const verifyEmailDomain = async (emailDomainId: string) => {
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
const response = await sesClient.send(
new GetEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
);
const isVerified = response.VerificationStatus === 'SUCCESS';
const updatedEmailDomain = await prisma.emailDomain.update({
where: {
id: emailDomainId,
},
data: {
status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
},
});
return {
emailDomain: updatedEmailDomain,
isVerified,
};
};

View File

@ -9,6 +9,7 @@ export const VALID_DATE_FORMAT_VALUES = [
'yyyy-MM-dd',
'dd/MM/yyyy hh:mm a',
'MM/dd/yyyy hh:mm a',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yyyy-MM-dd HH:mm:ss',
@ -40,6 +41,11 @@ export const DATE_FORMATS = [
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',

View File

@ -3,6 +3,11 @@ import { env } from '../utils/env';
export const FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com';
export const FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso';
export const DOCUMENSO_INTERNAL_EMAIL = {
name: FROM_NAME,
address: FROM_ADDRESS,
};
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
export const EMAIL_VERIFICATION_STATE = {

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -43,11 +42,13 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { documentMeta, user: documentOwner } = document;
@ -59,9 +60,7 @@ export const run = async ({
return;
}
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
@ -82,9 +81,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -95,10 +94,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" Cancelled`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -56,7 +55,8 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -80,29 +80,24 @@ export const run = async ({
organisationUrl: organisation.url,
});
const lang = settings.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -52,7 +51,8 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -76,28 +76,23 @@ export const run = async ({
organisationUrl: organisation.url,
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -71,17 +70,18 @@ export const run = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
@ -92,9 +92,9 @@ export const run = async ({
await io.runTask('send-recipient-signed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -105,10 +105,7 @@ export const run = async ({
name: owner.name ?? '',
address: owner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
html,
text,

View File

@ -10,7 +10,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -52,7 +52,7 @@ export const run = async ({
}),
]);
const { documentMeta, user: documentOwner } = document;
const { user: documentOwner } = document;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
@ -62,16 +62,16 @@ export const run = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
@ -84,9 +84,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang, branding }),
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -97,10 +97,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
html,
text,
@ -120,9 +118,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang, branding }),
renderEmailWithI18N(ownerTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(ownerTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -133,10 +131,7 @@ export const run = async ({
name: documentOwner.name || '',
address: documentOwner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: DOCUMENSO_INTERNAL_EMAIL, // Purposefully using internal email here.
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
html,
text,

View File

@ -15,7 +15,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -80,12 +79,15 @@ export const run = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@ -95,9 +97,7 @@ export const run = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
@ -166,9 +166,9 @@ export const run = async ({
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -179,10 +179,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,

View File

@ -13,7 +13,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { AppError } from '../../../errors/app-error';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -162,24 +161,23 @@ export const run = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
},
});
const lang = template.templateMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(completionTemplate, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(completionTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -190,10 +188,7 @@ export const run = async ({
name: user.name || '',
address: user.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
html,
text,

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/client-sesv2": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",

View File

@ -23,6 +23,8 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailId?: string | null;
emailReplyTo?: string | null;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
@ -46,6 +48,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -54,7 +58,7 @@ export const upsertDocumentMeta = async ({
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -75,6 +79,22 @@ export const upsertDocumentMeta = async ({
const { documentMeta: originalDocumentMeta } = document;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
@ -90,6 +110,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -106,6 +128,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,

View File

@ -24,6 +24,7 @@ import {
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
@ -134,6 +135,24 @@ export const createDocumentV2 = async ({
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -148,15 +167,7 @@ export const createDocumentV2 = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
...meta,
signingOrder: meta?.signingOrder || undefined,
emailSettings: meta?.emailSettings || undefined,
language: meta?.language || settings.documentLanguage,
typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});

View File

@ -15,6 +15,7 @@ import {
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
@ -30,6 +31,7 @@ export type CreateDocumentOptions = {
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
@ -44,6 +46,7 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
@ -101,6 +104,10 @@ export const createDocument = async ({
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -117,13 +124,9 @@ export const createDocument = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: settings.documentLanguage,
timezone: timezone,
typedSignatureEnabled: settings.typedSignatureEnabled,
uploadSignatureEnabled: settings.uploadSignatureEnabled,
drawSignatureEnabled: settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
}),
},
},
});

View File

@ -10,7 +10,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -151,11 +150,13 @@ const handleDocumentOwnerDelete = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
// Soft delete completed documents.
@ -232,28 +233,24 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -5,7 +5,6 @@ import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -96,12 +95,15 @@ export const resendDocument = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
@ -109,8 +111,7 @@ export const resendDocument = async ({
return;
}
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -169,11 +170,11 @@ export const resendDocument = async ({
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -186,10 +187,8 @@ export const resendDocument = async ({
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: customEmail?.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),

View File

@ -14,7 +14,6 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { env } from '../../utils/env';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
@ -54,11 +53,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { user: owner } = document;
@ -97,18 +98,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -117,10 +116,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: owner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Signing Complete!`),
html,
text,
@ -174,18 +171,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -194,10 +189,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: recipient.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)

View File

@ -10,7 +10,6 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@ -44,11 +43,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { email, name } = document.user;
@ -61,28 +62,23 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name: name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document Deleted!`),
html,
text,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@ -46,11 +45,13 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
@ -72,28 +73,24 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -41,11 +40,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
}
const { branding, settings } = await getEmailContext({
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { status, user } = document;
@ -92,10 +93,8 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -1,4 +1,5 @@
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -120,9 +121,11 @@ export const updateDocument = async ({
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
documentGlobalAccessAuth === undefined ||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
documentGlobalActionAuth === undefined ||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;

View File

@ -1,16 +1,34 @@
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type { OrganisationType } from '@documenso/prisma/client';
import { type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import type {
DocumentMeta,
EmailDomain,
Organisation,
OrganisationEmail,
OrganisationType,
} from '@documenso/prisma/client';
import {
EmailDomainStatus,
type OrganisationClaim,
type OrganisationGlobalSettings,
} from '@documenso/prisma/client';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { getTeamSettings } from '../team/get-team-settings';
import { extractDerivedTeamSettings } from '../../utils/teams';
type GetEmailContextOptions = {
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
type BaseGetEmailContextOptions = {
/**
* The source to extract the email context from.
* - "Team" will use the team settings followed by the inherited organisation settings
* - "Organisation" will use the organisation settings
*/
source:
| {
type: 'team';
@ -20,37 +38,112 @@ type GetEmailContextOptions = {
type: 'organisation';
organisationId: string;
};
/**
* The email type being sent, used to determine what email sender and language to use.
* - INTERNAL: Emails to users, such as team invites, etc.
* - RECIPIENT: Emails to recipients, such as document sent, document signed, etc.
*/
emailType: 'INTERNAL' | 'RECIPIENT';
};
type InternalGetEmailContextOptions = BaseGetEmailContextOptions & {
emailType: 'INTERNAL';
meta?: EmailMetaOption | null;
};
type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
emailType: 'RECIPIENT';
/**
* Force meta options as a typesafe way to ensure developers don't forget to
* pass it in if it is available.
*/
meta: EmailMetaOption | null;
};
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
type EmailContextResponse = {
allowedEmails: OrganisationEmail[];
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
organisationType: OrganisationType;
senderEmail: {
name: string;
address: string;
};
replyToEmail: string | undefined;
emailLanguage: string;
};
export const getEmailContext = async (
options: GetEmailContextOptions,
): Promise<EmailContextResponse> => {
const { source } = options;
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
if (source.type === 'organisation') {
emailContext = await handleOrganisationEmailContext(source.organisationId);
} else {
emailContext = await handleTeamEmailContext(source.teamId);
}
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
// Immediate return for internal emails.
if (options.emailType === 'INTERNAL') {
return {
...emailContext,
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
replyToEmail: undefined,
emailLanguage, // Not sure if we want to use this for internal emails.
};
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId || emailContext.settings.emailId;
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);
// Reset the emailId to null if not found.
if (!foundSenderEmail) {
emailContext.settings.emailId = null;
}
const senderEmail = foundSenderEmail
? {
name: foundSenderEmail.emailName,
address: foundSenderEmail.email,
}
: DOCUMENSO_INTERNAL_EMAIL;
return {
...emailContext,
senderEmail,
replyToEmail,
emailLanguage,
};
};
const handleOrganisationEmailContext = async (organisationId: string) => {
const organisation = await prisma.organisation.findFirst({
where:
source.type === 'organisation'
? {
id: source.organisationId,
}
: {
teams: {
some: {
id: source.teamId,
},
},
},
where: {
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
omit: {
privateKey: true,
},
include: {
emails: true,
},
},
},
});
@ -60,27 +153,64 @@ export const getEmailContext = async (
const claims = organisation.organisationClaim;
if (source.type === 'organisation') {
return {
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
settings: organisation.organisationGlobalSettings,
claims,
organisationType: organisation.type,
};
}
const teamSettings = await getTeamSettings({
teamId: source.teamId,
});
const allowedEmails = getAllowedEmails(organisation);
return {
allowedEmails,
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
settings: organisation.organisationGlobalSettings,
claims,
organisationType: organisation.type,
};
};
const handleTeamEmailContext = async (teamId: number) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
organisation: {
include: {
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
omit: {
privateKey: true,
},
include: {
emails: true,
},
},
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const organisation = team.organisation;
const claims = organisation.organisationClaim;
const allowedEmails = getAllowedEmails(organisation);
const teamSettings = extractDerivedTeamSettings(
organisation.organisationGlobalSettings,
team.teamGlobalSettings,
);
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(
teamSettings,
source.teamId,
teamId,
claims.flags.hidePoweredBy ?? false,
),
settings: teamSettings,
@ -88,3 +218,18 @@ export const getEmailContext = async (
organisationType: organisation.type,
};
};
const getAllowedEmails = (
organisation: Organisation & {
emailDomains: (Pick<EmailDomain, 'status'> & { emails: OrganisationEmail[] })[];
organisationClaim: OrganisationClaim;
},
) => {
if (!organisation.organisationClaim.flags.emailDomains) {
return [];
}
return organisation.emailDomains
.filter((emailDomain) => emailDomain.status === EmailDomainStatus.ACTIVE)
.flatMap((emailDomain) => emailDomain.emails);
};

View File

@ -9,7 +9,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
@ -190,7 +189,8 @@ export const sendOrganisationMemberInviteEmail = async ({
organisationName: organisation.name,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -199,24 +199,21 @@ export const sendOrganisationMemberInviteEmail = async ({
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(settings.documentLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
html,
text,

View File

@ -11,7 +11,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -125,31 +124,29 @@ export const deleteDocumentRecipient = async ({
assetBaseUrl,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

@ -25,7 +25,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients';
@ -71,13 +70,6 @@ export const setDocumentRecipients = async ({
},
});
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -97,6 +89,15 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: document.documentMeta || null,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@ -302,24 +303,20 @@ export const setDocumentRecipients = async ({
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

@ -8,14 +8,12 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
@ -122,33 +120,28 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
token,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: team.id,
},
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const lang = settings.documentLanguage as SupportedLanguageCodes;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),

View File

@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
@ -27,7 +26,8 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
@ -82,24 +82,19 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,

View File

@ -7,7 +7,6 @@ import { uniqueBy } from 'remeda';
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
@ -130,28 +129,24 @@ export const sendTeamDeleteEmail = async ({
teamUrl: team.url,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId,
},
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,

View File

@ -33,5 +33,13 @@ export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions
const organisationSettings = team.organisation.organisationGlobalSettings;
const teamSettings = team.teamGlobalSettings;
// Override branding settings if inherit is enabled.
if (teamSettings.brandingEnabled === null) {
teamSettings.brandingEnabled = organisationSettings.brandingEnabled;
teamSettings.brandingLogo = organisationSettings.brandingLogo;
teamSettings.brandingUrl = organisationSettings.brandingUrl;
teamSettings.brandingCompanyDetails = organisationSettings.brandingCompanyDetails;
}
return extractDerivedTeamSettings(organisationSettings, teamSettings);
};

View File

@ -3,7 +3,6 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
FieldType,
@ -25,8 +24,6 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
@ -38,6 +35,7 @@ import {
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isRequiredField } from '../../utils/advanced-fields-helpers';
import { extractDerivedDocumentMeta } from '../../utils/document';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -45,7 +43,6 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
@ -116,7 +113,8 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
const { branding, settings } = await getEmailContext({
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: template.teamId,
@ -169,13 +167,7 @@ export const createDocumentFromDirectTemplate = async ({
const nonDirectTemplateRecipients = template.recipients.filter(
(recipient) => recipient.id !== directTemplateRecipient.id,
);
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
const metaLanguage = template.templateMeta?.language ?? settings.documentLanguage;
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, template.templateMeta);
// Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
@ -234,7 +226,9 @@ export const createDocumentFromDirectTemplate = async ({
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (templateField.type === FieldType.DATE) {
customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat);
customText = DateTime.now()
.setZone(derivedDocumentMeta.timezone)
.toFormat(derivedDocumentMeta.dateFormat);
}
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
@ -318,18 +312,7 @@ export const createDocumentFromDirectTemplate = async ({
},
},
documentMeta: {
create: {
timezone: metaTimezone,
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
create: derivedDocumentMeta,
},
},
include: {
@ -589,11 +572,11 @@ export const createDocumentFromDirectTemplate = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(metaLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -602,10 +585,7 @@ export const createDocumentFromDirectTemplate = async ({
address: templateOwner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,

View File

@ -3,6 +3,7 @@ import { DocumentSource, type RecipientRole } from '@prisma/client';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
@ -78,18 +79,7 @@ export const createDocumentFromTemplateLegacy = async ({
})),
},
documentMeta: {
create: {
subject: template.templateMeta?.subject,
message: template.templateMeta?.message,
timezone: template.templateMeta?.timezone,
dateFormat: template.templateMeta?.dateFormat,
redirectUrl: template.templateMeta?.redirectUrl,
signingOrder: template.templateMeta?.signingOrder ?? undefined,
language: template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, template.templateMeta),
},
},

View File

@ -1,6 +1,5 @@
import type { DocumentDistributionMethod } from '@prisma/client';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
type Field,
type Recipient,
@ -40,6 +39,7 @@ import {
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
@ -378,7 +378,7 @@ export const createDocumentFromTemplate = async ({
visibility: template.visibility || settings.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: {
create: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
@ -387,13 +387,8 @@ export const createDocumentFromTemplate = async ({
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
// last `undefined` is due to JsonValue's
emailSettings:
override?.emailSettings || template.templateMeta?.emailSettings || undefined,
signingOrder:
override?.signingOrder ||
template.templateMeta?.signingOrder ||
DocumentSigningOrder.PARALLEL,
emailSettings: override?.emailSettings || template.templateMeta?.emailSettings,
signingOrder: override?.signingOrder || template.templateMeta?.signingOrder,
language:
override?.language || template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
@ -403,10 +398,8 @@ export const createDocumentFromTemplate = async ({
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ??
template.templateMeta?.allowDictateNextSigner ??
false,
},
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
}),
},
recipients: {
createMany: {

View File

@ -1,16 +1,32 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//TemplateSchema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
export type CreateTemplateOptions = {
userId: number;
teamId: number;
templateDocumentDataId: string;
data: {
title: string;
folderId?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
publicTitle?: string;
publicDescription?: string;
type?: Template['type'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
};
export const ZCreateTemplateResponseSchema = TemplateSchema;
@ -18,12 +34,14 @@ export const ZCreateTemplateResponseSchema = TemplateSchema;
export type TCreateTemplateResponse = z.infer<typeof ZCreateTemplateResponseSchema>;
export const createTemplate = async ({
title,
userId,
teamId,
templateDocumentDataId,
folderId,
data,
meta = {},
}: CreateTemplateOptions) => {
const { title, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
});
@ -52,20 +70,42 @@ export const createTemplate = async ({
teamId,
});
const emailId = meta.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.template.create({
data: {
title,
teamId,
userId,
templateDocumentDataId,
teamId,
folderId: folderId,
folderId,
externalId: data.externalId,
visibility: data.visibility ?? settings.documentVisibility,
authOptions: createDocumentAuthOptions({
globalAccessAuth: data.globalAccessAuth || [],
globalActionAuth: data.globalActionAuth || [],
}),
publicTitle: data.publicTitle,
publicDescription: data.publicDescription,
type: data.type,
templateMeta: {
create: {
language: settings.documentLanguage,
typedSignatureEnabled: settings.typedSignatureEnabled,
uploadSignatureEnabled: settings.uploadSignatureEnabled,
drawSignatureEnabled: settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});

View File

@ -43,6 +43,7 @@ export const updateTemplate = async ({
templateMeta: true,
team: {
select: {
organisationId: true,
organisation: {
select: {
organisationClaim: true,
@ -99,6 +100,24 @@ export const updateTemplate = async ({
globalActionAuth: newGlobalActionAuth,
});
const emailId = meta.emailId;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: template.team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.template.update({
where: {
id: templateId,

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ msgstr ""
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid " Enable direct link signing"
msgstr " Activer la signature de lien direct"
msgstr " Activer la signature par lien direct"
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
msgid ".PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)"
@ -42,7 +42,7 @@ msgstr "« {documentName} » a été signé"
#: packages/email/template-components/template-document-completed.tsx
msgid "“{documentName}” was signed by all signers"
msgstr "{documentName} a été signé par tous les signataires"
msgstr "« {documentName} » a été signé par tous les signataires"
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "\"{documentTitle}\" has been successfully deleted"
@ -50,7 +50,7 @@ msgstr "\"{documentTitle}\" a été supprimé avec succès"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "\"{placeholderEmail}\" on behalf of \"Team Name\" has invited you to sign \"example document\"."
msgstr "\"{placeholderEmail}\" au nom de \"Team Name\" vous a invité à signer \"example document\"."
msgstr "\"{placeholderEmail}\" représentant \"Team Name\" vous a invité à signer \"example document\"."
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "\"Team Name\" has invited you to sign \"example document\"."
@ -411,7 +411,7 @@ msgstr "<0>Cliquez pour importer</0> ou faites glisser et déposez"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Drawn</0> - A signature that is drawn using a mouse or stylus."
msgstr "<0>Signée</0> - Une signature dessinée en utilisant une souris ou un stylet."
msgstr "<0>Dessinée</0> - Une signature dessinée en utilisant une souris ou un stylet."
#: packages/ui/primitives/template-flow/add-template-settings.tsx
msgid "<0>Email</0> - The recipient will be emailed the document to sign, approve, etc."
@ -465,11 +465,11 @@ msgstr "<0>Expéditeur :</0> Tous"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Typed</0> - A signature that is typed using a keyboard."
msgstr "<0>Tappée</0> - Une signature tapée à l'aide d'un clavier."
msgstr "<0>Écrite</0> - Une signature écrite à l'aide d'un clavier."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "<0>Uploaded</0> - A signature that is uploaded from a file."
msgstr "<0>Téléchargée</0> - Une signature téléchargée à partir d'un fichier."
msgstr "<0>Importée</0> - Une signature importée à partir d'un fichier."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
msgid "<0>You are about to complete approving <1>\"{documentTitle}\"</1>.</0><2/> Are you sure?"
@ -1020,7 +1020,7 @@ msgstr "Autoriser les destinataires du document à répondre directement à cett
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Allow signers to dictate next signer"
msgstr "Permettre aux signataires de dicter le prochain signataire"
msgstr "Permettre aux signataires de désigner le prochain signataire"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
#: packages/ui/primitives/template-flow/add-template-settings.tsx
@ -1333,7 +1333,7 @@ msgstr "Approval en cours"
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
msgid "Are you sure you want to complete the document? This action cannot be undone. Please ensure that you have completed prefilling all relevant fields before proceeding."
msgstr "Êtes-vous sûr de vouloir terminer le document ? Cette action ne peut être annulée. Veuillez vous assurer d'avoir pré-rempli tous les champs pertinents avant de procéder."
msgstr "Êtes-vous sûr de vouloir finaliser le document ? Cette action ne peut être annulée. Veuillez vous assurer d'avoir pré-rempli tous les champs pertinents avant de continuer."
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
msgid "Are you sure you want to delete the following claim?"
@ -1840,7 +1840,7 @@ msgstr "Compléter la signature"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
msgid "Complete the fields for the following signers. Once reviewed, they will inform you if any modifications are needed."
msgstr "Complétez les champs pour les signataires suivants. Une fois révisés, ils vous informeront si des modifications sont nécessaires."
msgstr "Complétez les champs pour les signataires suivants. Une fois vérifiés, ils vous informeront si des modifications sont nécessaires."
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
@ -3655,7 +3655,7 @@ msgstr "t'a invité à voir ce document"
#: packages/ui/primitives/document-flow/add-signers.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
msgstr "Avoir un assistant comme dernier signataire signifie qu'il ne pourra prendre aucune mesure car il n'y a pas de signataires ultérieurs à assister."
msgstr "Avoir un assistant comme dernier signataire signifie qu'il ne pourra rien faire car il n'y a pas d'autres signataires à assister."
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Help complete the document for other signers."
@ -5185,7 +5185,7 @@ msgstr "Raison de l'annulation: {cancellationReason}"
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Reason for rejection: "
msgstr "Raison du rejet: "
msgstr "Raison du rejet : "
#: packages/email/template-components/template-document-rejected.tsx
msgid "Reason for rejection: {rejectionReason}"
@ -5940,7 +5940,7 @@ msgstr "La signature est trop petite"
#: apps/remix/app/components/forms/profile.tsx
msgid "Signature Pad cannot be empty."
msgstr "Le Pad de Signature ne peut pas être vide."
msgstr "Le champ de signature ne peut pas être vide."
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "Signature types"
@ -7453,7 +7453,7 @@ msgstr "Mettez à niveau votre plan pour importer plus de documents"
#: packages/lib/constants/document.ts
msgid "Upload"
msgstr "Télécharger"
msgstr "Importer"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
@ -8092,7 +8092,7 @@ msgstr "Ce que vous pouvez faire avec les équipes :"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Lorsqu'il est activé, les signataires peuvent choisir qui doit signer ensuite dans la séquence au lieu de suivre l'ordre prédéfini."
msgstr "Lorsqu'il est activé, les signataires peuvent choisir qui doit signer ensuite au lieu de suivre l'ordre prédéfini."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-07-14 18:04\n"
"PO-Revision-Date: 2025-07-18 02:41\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@ -3832,7 +3832,7 @@ msgstr "Statystyki instancji"
#: apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx
msgid "Invalid code. Please try again."
msgstr "Nieprawidłowy kod. Proszę spróbuj ponownie."
msgstr "Kod jest nieprawidłowy. Spróbuj ponownie."
#: packages/ui/primitives/document-flow/add-signers.types.ts
msgid "Invalid email"
@ -4947,7 +4947,7 @@ msgstr "Proszę sprawdzić plik CSV i upewnić się, że jest zgodny z naszym fo
#: apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx
msgid "Please check with the parent application for more information."
msgstr "Sprawdź, proszę, aplikację nadrzędną po więcej informacji."
msgstr "Sprawdź aplikację nadrzędną, aby uzyskać więcej informacji."
#: apps/remix/app/routes/_recipient+/sign.$token+/waiting.tsx
msgid "Please check your email for updates."
@ -4955,7 +4955,7 @@ msgstr "Proszę sprawdzić swój email w celu aktualizacji."
#: apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx
msgid "Please choose your new password"
msgstr "Proszę wybrać nowe hasło"
msgstr "Wybierz nowe hasło"
#: apps/remix/app/routes/embed+/v1+/authoring+/template.edit.$id.tsx
#: apps/remix/app/routes/embed+/v1+/authoring+/document.edit.$id.tsx
@ -5065,7 +5065,7 @@ msgstr "Wpisz <0>{0}</0>, aby potwierdzić."
#: apps/remix/app/components/forms/branding-preferences-form.tsx
msgid "Please upload a logo"
msgstr "Proszę przesłać logo"
msgstr "Prześlij logo"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Pre-formatted CSV template with example data."

View File

@ -58,6 +58,9 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
'REDIRECT_URL',
'SUBJECT',
'TIMEZONE',
'EMAIL_ID',
'EMAIL_REPLY_TO',
'EMAIL_SETTINGS',
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
@ -109,6 +112,9 @@ export const ZDocumentAuditLogDocumentMetaSchema = z.union([
z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL),
z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT),
z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_ID),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS),
]),
from: z.string().nullable(),
to: z.string().nullable(),

View File

@ -54,15 +54,7 @@ export const ZDocumentEmailSettingsSchema = z
.default(true),
})
.strip()
.catch(() => ({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
}));
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
export type TDocumentEmailSettings = z.infer<typeof ZDocumentEmailSettingsSchema>;
@ -88,3 +80,13 @@ export const extractDerivedDocumentEmailSettings = (
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
};
};
export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
};

View File

@ -58,6 +58,8 @@ export const ZDocumentSchema = DocumentSchema.pick({
allowDictateNextSigner: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
folder: FolderSchema.pick({
id: true,

View File

@ -0,0 +1,40 @@
import type { z } from 'zod';
import { EmailDomainSchema } from '@documenso/prisma/generated/zod/modelSchema/EmailDomainSchema';
import { ZOrganisationEmailLiteSchema } from './organisation-email';
/**
* The full email domain response schema.
*
* Mainly used for returning a single email domain from the API.
*/
export const ZEmailDomainSchema = EmailDomainSchema.pick({
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
}).extend({
emails: ZOrganisationEmailLiteSchema.array(),
});
export type TEmailDomain = z.infer<typeof ZEmailDomainSchema>;
/**
* A version of the email domain response schema when returning multiple email domains at once from a single API endpoint.
*/
export const ZEmailDomainManySchema = EmailDomainSchema.pick({
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
createdAt: true,
updatedAt: true,
});
export type TEmailDomainMany = z.infer<typeof ZEmailDomainManySchema>;

View File

@ -0,0 +1,42 @@
import { EmailDomainStatus } from '@prisma/client';
import { z } from 'zod';
import { OrganisationEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationEmailSchema';
export const ZOrganisationEmailSchema = OrganisationEmailSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
}).extend({
emailDomain: z.object({
id: z.string(),
status: z.nativeEnum(EmailDomainStatus),
}),
});
export type TOrganisationEmail = z.infer<typeof ZOrganisationEmailSchema>;
/**
* A lite version of the organisation email response schema without relations.
*/
export const ZOrganisationEmailLiteSchema = OrganisationEmailSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
});
export const ZOrganisationEmailManySchema = ZOrganisationEmailLiteSchema.extend({
// Put anything extra here.
});
export type TOrganisationEmailMany = z.infer<typeof ZOrganisationEmailManySchema>;

View File

@ -19,6 +19,8 @@ export const ZClaimFlagsSchema = z.object({
unlimitedDocuments: z.boolean().optional(),
emailDomains: z.boolean().optional(),
embedAuthoring: z.boolean().optional(),
embedAuthoringWhiteLabel: z.boolean().optional(),
@ -50,6 +52,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'hidePoweredBy',
label: 'Hide Documenso branding by',
},
emailDomains: {
key: 'emailDomains',
label: 'Email domains',
},
embedAuthoring: {
key: 'embedAuthoring',
label: 'Embed authoring',
@ -128,6 +134,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
embedAuthoring: false,
embedAuthoringWhiteLabel: true,
embedSigning: false,
@ -144,6 +151,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
embedSigning: true,

View File

@ -55,6 +55,8 @@ export const ZTemplateSchema = TemplateSchema.pick({
redirectUrl: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.nullable(),
user: UserSchema.pick({

View File

@ -11,7 +11,9 @@ export const prefixedId = (prefix: string, length = 16) => {
};
type DatabaseIdPrefix =
| 'email_domain'
| 'org'
| 'org_email'
| 'org_claim'
| 'org_group'
| 'org_setting'

View File

@ -205,12 +205,18 @@ export const diffDocumentMetaChanges = (
const oldTimezone = oldData?.timezone ?? '';
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
const oldEmailId = oldData?.emailId || null;
const oldEmailReplyTo = oldData?.emailReplyTo || null;
const oldEmailSettings = oldData?.emailSettings || null;
const newDateFormat = newData?.dateFormat ?? '';
const newMessage = newData?.message ?? '';
const newSubject = newData?.subject ?? '';
const newTimezone = newData?.timezone ?? '';
const newRedirectUrl = newData?.redirectUrl ?? '';
const newEmailId = newData?.emailId || null;
const newEmailReplyTo = newData?.emailReplyTo || null;
const newEmailSettings = newData?.emailSettings || null;
if (oldDateFormat !== newDateFormat) {
diffs.push({
@ -258,6 +264,30 @@ export const diffDocumentMetaChanges = (
});
}
if (oldEmailId !== newEmailId) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_ID,
from: oldEmailId,
to: newEmailId,
});
}
if (oldEmailReplyTo !== newEmailReplyTo) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO,
from: oldEmailReplyTo,
to: newEmailReplyTo,
});
}
if (!isDeepEqual(oldEmailSettings, newEmailSettings)) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS,
from: JSON.stringify(oldEmailSettings),
to: JSON.stringify(newEmailSettings),
});
}
return diffs;
};

View File

@ -1,8 +1,62 @@
import type { Document } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import type {
Document,
DocumentMeta,
OrganisationGlobalSettings,
TemplateMeta,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isDocumentCompleted = (document: Pick<Document, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
};
/**
* Extracts the derived document meta which should be used when creating a document
* from scratch, or from a template.
*
* Uses the following, the lower number overrides the higher number:
* 1. Merged organisation/team settings
* 2. Meta overrides
*
* @param settings - The merged organisation/team settings.
* @param overrideMeta - The meta to override the settings with.
* @returns The derived document meta.
*/
export const extractDerivedDocumentMeta = (
settings: Omit<OrganisationGlobalSettings, 'id'>,
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
) => {
const meta = overrideMeta ?? {};
// Note: If you update this you will also need to update `create-document-from-template.ts`
// since there is custom work there which allows 3 overrides.
return {
language: meta.language || settings.documentLanguage,
timezone: meta.timezone || settings.documentTimezone || DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: meta.dateFormat || settings.documentDateFormat,
message: meta.message || null,
subject: meta.subject || null,
password: meta.password || null,
redirectUrl: meta.redirectUrl || null,
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
// Signature settings.
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
// Email settings.
emailId: meta.emailId ?? settings.emailId,
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
};

View File

@ -0,0 +1,17 @@
export const generateDkimRecord = (recordName: string, publicKeyFlattened: string) => {
return {
name: recordName,
value: `v=DKIM1; k=rsa; p=${publicKeyFlattened}`,
type: 'TXT',
};
};
export const AWS_SES_SPF_RECORD = {
name: `@`,
value: 'v=spf1 include:amazonses.com -all',
type: 'TXT',
};
export const generateEmailDomainRecords = (recordName: string, publicKeyFlattened: string) => {
return [generateDkimRecord(recordName, publicKeyFlattened), AWS_SES_SPF_RECORD];
};

View File

@ -7,11 +7,13 @@ import {
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../constants/organisations';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isPersonalLayout = (organisations: Pick<Organisation, 'type'>[]) => {
return organisations.length === 1 && organisations[0].type === 'PERSONAL';
@ -113,6 +115,9 @@ export const generateDefaultOrganisationSettings = (): Omit<
return {
documentVisibility: DocumentVisibility.EVERYONE,
documentLanguage: 'en',
documentTimezone: null, // Null means local timezone.
documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT,
includeSenderDetails: true,
includeSigningCertificate: true,
@ -124,5 +129,10 @@ export const generateDefaultOrganisationSettings = (): Omit<
brandingLogo: '',
brandingUrl: '',
brandingCompanyDetails: '',
emailId: null,
emailReplyTo: null,
// emailReplyToName: null,
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
};
};

View File

@ -165,6 +165,9 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
return {
documentVisibility: null,
documentLanguage: null,
documentTimezone: null,
documentDateFormat: null,
includeSenderDetails: null,
includeSigningCertificate: null,
@ -176,6 +179,11 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
brandingLogo: null,
brandingUrl: null,
brandingCompanyDetails: null,
emailDocumentSettings: null,
emailId: null,
emailReplyTo: null,
// emailReplyToName: null,
};
};

View File

@ -0,0 +1,94 @@
-- [CUSTOM_MIGRATION] Required to fill in missing rows for `emailDocumentSettings` column.
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "emailDocumentSettings" JSONB;
-- [CUSTOM_CHANGE] Insert default values for `emailDocumentSettings` column.
UPDATE "OrganisationGlobalSettings"
SET "emailDocumentSettings" = '{
"recipientSigningRequest": true,
"recipientRemoved": true,
"recipientSigned": true,
"documentPending": true,
"documentCompleted": true,
"documentDeleted": true,
"ownerDocumentCompleted": true
}'::jsonb
WHERE "emailDocumentSettings" IS NULL;
-- CreateEnum
CREATE TYPE "EmailDomainStatus" AS ENUM ('PENDING', 'ACTIVE');
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "documentDateFormat" TEXT NOT NULL DEFAULT 'yyyy-MM-dd hh:mm a',
ADD COLUMN "documentTimezone" TEXT,
ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- [CUSTOM_MIGRATION] Set the `emailDocumentSettings` column back to not null.
ALTER TABLE "OrganisationGlobalSettings" ALTER COLUMN "emailDocumentSettings" SET NOT NULL;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "documentDateFormat" TEXT,
ADD COLUMN "documentTimezone" TEXT,
ADD COLUMN "emailDocumentSettings" JSONB,
ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- AlterTable
ALTER TABLE "TemplateMeta" ADD COLUMN "emailId" TEXT,
ADD COLUMN "emailReplyTo" TEXT;
-- CreateTable
CREATE TABLE "EmailDomain" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"status" "EmailDomainStatus" NOT NULL DEFAULT 'PENDING',
"selector" TEXT NOT NULL,
"domain" TEXT NOT NULL,
"publicKey" TEXT NOT NULL,
"privateKey" TEXT NOT NULL,
"organisationId" TEXT NOT NULL,
CONSTRAINT "EmailDomain_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrganisationEmail" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"email" TEXT NOT NULL,
"emailName" TEXT NOT NULL,
"emailDomainId" TEXT NOT NULL,
"organisationId" TEXT NOT NULL,
CONSTRAINT "OrganisationEmail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "EmailDomain_selector_key" ON "EmailDomain"("selector");
-- CreateIndex
CREATE UNIQUE INDEX "EmailDomain_domain_key" ON "EmailDomain"("domain");
-- CreateIndex
CREATE UNIQUE INDEX "OrganisationEmail_email_key" ON "OrganisationEmail"("email");
-- AddForeignKey
ALTER TABLE "OrganisationGlobalSettings" ADD CONSTRAINT "OrganisationGlobalSettings_emailId_fkey" FOREIGN KEY ("emailId") REFERENCES "OrganisationEmail"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamGlobalSettings" ADD CONSTRAINT "TeamGlobalSettings_emailId_fkey" FOREIGN KEY ("emailId") REFERENCES "OrganisationEmail"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmailDomain" ADD CONSTRAINT "EmailDomain_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganisationEmail" ADD CONSTRAINT "OrganisationEmail_emailDomainId_fkey" FOREIGN KEY ("emailDomainId") REFERENCES "EmailDomain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganisationEmail" ADD CONSTRAINT "OrganisationEmail_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -469,7 +469,10 @@ model DocumentMeta {
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
}
enum ReadStatus {
@ -620,6 +623,9 @@ model Organisation {
teams Team[]
emailDomains EmailDomain[]
organisationEmails OrganisationEmail[]
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
ownerUserId Int
@ -724,31 +730,46 @@ enum OrganisationMemberInviteStatus {
DECLINED
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model OrganisationGlobalSettings {
id String @id
organisation Organisation?
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])
emailReplyTo String?
// emailReplyToName String? // Placeholder for future feature.
emailDocumentSettings Json /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
brandingEnabled Boolean @default(false)
brandingLogo String @default("")
brandingUrl String @default("")
brandingCompanyDetails String @default("")
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model TeamGlobalSettings {
id String @id
team Team?
documentVisibility DocumentVisibility?
documentLanguage String?
documentVisibility DocumentVisibility?
documentLanguage String?
documentTimezone String?
documentDateFormat String?
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
@ -756,6 +777,13 @@ model TeamGlobalSettings {
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
emailId String?
email OrganisationEmail? @relation(fields: [emailId], references: [id])
emailReplyTo String?
// emailReplyToName String? // Placeholder for future feature.
emailDocumentSettings Json? /// [DocumentEmailSettingsNullable] @zod.custom.use(ZDocumentEmailSettingsSchema)
brandingEnabled Boolean?
brandingLogo String?
brandingUrl String?
@ -830,11 +858,14 @@ model TemplateMeta {
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
language String @default("en")
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
language String @default("en")
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
@ -962,3 +993,45 @@ model UserTwoFactorEmailVerification {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum EmailDomainStatus {
PENDING
ACTIVE
}
model EmailDomain {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status EmailDomainStatus @default(PENDING)
selector String @unique
domain String @unique
publicKey String
privateKey String
organisationId String
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
emails OrganisationEmail[]
}
model OrganisationEmail {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
email String @unique
emailName String
// replyTo String?
emailDomainId String
emailDomain EmailDomain @relation(fields: [emailDomainId], references: [id], onDelete: Cascade)
organisationId String
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
organisationGlobalSettings OrganisationGlobalSettings[]
teamGlobalSettings TeamGlobalSettings[]
}

View File

@ -207,7 +207,9 @@ export const seedTeamTemplateWithMeta = async (team: Team) => {
const ownerUser = organisation.owner;
const template = await createTemplate({
title: `[TEST] Template ${nanoid(8)} - Draft`,
data: {
title: `[TEST] Template ${nanoid(8)} - Draft`,
},
userId: ownerUser.id,
teamId: team.id,
templateDocumentDataId: documentData.id,

View File

@ -18,6 +18,7 @@ declare global {
type DocumentFormValues = TDocumentFormValues;
type DocumentAuthOptions = TDocumentAuthOptions;
type DocumentEmailSettings = TDocumentEmailSettings;
type DocumentEmailSettingsNullable = TDocumentEmailSettings | null;
type RecipientAuthOptions = TRecipientAuthOptions;

View File

@ -1,20 +0,0 @@
import { router } from '../trpc';
import { createSubscriptionRoute } from './create-subscription';
import { getInvoicesRoute } from './get-invoices';
import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { manageSubscriptionRoute } from './manage-subscription';
export const billingRouter = router({
plans: {
get: getPlansRoute,
},
subscription: {
get: getSubscriptionRoute,
create: createSubscriptionRoute,
manage: manageSubscriptionRoute,
},
invoices: {
get: getInvoicesRoute,
},
});

View File

@ -322,7 +322,7 @@ export const documentRouter = router({
return {
document: createdDocument,
folder: createdDocument.folder,
folder: createdDocument.folder, // Todo: Remove this prior to api-v2 release.
uploadUrl: url,
};
}),
@ -367,7 +367,7 @@ export const documentRouter = router({
title,
documentDataId,
normalizePdf: true,
timezone,
userTimezone: timezone,
requestMetadata: ctx.metadata,
folderId,
});
@ -477,6 +477,8 @@ export const documentRouter = router({
distributionMethod: meta.distributionMethod,
emailSettings: meta.emailSettings,
language: meta.language,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,
requestMetadata: ctx.metadata,
});
}

View File

@ -294,6 +294,8 @@ export const ZDistributeDocumentRequestSchema = z.object({
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
language: ZDocumentMetaLanguageSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),

View File

@ -44,6 +44,8 @@ export const updateDocumentRoute = authenticatedProcedure
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
allowDictateNextSigner: meta.allowDictateNextSigner,
emailId: meta.emailId,
emailReplyTo: meta.emailReplyTo,
emailSettings: meta.emailSettings,
requestMetadata: ctx.metadata,
});

View File

@ -61,6 +61,8 @@ export const ZUpdateDocumentRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: z.string().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),

View File

@ -33,7 +33,9 @@ export const createEmbeddingTemplateRoute = procedure
// First create the template
const template = await createTemplate({
userId: apiToken.userId,
title,
data: {
title,
},
templateDocumentDataId: documentDataId,
teamId: apiToken.teamId ?? undefined,
});
@ -77,16 +79,31 @@ export const createEmbeddingTemplateRoute = procedure
// Update the template meta if needed
if (meta) {
const upsertMetaData = {
subject: meta.subject,
message: meta.message,
timezone: meta.timezone,
dateFormat: meta.dateFormat,
distributionMethod: meta.distributionMethod,
signingOrder: meta.signingOrder,
redirectUrl: meta.redirectUrl,
language: meta.language,
typedSignatureEnabled: meta.typedSignatureEnabled,
drawSignatureEnabled: meta.drawSignatureEnabled,
uploadSignatureEnabled: meta.uploadSignatureEnabled,
emailSettings: meta.emailSettings,
};
await prisma.templateMeta.upsert({
where: {
templateId: template.id,
},
create: {
templateId: template.id,
...meta,
...upsertMetaData,
},
update: {
...meta,
...upsertMetaData,
},
});
}

View File

@ -0,0 +1,66 @@
import { createEmailDomain } from '@documenso/ee/server-only/lib/create-email-domain';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateOrganisationEmailDomainRequestSchema,
ZCreateOrganisationEmailDomainResponseSchema,
} from './create-organisation-email-domain.types';
export const createOrganisationEmailDomainRoute = authenticatedProcedure
.input(ZCreateOrganisationEmailDomainRequestSchema)
.output(ZCreateOrganisationEmailDomainResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, domain } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
domain,
},
});
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
emailDomains: true,
organisationClaim: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
if (!organisation.organisationClaim.flags.emailDomains) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Email domains are not enabled for this organisation',
});
}
if (organisation.emailDomains.length >= 100) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You have reached the maximum number of email domains',
});
}
return await createEmailDomain({
domain,
organisationId,
});
});

View File

@ -0,0 +1,27 @@
import { z } from 'zod';
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
const domainRegex =
/^(?!https?:\/\/)(?!www\.)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
export const ZDomainSchema = z
.string()
.regex(domainRegex, { message: 'Invalid domain name' })
.toLowerCase();
export const ZCreateOrganisationEmailDomainRequestSchema = z.object({
organisationId: z.string(),
domain: ZDomainSchema,
});
export const ZCreateOrganisationEmailDomainResponseSchema = z.object({
emailDomain: ZEmailDomainSchema,
records: z.array(
z.object({
name: z.string(),
value: z.string(),
type: z.string(),
}),
),
});

View File

@ -0,0 +1,61 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateOrganisationEmailRequestSchema,
ZCreateOrganisationEmailResponseSchema,
} from './create-organisation-email.types';
export const createOrganisationEmailRoute = authenticatedProcedure
.input(ZCreateOrganisationEmailRequestSchema)
.output(ZCreateOrganisationEmailResponseSchema)
.mutation(async ({ input, ctx }) => {
const { email, emailName, emailDomainId } = input;
const { user } = ctx;
ctx.logger.info({
input: {
emailDomainId,
},
});
const emailDomain = await prisma.emailDomain.findFirst({
where: {
id: emailDomainId,
organisation: buildOrganisationWhereQuery({
organisationId: undefined,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const allowedEmailSuffix = '@' + emailDomain.domain;
if (!email.endsWith(allowedEmailSuffix)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Cannot create an email with a different domain',
});
}
await prisma.organisationEmail.create({
data: {
id: generateDatabaseId('org_email'),
organisationId: emailDomain.organisationId,
emailName,
// replyTo,
email,
emailDomainId,
},
});
});

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const ZCreateOrganisationEmailRequestSchema = z.object({
emailDomainId: z.string(),
emailName: z.string().min(1).max(100),
email: z.string().email().toLowerCase(),
// This does not need to be validated to be part of the domain.
// replyTo: z.string().email().optional(),
});
export const ZCreateOrganisationEmailResponseSchema = z.void();

View File

@ -0,0 +1,53 @@
import { deleteEmailDomain } from '@documenso/ee/server-only/lib/delete-email-domain';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationEmailDomainRequestSchema,
ZDeleteOrganisationEmailDomainResponseSchema,
} from './delete-organisation-email-domain.types';
export const deleteOrganisationEmailDomainRoute = authenticatedProcedure
.input(ZDeleteOrganisationEmailDomainRequestSchema)
.output(ZDeleteOrganisationEmailDomainResponseSchema)
.mutation(async ({ input, ctx }) => {
const { emailDomainId } = input;
const { user } = ctx;
ctx.logger.info({
input: {
emailDomainId,
},
});
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const emailDomain = await prisma.emailDomain.findFirst({
where: {
id: emailDomainId,
organisation: buildOrganisationWhereQuery({
organisationId: undefined,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
await deleteEmailDomain({
emailDomainId: emailDomain.id,
});
});

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZDeleteOrganisationEmailDomainRequestSchema = z.object({
emailDomainId: z.string(),
});
export const ZDeleteOrganisationEmailDomainResponseSchema = z.void();

View File

@ -0,0 +1,45 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationEmailRequestSchema,
ZDeleteOrganisationEmailResponseSchema,
} from './delete-organisation-email.types';
export const deleteOrganisationEmailRoute = authenticatedProcedure
.input(ZDeleteOrganisationEmailRequestSchema)
.output(ZDeleteOrganisationEmailResponseSchema)
.mutation(async ({ input, ctx }) => {
const { emailId } = input;
const { user } = ctx;
ctx.logger.info({
input: {
emailId,
},
});
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisation: buildOrganisationWhereQuery({
organisationId: undefined,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
},
});
if (!email) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
await prisma.organisationEmail.delete({
where: {
id: email.id,
},
});
});

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZDeleteOrganisationEmailRequestSchema = z.object({
emailId: z.string(),
});
export const ZDeleteOrganisationEmailResponseSchema = z.void();

View File

@ -0,0 +1,122 @@
import type { EmailDomainStatus } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindOrganisationEmailDomainsRequestSchema,
ZFindOrganisationEmailDomainsResponseSchema,
} from './find-organisation-email-domain.types';
export const findOrganisationEmailDomainsRoute = authenticatedProcedure
.input(ZFindOrganisationEmailDomainsRequestSchema)
.output(ZFindOrganisationEmailDomainsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, emailDomainId, statuses, query, page, perPage } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
},
});
return await findOrganisationEmailDomains({
userId: user.id,
organisationId,
emailDomainId,
statuses,
query,
page,
perPage,
});
});
type FindOrganisationEmailDomainsOptions = {
userId: number;
organisationId: string;
emailDomainId?: string;
statuses?: EmailDomainStatus[];
query?: string;
page?: number;
perPage?: number;
};
export const findOrganisationEmailDomains = async ({
userId,
organisationId,
emailDomainId,
statuses = [],
query,
page = 1,
perPage = 100,
}: FindOrganisationEmailDomainsOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.EmailDomainWhereInput = {
organisationId: organisation.id,
status: statuses.length > 0 ? { in: statuses } : undefined,
};
if (emailDomainId) {
whereClause.id = emailDomainId;
}
if (query) {
whereClause.domain = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.emailDomain.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
emails: true,
},
},
},
}),
prisma.emailDomain.count({
where: whereClause,
}),
]);
const mappedData = data.map((item) => ({
...item,
emailCount: item._count.emails,
}));
return {
data: mappedData,
count,
currentPage: page,
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof mappedData>;
};

View File

@ -0,0 +1,23 @@
import { EmailDomainStatus } from '@prisma/client';
import { z } from 'zod';
import { ZEmailDomainManySchema } from '@documenso/lib/types/email-domain';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
export const ZFindOrganisationEmailDomainsRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
emailDomainId: z.string().optional(),
statuses: z.nativeEnum(EmailDomainStatus).array().optional(),
});
export const ZFindOrganisationEmailDomainsResponseSchema = ZFindResultResponse.extend({
data: z.array(
ZEmailDomainManySchema.extend({
emailCount: z.number(),
}),
),
});
export type TFindOrganisationEmailDomainsResponse = z.infer<
typeof ZFindOrganisationEmailDomainsResponseSchema
>;

View File

@ -0,0 +1,105 @@
import { Prisma } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindOrganisationEmailsRequestSchema,
ZFindOrganisationEmailsResponseSchema,
} from './find-organisation-emails.types';
export const findOrganisationEmailsRoute = authenticatedProcedure
.input(ZFindOrganisationEmailsRequestSchema)
.output(ZFindOrganisationEmailsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, emailDomainId, query, page, perPage } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
},
});
return await findOrganisationEmails({
userId: user.id,
organisationId,
emailDomainId,
query,
page,
perPage,
});
});
type FindOrganisationEmailsOptions = {
userId: number;
organisationId: string;
emailDomainId?: string;
query?: string;
page?: number;
perPage?: number;
};
export const findOrganisationEmails = async ({
userId,
organisationId,
emailDomainId,
query,
page = 1,
perPage = 100,
}: FindOrganisationEmailsOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.OrganisationEmailWhereInput = {
organisationId: organisation.id,
emailDomainId,
};
if (query) {
whereClause.email = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.organisationEmail.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
},
}),
prisma.organisationEmail.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: page,
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { ZOrganisationEmailManySchema } from '@documenso/lib/types/organisation-email';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
export const ZFindOrganisationEmailsRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
emailDomainId: z.string().optional(),
});
export const ZFindOrganisationEmailsResponseSchema = ZFindResultResponse.extend({
data: ZOrganisationEmailManySchema.array(),
});
export type TFindOrganisationEmailsResponse = z.infer<typeof ZFindOrganisationEmailsResponseSchema>;

View File

@ -0,0 +1,63 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationEmailDomainRequestSchema,
ZGetOrganisationEmailDomainResponseSchema,
} from './get-organisation-email-domain.types';
export const getOrganisationEmailDomainRoute = authenticatedProcedure
.input(ZGetOrganisationEmailDomainRequestSchema)
.output(ZGetOrganisationEmailDomainResponseSchema)
.query(async ({ input, ctx }) => {
const { emailDomainId } = input;
ctx.logger.info({
input: {
emailDomainId,
},
});
return await getOrganisationEmailDomain({
userId: ctx.user.id,
emailDomainId,
});
});
type GetOrganisationEmailDomainOptions = {
userId: number;
emailDomainId: string;
};
export const getOrganisationEmailDomain = async ({
userId,
emailDomainId,
}: GetOrganisationEmailDomainOptions) => {
const emailDomain = await prisma.emailDomain.findFirst({
where: {
id: emailDomainId,
organisation: buildOrganisationWhereQuery({
organisationId: undefined,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
},
omit: {
privateKey: true,
},
include: {
emails: true,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
return emailDomain;
};

View File

@ -0,0 +1,13 @@
import { z } from 'zod';
import { ZEmailDomainSchema } from '@documenso/lib/types/email-domain';
export const ZGetOrganisationEmailDomainRequestSchema = z.object({
emailDomainId: z.string(),
});
export const ZGetOrganisationEmailDomainResponseSchema = ZEmailDomainSchema;
export type TGetOrganisationEmailDomainResponse = z.infer<
typeof ZGetOrganisationEmailDomainResponseSchema
>;

View File

@ -0,0 +1,46 @@
import { router } from '../trpc';
import { createOrganisationEmailRoute } from './create-organisation-email';
import { createOrganisationEmailDomainRoute } from './create-organisation-email-domain';
import { createSubscriptionRoute } from './create-subscription';
import { deleteOrganisationEmailRoute } from './delete-organisation-email';
import { deleteOrganisationEmailDomainRoute } from './delete-organisation-email-domain';
import { findOrganisationEmailDomainsRoute } from './find-organisation-email-domain';
import { findOrganisationEmailsRoute } from './find-organisation-emails';
import { getInvoicesRoute } from './get-invoices';
import { getOrganisationEmailDomainRoute } from './get-organisation-email-domain';
import { getPlansRoute } from './get-plans';
import { getSubscriptionRoute } from './get-subscription';
import { manageSubscriptionRoute } from './manage-subscription';
import { updateOrganisationEmailRoute } from './update-organisation-email';
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
export const enterpriseRouter = router({
organisation: {
email: {
find: findOrganisationEmailsRoute,
create: createOrganisationEmailRoute,
update: updateOrganisationEmailRoute,
delete: deleteOrganisationEmailRoute,
},
emailDomain: {
get: getOrganisationEmailDomainRoute,
find: findOrganisationEmailDomainsRoute,
create: createOrganisationEmailDomainRoute,
delete: deleteOrganisationEmailDomainRoute,
verify: verifyOrganisationEmailDomainRoute,
},
},
billing: {
plans: {
get: getPlansRoute,
},
subscription: {
get: getSubscriptionRoute,
create: createSubscriptionRoute,
manage: manageSubscriptionRoute,
},
invoices: {
get: getInvoicesRoute,
},
},
});

View File

@ -0,0 +1,49 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationEmailRequestSchema,
ZUpdateOrganisationEmailResponseSchema,
} from './update-organisation-email.types';
export const updateOrganisationEmailRoute = authenticatedProcedure
.input(ZUpdateOrganisationEmailRequestSchema)
.output(ZUpdateOrganisationEmailResponseSchema)
.mutation(async ({ input, ctx }) => {
const { emailId, emailName } = input;
const { user } = ctx;
ctx.logger.info({
input: {
emailId,
},
});
const organisationEmail = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisation: buildOrganisationWhereQuery({
organisationId: undefined,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
},
});
if (!organisationEmail) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
await prisma.organisationEmail.update({
where: {
id: emailId,
},
data: {
emailName,
// replyTo,
},
});
});

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
import { ZCreateOrganisationEmailRequestSchema } from './create-organisation-email.types';
export const ZUpdateOrganisationEmailRequestSchema = z
.object({
emailId: z.string(),
})
.extend(
ZCreateOrganisationEmailRequestSchema.pick({
emailName: true,
// replyTo: true
}).shape,
);
export const ZUpdateOrganisationEmailResponseSchema = z.void();
export type TUpdateOrganisationEmailRequest = z.infer<typeof ZUpdateOrganisationEmailRequestSchema>;

View File

@ -0,0 +1,59 @@
import { verifyEmailDomain } from '@documenso/ee/server-only/lib/verify-email-domain';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZVerifyOrganisationEmailDomainRequestSchema,
ZVerifyOrganisationEmailDomainResponseSchema,
} from './verify-organisation-email-domain.types';
export const verifyOrganisationEmailDomainRoute = authenticatedProcedure
.input(ZVerifyOrganisationEmailDomainRequestSchema)
.output(ZVerifyOrganisationEmailDomainResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, emailDomainId } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
emailDomainId,
},
});
if (!IS_BILLING_ENABLED()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Billing is not enabled',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
emailDomains: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
// Filter down emails to verify a specific email, otherwise verify all emails regardless of status.
const emailsToVerify = organisation.emailDomains.filter((email) => {
if (emailDomainId && email.id !== emailDomainId) {
return false;
}
return true;
});
await Promise.all(emailsToVerify.map(async (email) => verifyEmailDomain(email.id)));
});

View File

@ -0,0 +1,8 @@
import { z } from 'zod';
export const ZVerifyOrganisationEmailDomainRequestSchema = z.object({
organisationId: z.string(),
emailDomainId: z.string().optional().describe('Leave blank to revalidate all emails'),
});
export const ZVerifyOrganisationEmailDomainResponseSchema = z.void();

View File

@ -26,16 +26,25 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
} = data;
if (Object.values(data).length === 0) {
@ -61,6 +70,22 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
});
}
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
const derivedTypedSignatureEnabled =
typedSignatureEnabled ?? organisation.organisationGlobalSettings.typedSignatureEnabled;
const derivedUploadSignatureEnabled =
@ -88,6 +113,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
@ -99,6 +126,12 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
},
},
},

View File

@ -1,14 +1,22 @@
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '../document-router/schema';
export const ZUpdateOrganisationSettingsRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
// Document related settings.
documentVisibility: z.nativeEnum(DocumentVisibility).optional(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
documentTimezone: ZDocumentMetaTimezoneSchema.nullish(), // Null means local timezone.
documentDateFormat: ZDocumentMetaDateFormatSchema.optional(),
includeSenderDetails: z.boolean().optional(),
includeSigningCertificate: z.boolean().optional(),
typedSignatureEnabled: z.boolean().optional(),
@ -20,6 +28,12 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(),
// Email related settings.
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
// emailReplyToName: z.string().optional(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(),
}),
});

View File

@ -1,9 +1,9 @@
import { adminRouter } from './admin-router/router';
import { apiTokenRouter } from './api-token-router/router';
import { authRouter } from './auth-router/router';
import { billingRouter } from './billing/router';
import { documentRouter } from './document-router/router';
import { embeddingPresignRouter } from './embedding-router/_router';
import { enterpriseRouter } from './enterprise-router/router';
import { fieldRouter } from './field-router/router';
import { folderRouter } from './folder-router/router';
import { organisationRouter } from './organisation-router/router';
@ -16,8 +16,8 @@ import { router } from './trpc';
import { webhookRouter } from './webhook-router/router';
export const appRouter = router({
enterprise: enterpriseRouter,
auth: authRouter,
billing: billingRouter,
profile: profileRouter,
document: documentRouter,
field: fieldRouter,

View File

@ -1,3 +1,5 @@
import { Prisma } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
@ -26,6 +28,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
@ -37,6 +41,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
} = data;
if (Object.values(data).length === 0) {
@ -70,6 +80,22 @@ export const updateTeamSettingsRoute = authenticatedProcedure
});
}
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
await prisma.team.update({
where: {
id: teamId,
@ -80,6 +106,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
@ -91,6 +119,13 @@ export const updateTeamSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings:
emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
},
},
},

View File

@ -1,8 +1,14 @@
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaTimezoneSchema,
} from '../document-router/schema';
/**
* Null = Inherit from organisation.
* Undefined = Do nothing
@ -13,6 +19,8 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
// Document related settings.
documentVisibility: z.nativeEnum(DocumentVisibility).nullish(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullish(),
documentTimezone: ZDocumentMetaTimezoneSchema.nullish(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(),
includeSenderDetails: z.boolean().nullish(),
includeSigningCertificate: z.boolean().nullish(),
typedSignatureEnabled: z.boolean().nullish(),
@ -24,6 +32,12 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
brandingLogo: z.string().nullish(),
brandingUrl: z.string().nullish(),
brandingCompanyDetails: z.string().nullish(),
// Email related settings.
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
// emailReplyToName: z.string().nullish(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullish(),
}),
});

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