feat: i18n for emails (#1442)

## Description

Support setting a document language that will control the language used
for sending emails to recipients. Additional work has been done to
convert all emails to using our i18n implementation so we can later add
controls for sending other kinds of emails in a users target language.

## Related Issue

N/A

## Changes Made

- Added `<Trans>` and `msg` macros to emails
- Introduced a new `renderEmailWithI18N` utility in the lib package
- Updated all emails to use the `<Tailwind>` component at the top level
due to rendering constraints
- Updated the `i18n.server.tsx` file to not use a top level await

## Testing Performed

- Configured document language and verified emails were sent in the
expected language
- Created a document from a template and verified that the templates
language was transferred to the document
This commit is contained in:
Lucas Smith
2024-11-05 11:52:54 +11:00
committed by GitHub
parent 04b1ce1aab
commit 4dd95016b1
163 changed files with 3549 additions and 1461 deletions

View File

@ -3,7 +3,6 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
@ -14,6 +13,7 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export type DeleteDocumentOptions = {
id: number;
@ -191,6 +191,11 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
await mailer.sendMail({
to: {
address: recipient.email,
@ -201,8 +206,8 @@ const handleDocumentOwnerDelete = async ({
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
html,
text,
});
}),
);

View File

@ -1,11 +1,12 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION_ENG,
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -16,7 +17,9 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = {
@ -92,25 +95,28 @@ export const resendDocument = async ({
return;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb =
RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].actionVerb.toLowerCase();
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = customEmail?.message || '';
let emailSubject = `Reminder: Please ${recipientActionVerb} this document`;
let emailMessage = msg`${customEmail?.message || ''}`;
let emailSubject = msg`Reminder: Please ${recipientActionVerb} this document`;
if (selfSigner) {
emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = `Reminder: Please ${recipientActionVerb} your document`;
emailMessage = msg`You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`;
emailSubject = msg`Reminder: Please ${recipientActionVerb} your document`;
}
if (isTeamDocument && document.team) {
emailSubject = `Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`;
emailMessage = `${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`;
emailSubject = msg`Reminder: ${document.team.name} invited you to ${recipientActionVerb} a document`;
emailMessage = msg`${user.name} on behalf of ${document.team.name} has invited you to ${recipientActionVerb} the document "${document.title}".`;
}
const customEmailTemplate = {
@ -128,7 +134,7 @@ export const resendDocument = async ({
inviterEmail: isTeamDocument ? document.team?.teamEmail?.email || user.email : user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
customBody: renderCustomEmailTemplate(i18n._(emailMessage), customEmailTemplate),
role: recipient.role,
selfSigner,
isTeamInvite: isTeamDocument,
@ -137,6 +143,14 @@ export const resendDocument = async ({
await prisma.$transaction(
async (tx) => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, {
lang: document.documentMeta?.language,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
address: email,
@ -147,10 +161,13 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),
customEmailTemplate,
)
: i18n._(emailSubject),
html,
text,
});
await tx.documentAuditLog.create({

View File

@ -1,17 +1,20 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { DocumentSource } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendDocumentOptions {
documentId: number;
@ -61,6 +64,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
}`;
}
const i18n = await getI18nInstance(document.documentMeta?.language);
// If the document owner is not a recipient then send the email to them separately
if (!document.Recipient.find((recipient) => recipient.email === owner.email)) {
const template = createElement(DocumentCompletedEmailTemplate, {
@ -69,6 +74,11 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
]);
await mailer.sendMail({
to: [
{
@ -80,9 +90,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
@ -129,6 +139,11 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
]);
await mailer.sendMail({
to: [
{
@ -143,9 +158,9 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)
: 'Signing Complete!',
html: render(template),
text: render(template, { plainText: true }),
: i18n._(msg`Signing Complete!`),
html,
text,
attachments: [
{
filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendDeleteEmailOptions {
documentId: number;
@ -36,6 +39,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: email,
@ -45,8 +55,8 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Document Deleted!',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Deleted!`),
html,
text,
});
};

View File

@ -1,11 +1,14 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendPendingEmailOptions {
documentId: number;
@ -28,6 +31,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
id: recipientId,
},
},
documentMeta: true,
},
});
@ -50,6 +54,13 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: email,
@ -59,8 +70,8 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
},
subject: 'Waiting for others to complete signing.',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,
});
};

View File

@ -2,17 +2,20 @@
import { createElement } from 'react';
import { msg } from '@lingui/macro';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export type SuperDeleteDocumentOptions = {
id: number;
@ -53,6 +56,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: document.documentMeta?.language }),
renderEmailWithI18N(template, { lang: document.documentMeta?.language, plainText: true }),
]);
const i18n = await getI18nInstance(document.documentMeta?.language);
await mailer.sendMail({
to: {
address: recipient.email,
@ -62,9 +72,9 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);