chore: fix conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-08-22 16:17:59 +00:00
228 changed files with 8051 additions and 1783 deletions

View File

@ -13,4 +13,4 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = 'support@documenso.com';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';

View File

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

View File

@ -49,15 +49,24 @@ type DocumentSignatureTypeData = {
export const DOCUMENT_SIGNATURE_TYPES = {
[DocumentSignatureType.DRAW]: {
label: msg`Draw`,
label: msg({
message: `Draw`,
context: `Draw signatute type`,
}),
value: DocumentSignatureType.DRAW,
},
[DocumentSignatureType.TYPE]: {
label: msg`Type`,
label: msg({
message: `Type`,
context: `Type signatute type`,
}),
value: DocumentSignatureType.TYPE,
},
[DocumentSignatureType.UPLOAD]: {
label: msg`Upload`,
label: msg({
message: `Upload`,
context: `Upload signatute type`,
}),
value: DocumentSignatureType.UPLOAD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;

View File

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

View File

@ -4,39 +4,114 @@ import { RecipientRole } from '@prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: msg`Approve`,
actioned: msg`Approved`,
progressiveVerb: msg`Approving`,
roleName: msg`Approver`,
roleNamePlural: msg`Approvers`,
actionVerb: msg({
message: `Approve`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Approved`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Approving`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Approver`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Approvers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.CC]: {
actionVerb: msg`CC`,
actioned: msg`CC'd`,
progressiveVerb: msg`CC`,
roleName: msg`Cc`,
roleNamePlural: msg`Ccers`,
actionVerb: msg({
message: `CC`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `CC'd`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `CC`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Cc`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Ccers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.SIGNER]: {
actionVerb: msg`Sign`,
actioned: msg`Signed`,
progressiveVerb: msg`Signing`,
roleName: msg`Signer`,
roleNamePlural: msg`Signers`,
actionVerb: msg({
message: `Sign`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Signed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Signing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Signer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Signers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.VIEWER]: {
actionVerb: msg`View`,
actioned: msg`Viewed`,
progressiveVerb: msg`Viewing`,
roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`,
actionVerb: msg({
message: `View`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Viewed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Viewing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Viewer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Viewers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.ASSISTANT]: {
actionVerb: msg`Assist`,
actioned: msg`Assisted`,
progressiveVerb: msg`Assisting`,
roleName: msg`Assistant`,
roleNamePlural: msg`Assistants`,
actionVerb: msg({
message: `Assist`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Assisted`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Assisting`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Assistant`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Assistants`,
context: `Recipient role plural name`,
}),
},
} satisfies Record<keyof typeof RecipientRole, unknown>;

View File

@ -3,6 +3,10 @@ import { msg } from '@lingui/core/macro';
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const isTemplateRecipientEmailPlaceholder = (email: string) => {
return TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email);
};
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: msg`Enable Direct Link Signing`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@ -145,7 +146,24 @@ export const run = async ({
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
@ -174,6 +192,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/client-sesv2": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",
@ -32,6 +33,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",

View File

@ -0,0 +1,7 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
});

View File

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

View File

@ -1,6 +1,7 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
@ -24,6 +25,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';
@ -44,6 +46,7 @@ export type CreateDocumentOptions = {
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
@ -58,7 +61,7 @@ export const createDocumentV2 = async ({
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
@ -77,6 +80,22 @@ export const createDocumentV2 = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@ -134,6 +153,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: {
@ -145,18 +182,11 @@ export const createDocumentV2 = async ({
teamId,
authOptions,
visibility,
folderId,
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),
},
},
});
@ -201,7 +231,7 @@ export const createDocumentV2 = async ({
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({

View File

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

View File

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

View File

@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: {
in: teamVisibilityFilters,
},
teamId,
teamId: team.id,
},
// Or, if they are a recipient of the document.
{

View File

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

View File

@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@ -125,6 +126,18 @@ export const sealDocument = async ({
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@ -147,6 +160,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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;

View File

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

View File

@ -0,0 +1,83 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogsPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();
void browser.close();
return result;
};

View File

@ -46,7 +46,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
await page.context().addCookies([
{
name: 'language',
name: 'lang',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
@ -57,8 +57,22 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();

View File

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

View File

@ -2,8 +2,10 @@ import type { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppErrorCode } from '../../errors/app-error';
import { AppError } from '../../errors/app-error';
@ -30,6 +32,33 @@ export const createOrganisation = async ({
customerId,
claim,
}: CreateOrganisationOptions) => {
let customerIdToUse = customerId;
if (!customerId && IS_BILLING_ENABLED()) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
customerIdToUse = await createCustomer({
name: user.name || user.email,
email: user.email,
})
.then((customer) => customer.id)
.catch((err) => {
console.error(err);
return undefined;
});
}
return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({
data: {
@ -64,7 +93,7 @@ export const createOrganisation = async ({
id: generateDatabaseId('org_group'),
})),
},
customerId,
customerId: customerIdToUse,
},
include: {
groups: true,

View File

@ -3,6 +3,7 @@ import type { PDFDocument } from 'pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
@ -27,7 +28,7 @@ export async function addRejectionStampToPdf(
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
const { width, height } = getPageSize(page);
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';

View File

@ -0,0 +1,18 @@
import type { PDFPage } from 'pdf-lib';
/**
* Gets the effective page size for PDF operations.
*
* Uses CropBox by default to handle rare cases where MediaBox is larger than CropBox.
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
};

View File

@ -33,6 +33,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@ -77,7 +78,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.

View File

@ -26,6 +26,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@ -63,7 +64,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.

View File

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

View File

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

View File

@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@ -203,9 +198,6 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import type { DocumentDistributionMethod } from '@prisma/client';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
type Field,
FolderType,
type Recipient,
RecipientRole,
SendStatus,
@ -40,6 +40,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,
@ -69,6 +70,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
@ -274,6 +276,7 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId,
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
@ -298,6 +301,22 @@ export const createDocumentFromTemplate = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@ -368,6 +387,7 @@ export const createDocumentFromTemplate = async ({
externalId: externalId || template.externalId,
templateId: template.id,
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,
@ -378,7 +398,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 +407,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 +418,8 @@ export const createDocumentFromTemplate = async ({
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ??
template.templateMeta?.allowDictateNextSigner ??
false,
},
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
}),
},
recipients: {
createMany: {

View File

@ -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),
},
},
});

View File

@ -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,

View File

@ -0,0 +1,72 @@
import { plainClient } from '@documenso/lib/plain/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { getTeamById } from '../team/get-team';
type SubmitSupportTicketOptions = {
subject: string;
message: string;
userId: number;
organisationId: string;
teamId?: number | null;
};
export const submitSupportTicket = async ({
subject,
message,
userId,
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const team = teamId
? await getTeamById({
userId,
teamId,
})
: null;
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -205,12 +205,18 @@ export const diffDocumentMetaChanges = (
const oldTimezone = oldData?.timezone ?? '';
const oldPassword = oldData?.password ?? null;
const oldRedirectUrl = oldData?.redirectUrl ?? '';
const oldEmailId = oldData?.emailId || null;
const oldEmailReplyTo = oldData?.emailReplyTo || null;
const oldEmailSettings = oldData?.emailSettings || null;
const newDateFormat = newData?.dateFormat ?? '';
const newMessage = newData?.message ?? '';
const newSubject = newData?.subject ?? '';
const newTimezone = newData?.timezone ?? '';
const newRedirectUrl = newData?.redirectUrl ?? '';
const newEmailId = newData?.emailId || null;
const newEmailReplyTo = newData?.emailReplyTo || null;
const newEmailSettings = newData?.emailSettings || null;
if (oldDateFormat !== newDateFormat) {
diffs.push({
@ -258,6 +264,30 @@ export const diffDocumentMetaChanges = (
});
}
if (oldEmailId !== newEmailId) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_ID,
from: oldEmailId,
to: newEmailId,
});
}
if (oldEmailReplyTo !== newEmailReplyTo) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO,
from: oldEmailReplyTo,
to: newEmailReplyTo,
});
}
if (!isDeepEqual(oldEmailSettings, newEmailSettings)) {
diffs.push({
type: DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS,
from: JSON.stringify(oldEmailSettings),
to: JSON.stringify(newEmailSettings),
});
}
return diffs;
};
@ -275,87 +305,150 @@ export const formatDocumentAuditLogAction = (
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: msg`A field was added`,
anonymous: msg({
message: `A field was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: msg`A field was removed`,
anonymous: msg({
message: `A field was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: msg`A field was updated`,
anonymous: msg({
message: `A field was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: msg`A recipient was added`,
anonymous: msg({
message: `A recipient was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg`A recipient was removed`,
anonymous: msg({
message: `A recipient was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: msg`A recipient was updated`,
anonymous: msg({
message: `A recipient was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: msg`Document created`,
anonymous: msg({
message: `Document created`,
context: `Audit log format`,
}),
identified: msg`${prefix} created the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: msg`Document deleted`,
anonymous: msg({
message: `Document deleted`,
context: `Audit log format`,
}),
identified: msg`${prefix} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg`Field signed`,
anonymous: msg({
message: `Field signed`,
context: `Audit log format`,
}),
identified: msg`${prefix} signed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: msg`Field unsigned`,
anonymous: msg({
message: `Field unsigned`,
context: `Audit log format`,
}),
identified: msg`${prefix} unsigned a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
anonymous: msg`Field prefilled by assistant`,
anonymous: msg({
message: `Field prefilled by assistant`,
context: `Audit log format`,
}),
identified: msg`${prefix} prefilled a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`,
anonymous: msg({
message: `Document visibility updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg`Document access auth updated`,
anonymous: msg({
message: `Document access auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document access auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: msg`Document signing auth updated`,
anonymous: msg({
message: `Document signing auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document signing auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: msg`Document updated`,
anonymous: msg({
message: `Document updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: msg`Document opened`,
anonymous: msg({
message: `Document opened`,
context: `Audit log format`,
}),
identified: msg`${prefix} opened the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
anonymous: msg`Document viewed`,
anonymous: msg({
message: `Document viewed`,
context: `Audit log format`,
}),
identified: msg`${prefix} viewed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: msg`Document title updated`,
anonymous: msg({
message: `Document title updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document title`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: msg`Document external ID updated`,
anonymous: msg({
message: `Document external ID updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document external ID`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: msg`Document sent`,
anonymous: msg({
message: `Document sent`,
context: `Audit log format`,
}),
identified: msg`${prefix} sent the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: msg`Document moved to team`,
anonymous: msg({
message: `Document moved to team`,
context: `Audit log format`,
}),
identified: msg`${prefix} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
@ -390,8 +483,14 @@ export const formatDocumentAuditLogAction = (
: msg`${prefix} sent an email to ${data.recipientEmail}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg`Document completed`,
identified: msg`Document completed`,
anonymous: msg({
message: `Document completed`,
context: `Audit log format`,
}),
identified: msg({
message: `Document completed`,
context: `Audit log format`,
}),
}))
.exhaustive();

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import { type TransportTargetOptions, pino } from 'pino';
import type { BaseApiLog } from '../types/api-logs';
import { extractRequestMetadata } from '../universal/extract-request-metadata';
import { env } from './env';
const transports: TransportTargetOptions[] = [];
@ -33,3 +35,31 @@ export const logger = pino({
}
: undefined,
});
export const logDocumentAccess = ({
request,
documentId,
userId,
}: {
request: Request;
documentId: number;
userId: number;
}) => {
const metadata = extractRequestMetadata(request);
const data: BaseApiLog = {
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
path: new URL(request.url).pathname,
auth: 'session',
source: 'app',
userId,
};
logger.info({
...data,
input: {
documentId,
},
});
};

View File

@ -7,11 +7,13 @@ import {
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
} from '../constants/organisations';
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
export const isPersonalLayout = (organisations: Pick<Organisation, 'type'>[]) => {
return organisations.length === 1 && organisations[0].type === 'PERSONAL';
@ -113,8 +115,12 @@ 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,
includeAuditLog: false,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
@ -124,5 +130,10 @@ export const generateDefaultOrganisationSettings = (): Omit<
brandingLogo: '',
brandingUrl: '',
brandingCompanyDetails: '',
emailId: null,
emailReplyTo: null,
// emailReplyToName: null,
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
};
};

View File

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