feat: send custom email to signers of direct template documents (#1215)

Introduces customization options for the document completion email
template to allow for custom email bodies and subjects for documents
created from direct templates.


## Testing Performed
- Verified correct rendering of custom email subject and body for direct
template documents
- Verified the all other completed email types are sent correctly
This commit is contained in:
Ephraim Duncan
2024-07-05 03:03:22 +00:00
committed by GitHub
parent 06b1d4835e
commit 2eee2b4d2a
8 changed files with 51 additions and 9 deletions

View File

@@ -42,7 +42,7 @@ export const DirectTemplatePageView = ({
const { toast } = useToast(); const { toast } = useToast();
const { email, setEmail } = useRequiredSigningContext(); const { email, fullName, setEmail } = useRequiredSigningContext();
const { recipient, setRecipient } = useRequiredDocumentAuthContext(); const { recipient, setRecipient } = useRequiredDocumentAuthContext();
const [step, setStep] = useState<DirectTemplateStep>('configure'); const [step, setStep] = useState<DirectTemplateStep>('configure');
@@ -84,6 +84,7 @@ export const DirectTemplatePageView = ({
try { try {
const token = await createDocumentFromDirectTemplate({ const token = await createDocumentFromDirectTemplate({
directTemplateToken, directTemplateToken,
directRecipientName: fullName,
directRecipientEmail: recipient.email, directRecipientEmail: recipient.email,
templateUpdatedAt: template.updatedAt, templateUpdatedAt: template.updatedAt,
signedFieldValues: fields.map((field) => { signedFieldValues: fields.map((field) => {

View File

@@ -5,12 +5,14 @@ export interface TemplateDocumentCompletedProps {
downloadLink: string; downloadLink: string;
documentName: string; documentName: string;
assetBaseUrl: string; assetBaseUrl: string;
customBody?: string;
} }
export const TemplateDocumentCompleted = ({ export const TemplateDocumentCompleted = ({
downloadLink, downloadLink,
documentName, documentName,
assetBaseUrl, assetBaseUrl,
customBody,
}: TemplateDocumentCompletedProps) => { }: TemplateDocumentCompletedProps) => {
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();
@@ -34,7 +36,7 @@ export const TemplateDocumentCompleted = ({
</Section> </Section>
<Text className="text-primary mb-0 text-center text-lg font-semibold"> <Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} was signed by all signers {customBody ?? `${documentName}” was signed by all signers`}
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">

View File

@@ -5,12 +5,15 @@ import type { TemplateDocumentCompletedProps } from '../template-components/temp
import { TemplateDocumentCompleted } from '../template-components/template-document-completed'; import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
import { TemplateFooter } from '../template-components/template-footer'; import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>; export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps> & {
customBody?: string;
};
export const DocumentCompletedEmailTemplate = ({ export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com', downloadLink = 'https://documenso.com',
documentName = 'Open Source Pledge.pdf', documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody,
}: DocumentCompletedEmailTemplateProps) => { }: DocumentCompletedEmailTemplateProps) => {
const previewText = `Completed Document`; const previewText = `Completed Document`;
@@ -45,6 +48,7 @@ export const DocumentCompletedEmailTemplate = ({
downloadLink={downloadLink} downloadLink={downloadLink}
documentName={documentName} documentName={documentName}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
customBody={customBody}
/> />
</Section> </Section>
</Container> </Container>

View File

@@ -76,8 +76,6 @@ export const moveDocumentToTeam = async ({
}), }),
}); });
console.log(log);
return updatedDocument; return updatedDocument;
}); });
}; };

View File

@@ -4,12 +4,14 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed'; import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentSource } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
export interface SendDocumentOptions { export interface SendDocumentOptions {
documentId: number; documentId: number;
@@ -23,6 +25,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}, },
include: { include: {
documentData: true, documentData: true,
documentMeta: true,
Recipient: true, Recipient: true,
User: true, User: true,
team: { team: {
@@ -38,6 +41,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document not found'); throw new Error('Document not found');
} }
const isDirectTemplate = document?.source === DocumentSource.TEMPLATE_DIRECT_LINK;
if (document.Recipient.length === 0) { if (document.Recipient.length === 0) {
throw new Error('Document has no recipients'); throw new Error('Document has no recipients');
} }
@@ -106,12 +111,22 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
'document.name': document.title,
};
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`; const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
const template = createElement(DocumentCompletedEmailTemplate, { const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title, documentName: document.title,
assetBaseUrl, assetBaseUrl,
downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink, downloadLink: recipient.email === owner.email ? documentOwnerDownloadLink : downloadLink,
customBody:
isDirectTemplate && document.documentMeta?.message
? renderCustomEmailTemplate(document.documentMeta.message, customEmailTemplate)
: undefined,
}); });
await mailer.sendMail({ await mailer.sendMail({
@@ -125,7 +140,10 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
}, },
subject: 'Signing Complete!', subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
: 'Signing Complete!',
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [

View File

@@ -39,6 +39,7 @@ import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth'; import { validateFieldAuth } from '../document/validate-field-auth';
export type CreateDocumentFromDirectTemplateOptions = { export type CreateDocumentFromDirectTemplateOptions = {
directRecipientName?: string;
directRecipientEmail: string; directRecipientEmail: string;
directTemplateToken: string; directTemplateToken: string;
signedFieldValues: TSignFieldWithTokenMutationSchema[]; signedFieldValues: TSignFieldWithTokenMutationSchema[];
@@ -57,6 +58,7 @@ type CreatedDirectRecipientField = {
}; };
export const createDocumentFromDirectTemplate = async ({ export const createDocumentFromDirectTemplate = async ({
directRecipientName: initialDirectRecipientName,
directRecipientEmail, directRecipientEmail,
directTemplateToken, directTemplateToken,
signedFieldValues, signedFieldValues,
@@ -110,7 +112,7 @@ export const createDocumentFromDirectTemplate = async ({
documentAuth: template.authOptions, documentAuth: template.authOptions,
}); });
const directRecipientName = user?.name; const directRecipientName = user?.name || initialDirectRecipientName;
// Ensure typesafety when we add more options. // Ensure typesafety when we add more options.
const isAccessAuthValid = match(derivedRecipientAccessAuth) const isAccessAuthValid = match(derivedRecipientAccessAuth)
@@ -132,6 +134,8 @@ export const createDocumentFromDirectTemplate = async ({
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE; const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT; const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
// Associate, validate and map to a query every direct template recipient field with the provided fields. // Associate, validate and map to a query every direct template recipient field with the provided fields.
const createDirectRecipientFieldArgs = await Promise.all( const createDirectRecipientFieldArgs = await Promise.all(
@@ -250,6 +254,14 @@ export const createDocumentFromDirectTemplate = async ({
}), }),
}, },
}, },
documentMeta: {
create: {
timezone: metaTimezone,
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
},
},
}, },
include: { include: {
Recipient: true, Recipient: true,

View File

@@ -59,12 +59,18 @@ export const templateRouter = router({
.input(ZCreateDocumentFromDirectTemplateMutationSchema) .input(ZCreateDocumentFromDirectTemplateMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { directRecipientEmail, directTemplateToken, signedFieldValues, templateUpdatedAt } = const {
input; directRecipientName,
directRecipientEmail,
directTemplateToken,
signedFieldValues,
templateUpdatedAt,
} = input;
const requestMetadata = extractNextApiRequestMetadata(ctx.req); const requestMetadata = extractNextApiRequestMetadata(ctx.req);
return await createDocumentFromDirectTemplate({ return await createDocumentFromDirectTemplate({
directRecipientName,
directRecipientEmail, directRecipientEmail,
directTemplateToken, directTemplateToken,
signedFieldValues, signedFieldValues,

View File

@@ -17,6 +17,7 @@ export const ZCreateTemplateMutationSchema = z.object({
}); });
export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({ export const ZCreateDocumentFromDirectTemplateMutationSchema = z.object({
directRecipientName: z.string().optional(),
directRecipientEmail: z.string().email(), directRecipientEmail: z.string().email(),
directTemplateToken: z.string().min(1), directTemplateToken: z.string().min(1),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema), signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),