mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 19:21:39 +10:00
feat: add email domains (#1895)
Implemented Email Domains which allows Platform/Enterprise customers to send emails to recipients using their custom emails.
This commit is contained in:
@ -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
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
154
packages/ee/server-only/lib/create-email-domain.ts
Normal file
154
packages/ee/server-only/lib/create-email-domain.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
52
packages/ee/server-only/lib/delete-email-domain.ts
Normal file
52
packages/ee/server-only/lib/delete-email-domain.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
45
packages/ee/server-only/lib/verify-email-domain.ts
Normal file
45
packages/ee/server-only/lib/verify-email-domain.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}`),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentStatus, 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';
|
||||
@ -128,9 +129,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;
|
||||
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`,
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -6,6 +6,7 @@ import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//Tem
|
||||
|
||||
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';
|
||||
@ -69,6 +70,24 @@ 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,
|
||||
@ -86,14 +105,7 @@ export const createTemplate = async ({
|
||||
publicDescription: data.publicDescription,
|
||||
type: data.type,
|
||||
templateMeta: {
|
||||
create: {
|
||||
...meta,
|
||||
language: meta?.language ?? settings.documentLanguage,
|
||||
typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled,
|
||||
emailSettings: meta?.emailSettings || undefined,
|
||||
},
|
||||
create: extractDerivedDocumentMeta(settings, meta),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -41,6 +41,7 @@ export const updateTemplate = async ({
|
||||
templateMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
organisationId: true,
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
@ -86,6 +87,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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
40
packages/lib/types/email-domain.ts
Normal file
40
packages/lib/types/email-domain.ts
Normal 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>;
|
||||
42
packages/lib/types/organisation-email.ts
Normal file
42
packages/lib/types/organisation-email.ts
Normal 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>;
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -11,7 +11,9 @@ export const prefixedId = (prefix: string, length = 16) => {
|
||||
};
|
||||
|
||||
type DatabaseIdPrefix =
|
||||
| 'email_domain'
|
||||
| 'org'
|
||||
| 'org_email'
|
||||
| 'org_claim'
|
||||
| 'org_group'
|
||||
| 'org_setting'
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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'>;
|
||||
};
|
||||
|
||||
17
packages/lib/utils/email-domains.ts
Normal file
17
packages/lib/utils/email-domains.ts
Normal 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];
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -468,7 +468,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 {
|
||||
@ -619,6 +622,9 @@ model Organisation {
|
||||
|
||||
teams Team[]
|
||||
|
||||
emailDomains EmailDomain[]
|
||||
organisationEmails OrganisationEmail[]
|
||||
|
||||
avatarImage AvatarImage? @relation(fields: [avatarImageId], references: [id], onDelete: SetNull)
|
||||
|
||||
ownerUserId Int
|
||||
@ -723,31 +729,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?
|
||||
|
||||
@ -755,6 +776,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?
|
||||
@ -829,11 +857,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';"])
|
||||
@ -952,3 +983,45 @@ model AvatarImage {
|
||||
user User[]
|
||||
organisation Organisation[]
|
||||
}
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
1
packages/prisma/types/types.d.ts
vendored
1
packages/prisma/types/types.d.ts
vendored
@ -18,6 +18,7 @@ declare global {
|
||||
type DocumentFormValues = TDocumentFormValues;
|
||||
type DocumentAuthOptions = TDocumentAuthOptions;
|
||||
type DocumentEmailSettings = TDocumentEmailSettings;
|
||||
type DocumentEmailSettingsNullable = TDocumentEmailSettings | null;
|
||||
|
||||
type RecipientAuthOptions = TRecipientAuthOptions;
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -79,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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
@ -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(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeleteOrganisationEmailDomainRequestSchema = z.object({
|
||||
emailDomainId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteOrganisationEmailDomainResponseSchema = z.void();
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeleteOrganisationEmailRequestSchema = z.object({
|
||||
emailId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteOrganisationEmailResponseSchema = z.void();
|
||||
@ -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>;
|
||||
};
|
||||
@ -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
|
||||
>;
|
||||
@ -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>;
|
||||
};
|
||||
@ -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>;
|
||||
@ -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;
|
||||
};
|
||||
@ -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
|
||||
>;
|
||||
46
packages/trpc/server/enterprise-router/router.ts
Normal file
46
packages/trpc/server/enterprise-router/router.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
@ -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)));
|
||||
});
|
||||
@ -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();
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -64,6 +64,8 @@ export const ZTemplateMetaUpsertSchema = z.object({
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: z.string().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
|
||||
@ -23,7 +23,7 @@ import { FieldContent } from '../../primitives/document-flow/field-content';
|
||||
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: DocumentField[];
|
||||
documentMeta?: DocumentMeta | TemplateMeta;
|
||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||
|
||||
showFieldStatus?: boolean;
|
||||
|
||||
|
||||
@ -15,8 +15,10 @@ type ComboboxProps = {
|
||||
options: string[];
|
||||
value: string | null;
|
||||
onChange: (_value: string | null) => void;
|
||||
triggerPlaceholder?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
const Combobox = ({
|
||||
@ -25,7 +27,9 @@ const Combobox = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
triggerPlaceholder,
|
||||
placeholder,
|
||||
testId,
|
||||
}: ComboboxProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
@ -47,8 +51,9 @@ const Combobox = ({
|
||||
aria-expanded={open}
|
||||
className={cn('my-2 w-full justify-between', className)}
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
>
|
||||
{value ? value : placeholderValue}
|
||||
{value ? value : triggerPlaceholder || placeholderValue}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@ -5,13 +5,31 @@ import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDocument } from '@documenso/lib/types/document';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { CopyTextButton } from '../../components/common/copy-text-button';
|
||||
@ -21,11 +39,10 @@ import {
|
||||
mapFieldsWithRecipients,
|
||||
} from '../../components/document/document-read-only-fields';
|
||||
import { AvatarWithText } from '../avatar';
|
||||
import { FormErrorMessage } from '../form/form-error-message';
|
||||
import { Input } from '../input';
|
||||
import { Label } from '../label';
|
||||
import { useStep } from '../stepper';
|
||||
import { Textarea } from '../textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||
import { toast } from '../use-toast';
|
||||
import { type TAddSubjectFormSchema, ZAddSubjectFormSchema } from './add-subject.types';
|
||||
import {
|
||||
@ -56,15 +73,14 @@ export const AddSubjectFormPartial = ({
|
||||
}: AddSubjectFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TAddSubjectFormSchema>({
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const form = useForm<TAddSubjectFormSchema>({
|
||||
defaultValues: {
|
||||
meta: {
|
||||
emailId: document.documentMeta?.emailId ?? null,
|
||||
emailReplyTo: document.documentMeta?.emailReplyTo || undefined,
|
||||
// emailReplyName: document.documentMeta?.emailReplyName || undefined,
|
||||
subject: document.documentMeta?.subject ?? '',
|
||||
message: document.documentMeta?.message ?? '',
|
||||
distributionMethod:
|
||||
@ -75,6 +91,21 @@ export const AddSubjectFormPartial = ({
|
||||
resolver: zodResolver(ZAddSubjectFormSchema),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
const GoNextLabel = {
|
||||
[DocumentDistributionMethod.EMAIL]: {
|
||||
[DocumentStatus.DRAFT]: msg`Send`,
|
||||
@ -139,54 +170,141 @@ export const AddSubjectFormPartial = ({
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="flex flex-col gap-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="subject">
|
||||
<Trans>
|
||||
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</Label>
|
||||
<Form {...form}>
|
||||
<fieldset
|
||||
className="flex flex-col gap-y-4 rounded-lg border p-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Email Sender</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Input
|
||||
id="subject"
|
||||
className="bg-background mt-2"
|
||||
disabled={isSubmitting}
|
||||
{...register('meta.subject')}
|
||||
/>
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === '-1' ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger loading={isLoadingEmails} className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<FormErrorMessage className="mt-2" error={errors.meta?.subject} />
|
||||
</div>
|
||||
<SelectContent>
|
||||
{emails.map((email) => (
|
||||
<SelectItem key={email.id} value={email.id}>
|
||||
{email.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message">
|
||||
<Trans>
|
||||
Message <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</Label>
|
||||
<SelectItem value={'-1'}>Documenso</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Textarea
|
||||
id="message"
|
||||
className="bg-background mt-2 h-32 resize-none"
|
||||
disabled={isSubmitting}
|
||||
{...register('meta.message')}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormErrorMessage
|
||||
className="mt-2"
|
||||
error={
|
||||
typeof errors.meta?.message !== 'string' ? errors.meta?.message : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<DocumentSendEmailMessageHelper />
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<DocumentEmailCheckboxes
|
||||
className="mt-2"
|
||||
value={emailSettings}
|
||||
onChange={(value) => setValue('meta.emailSettings', value)}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="meta.emailReplyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Name</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Subject</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meta.message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="bg-background mt-2 h-16 resize-none" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DocumentEmailCheckboxes
|
||||
className="mt-2"
|
||||
value={emailSettings}
|
||||
onChange={(value) => setValue('meta.emailSettings', value)}
|
||||
/>
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
|
||||
@ -5,6 +5,9 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
|
||||
|
||||
export const ZAddSubjectFormSchema = z.object({
|
||||
meta: z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.string().email().optional(),
|
||||
// emailReplyName: z.string().optional(),
|
||||
subject: z.string(),
|
||||
message: z.string(),
|
||||
distributionMethod: z
|
||||
|
||||
@ -27,7 +27,7 @@ type FieldIconProps = {
|
||||
fieldMeta?: TFieldMetaSchema | null;
|
||||
signature?: Signature | null;
|
||||
};
|
||||
documentMeta?: DocumentMeta | TemplateMeta;
|
||||
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { Check, ChevronDown, Loader } from 'lucide-react';
|
||||
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
@ -13,20 +16,33 @@ const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
loading?: boolean;
|
||||
}
|
||||
>(({ className, children, loading, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
disabled={loading || props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
<AnimatePresence>
|
||||
{loading ? (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Loader className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-100" />
|
||||
</div>
|
||||
) : (
|
||||
<AnimateGenericFadeInOut className="flex w-full justify-between">
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</AnimateGenericFadeInOut>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user