Compare commits

...

1 Commits

Author SHA1 Message Date
Lucas Smith 844af17ec2 feat: move email sending into background jobs for retry support
Each direct mailer.sendMail() call is replaced by a dedicated background
job so that email delivery failures can be retried independently.

New jobs: send-pending-email, send-completed-email, send-forgot-password-email,
send-document-super-delete-email, send-recipient-removed-email,
send-document-deleted-emails, send-2fa-token-email, send-resend-document-email,
send-direct-template-created-email.

Existing handlers (send-document-cancelled-emails, send-organisation-member-*,
send-recipient-signed-email) have io.runTask wrappers removed since they
interfere with the job scheduler. Job triggers are dispatched after
transactions commit to avoid race conditions with uncommitted data.
2026-02-20 23:25:37 +11:00
28 changed files with 1080 additions and 441 deletions
+18
View File
@@ -1,12 +1,21 @@
import { JobClient } from './client/client';
import { SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION } from './definitions/emails/send-2fa-token-email';
import { SEND_COMPLETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-completed-email';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-direct-template-created-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-super-delete-email';
import { SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION } from './definitions/emails/send-forgot-password-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-pending-email';
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-resend-document-email';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
@@ -39,6 +48,15 @@ export const jobsClient = new JobClient([
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SEND_PENDING_EMAIL_JOB_DEFINITION,
SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION,
SEND_COMPLETED_EMAIL_JOB_DEFINITION,
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION,
SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION,
SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
@@ -0,0 +1,133 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '../../../server-only/2fa/email/constants';
import { generateTwoFactorTokenFromEmail } from '../../../server-only/2fa/email/generate-2fa-token-from-email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSend2FATokenEmailJobDefinition } from './send-2fa-token-email';
export const run = async ({
payload,
io,
}: {
payload: TSend2FATokenEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, recipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
},
},
},
include: {
recipients: {
where: {
id: recipientId,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const [recipient] = envelope.recipients;
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
if (!isRecipientEmailValidForSending(recipient)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient is missing email address',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
envelopeId,
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const subject = i18n._(msg`Your two-factor authentication code`);
const template = createElement(AccessAuth2FAEmailTemplate, {
documentTitle: envelope.title,
userName: recipient.name,
userEmail: recipient.email,
code: twoFactorTokenToken,
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID = 'send.2fa.token.email';
const SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSend2FATokenEmailJobDefinition = z.infer<
typeof SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION = {
id: SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID,
name: 'Send 2FA Token Email',
version: '1.0.0',
trigger: {
name: SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID,
schema: SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-2fa-token-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID,
TSend2FATokenEmailJobDefinition
>;
@@ -0,0 +1,20 @@
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendCompletedEmailJobDefinition } from './send-completed-email';
export const run = async ({
payload,
}: {
payload: TSendCompletedEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, requestMetadata } = payload;
await sendCompletedEmail({
id: {
type: 'envelopeId',
id: envelopeId,
},
requestMetadata,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID = 'send.document.completed.email';
const SEND_COMPLETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendCompletedEmailJobDefinition = z.infer<
typeof SEND_COMPLETED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_COMPLETED_EMAIL_JOB_DEFINITION = {
id: SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Completed Email',
version: '1.0.0',
trigger: {
name: SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_COMPLETED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-completed-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID,
TSendCompletedEmailJobDefinition
>;
@@ -0,0 +1,105 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentSource } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDirectTemplateCreatedEmailJobDefinition } from './send-direct-template-created-email';
export const run = async ({
payload,
}: {
payload: TSendDirectTemplateCreatedEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, teamId, directRecipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
source: DocumentSource.TEMPLATE_DIRECT_LINK,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
documentMeta: true,
team: {
select: {
url: true,
},
},
},
});
if (!envelope) {
throw new Error('Document not found');
}
const directRecipient = await prisma.recipient.findFirst({
where: {
id: directRecipientId,
envelopeId,
},
});
if (!directRecipient) {
throw new Error('Direct recipient not found on envelope');
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const templateOwner = envelope.user;
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${
envelope.id
}`;
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipient.email,
recipientRole: directRecipient.role,
documentLink,
documentName: envelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID = 'send.direct.template.created.email';
const SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
teamId: z.number(),
directRecipientId: z.number(),
});
export type TSendDirectTemplateCreatedEmailJobDefinition = z.infer<
typeof SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION = {
id: SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Direct Template Created Email',
version: '1.0.0',
trigger: {
name: SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-direct-template-created-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID,
TSendDirectTemplateCreatedEmailJobDefinition
>;
@@ -82,38 +82,36 @@ export const run = async ({
isRecipientEmailValidForSending(recipient),
);
await io.runTask('send-cancellation-emails', async () => {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" Cancelled`),
html,
text,
});
}),
);
});
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" Cancelled`),
html,
text,
});
}),
);
};
@@ -0,0 +1,68 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
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 { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentSuperDeleteEmailJobDefinition } from './send-document-super-delete-email';
export const run = async ({
payload,
io,
}: {
payload: TSendDocumentSuperDeleteEmailJobDefinition;
io: JobRunIO;
}) => {
const { userId, documentTitle, reason, teamId } = payload;
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { email: true, name: true },
});
const { branding, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentSuperDeleteEmailTemplate, {
documentName: documentTitle,
reason,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: senderEmail,
subject: i18n._(msg`Document Deleted!`),
html,
text,
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID = 'send.document.super.delete.email';
const SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
documentTitle: z.string(),
reason: z.string(),
teamId: z.number(),
});
export type TSendDocumentSuperDeleteEmailJobDefinition = z.infer<
typeof SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Super Delete Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-super-delete-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID,
TSendDocumentSuperDeleteEmailJobDefinition
>;
@@ -0,0 +1,72 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { env } from '../../../utils/env';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendForgotPasswordEmailJobDefinition } from './send-forgot-password-email';
export const run = async ({
payload,
io,
}: {
payload: TSendForgotPasswordEmailJobDefinition;
io: JobRunIO;
}) => {
const { userId } = payload;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
passwordResetTokens: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (user.passwordResetTokens.length === 0) {
throw new Error('No password reset token found for user');
}
const token = user.passwordResetTokens[0].token;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
subject: i18n._(msg`Forgot Password?`),
html,
text,
});
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID = 'send.forgot.password.email';
const SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
});
export type TSendForgotPasswordEmailJobDefinition = z.infer<
typeof SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION = {
id: SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID,
name: 'Send Forgot Password Email',
version: '1.0.0',
trigger: {
name: SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID,
schema: SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-forgot-password-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID,
TSendForgotPasswordEmailJobDefinition
>;
@@ -80,41 +80,36 @@ export const run = async ({
continue;
}
await io.runTask(
`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});
},
);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});
}
};
@@ -75,40 +75,35 @@ export const run = async ({
continue;
}
await io.runTask(
`send-organisation-member-left-email--${oldMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
},
);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
}
};
@@ -0,0 +1,20 @@
import { sendPendingEmail } from '../../../server-only/document/send-pending-email';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendPendingEmailJobDefinition } from './send-pending-email';
export const run = async ({
payload,
}: {
payload: TSendPendingEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, recipientId } = payload;
await sendPendingEmail({
id: {
type: 'envelopeId',
id: envelopeId,
},
recipientId,
});
};
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
const SEND_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendPendingEmailJobDefinition = z.infer<
typeof SEND_PENDING_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_PENDING_EMAIL_JOB_DEFINITION = {
id: SEND_PENDING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Pending Email',
version: '1.0.0',
trigger: {
name: SEND_PENDING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-pending-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_PENDING_EMAIL_JOB_DEFINITION_ID,
TSendPendingEmailJobDefinition
>;
@@ -105,25 +105,23 @@ export const run = async ({
assetBaseUrl,
});
await io.runTask('send-recipient-signed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${envelope.title}"`),
html,
text,
});
await mailer.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${envelope.title}"`),
html,
text,
});
};
@@ -0,0 +1,215 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import {
DocumentStatus,
EnvelopeType,
OrganisationType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import {
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';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isDocumentCompleted } from '../../../utils/document';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendResendDocumentEmailJobDefinition } from './send-resend-document-email';
export const run = async ({
payload,
}: {
payload: TSendResendDocumentEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, userId, recipientIds, requestMetadata } = payload;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
},
include: {
recipients: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!envelope) {
throw new Error('Document not found');
}
if (envelope.status === DocumentStatus.DRAFT) {
throw new Error('Can not send draft document');
}
if (isDocumentCompleted(envelope.status)) {
throw new Error('Can not send completed document');
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const recipientsToRemind = envelope.recipients.filter(
(recipient) =>
recipientIds.includes(recipient.id) &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC,
);
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return;
}
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = envelope.documentMeta.message || '';
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
envelope.documentMeta.message ||
i18n._(
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
);
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': envelope.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? envelope.team?.teamEmail?.email || user.email
: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
organisationType,
teamName: envelope.team?.name,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
}),
);
};
@@ -0,0 +1,35 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID = 'send.resend.document.email';
const SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
userId: z.number(),
teamId: z.number(),
recipientIds: z.array(z.number()),
requestMetadata: z.any().optional(),
});
export type TSendResendDocumentEmailJobDefinition = z.infer<
typeof SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION = {
id: SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Resend Document Email',
version: '1.0.0',
trigger: {
name: SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-resend-document-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID,
TSendResendDocumentEmailJobDefinition
>;
@@ -21,7 +21,6 @@ import { signPdf } from '@documenso/signing';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
@@ -323,20 +322,21 @@ export const run = async ({
};
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({
id: { type: 'envelopeId', id: envelopeId },
if (shouldSendCompletedEmail) {
await io.triggerJob('trigger-send-completed-email', {
name: 'send.document.completed.email',
payload: {
envelopeId,
requestMetadata,
});
}
});
},
});
}
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
@@ -1,30 +1,21 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, SendStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export type AdminSuperDeleteDocumentOptions = {
envelopeId: string;
reason: string;
requestMetadata?: RequestMetadata;
};
export const adminSuperDeleteDocument = async ({
envelopeId,
reason,
requestMetadata,
}: AdminSuperDeleteDocumentOptions) => {
const envelope = await prisma.envelope.findUnique({
@@ -32,7 +23,6 @@ export const adminSuperDeleteDocument = async ({
id: envelopeId,
},
include: {
recipients: true,
documentMeta: true,
user: {
select: {
@@ -50,75 +40,14 @@ export const adminSuperDeleteDocument = async ({
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { status, user } = envelope;
const { user } = envelope;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentDeleted;
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
// if the document is pending, send cancellation emails to all recipients
if (
status === DocumentStatus.PENDING &&
recipientsToNotify.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const lang = envelope.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
}
// always hard delete if deleted from admin
return await prisma.$transaction(async (tx) => {
// Always hard delete if deleted from admin.
const deletedEnvelope = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId,
@@ -133,4 +62,21 @@ export const adminSuperDeleteDocument = async ({
return await tx.envelope.delete({ where: { id: envelopeId } });
});
// Notify the document owner after the hard delete transaction commits.
// We only send the owner notification; recipient cancellation emails are
// omitted because the recipients are hard-deleted with the envelope.
if (isDocumentDeletedEmailEnabled) {
await jobs.triggerJob({
name: 'send.document.super.delete.email',
payload: {
userId: user.id,
documentTitle: envelope.title,
reason,
teamId: envelope.teamId,
},
});
}
return deletedEnvelope;
};
@@ -33,7 +33,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -317,7 +316,13 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
@@ -1,35 +1,12 @@
import { createElement } from 'react';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { msg } from '@lingui/core/macro';
import {
DocumentStatus,
EnvelopeType,
OrganisationType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import {
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';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
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 { jobs } from '../../jobs/client';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type ResendDocumentOptions = {
@@ -47,17 +24,6 @@ export const resendDocument = async ({
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -70,12 +36,6 @@ export const resendDocument = async ({
include: {
recipients: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
@@ -120,138 +80,20 @@ export const resendDocument = async ({
});
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
// Dispatch the email sending to a background job so that email delivery
// failures don't block the resend operation and can be retried independently.
if (recipientsToRemind.length > 0) {
await jobs.triggerJob({
name: 'send.resend.document.email',
payload: {
envelopeId: envelope.id,
userId,
teamId: envelope.teamId,
recipientIds: recipientsToRemind.map((r) => r.id),
requestMetadata,
},
meta: envelope.documentMeta,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return;
}
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = envelope.documentMeta.message || '';
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
envelope.documentMeta.message ||
i18n._(
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
);
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': envelope.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? envelope.team?.teamEmail?.email || user.email
: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
organisationType,
teamName: envelope.team?.name,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
}
return envelope;
};
@@ -22,6 +22,8 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
const TWENTY_MB_IN_BYTES = 20 * 1024 * 1024;
export interface SendDocumentOptions {
id: EnvelopeIdOptions;
requestMetadata?: RequestMetadata;
@@ -81,7 +83,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const { user: owner } = envelope;
const completedDocumentEmailAttachments = await Promise.all(
let completedDocumentEmailAttachments = await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => {
const file = await getFileServerSide(envelopeItem.documentData);
@@ -97,6 +99,16 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
}),
);
const allAttachmentsSize = completedDocumentEmailAttachments.reduce(
(acc, attachment) => acc + attachment.content.length,
0,
);
// If the total size of attachments exceeds 20MB, do not include attachments and instead provide a download link in the email body.
if (allAttachmentsSize > TWENTY_MB_IN_BYTES) {
completedDocumentEmailAttachments = [];
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
@@ -1,6 +1,3 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
@@ -18,14 +15,11 @@ import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { jobs } from '@documenso/lib/jobs/client';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
@@ -48,8 +42,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { getEmailContext } from '../email/get-email-context';
@@ -156,7 +148,7 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
const { settings } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -164,7 +156,7 @@ export const createDocumentFromDirectTemplate = async ({
},
});
const { recipients, directLink, user: templateOwner } = directTemplateEnvelope;
const { recipients, directLink } = directTemplateEnvelope;
const directTemplateRecipient = recipients.find(
(recipient) => recipient.id === directLink.directTemplateRecipientId,
@@ -755,37 +747,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
recipientRole: directTemplateRecipient.role,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(createdEnvelope.team?.url)}/${
createdEnvelope.id
}`,
documentName: createdEnvelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
createdEnvelope,
token: createdDirectRecipient.token,
@@ -793,6 +754,17 @@ export const createDocumentFromDirectTemplate = async ({
};
});
// Dispatch the email notification to the template owner as a background job.
// This is outside the transaction so email failures don't roll back document creation.
await jobs.triggerJob({
name: 'send.direct.template.created.email',
payload: {
envelopeId: createdEnvelope.id,
teamId: createdEnvelope.teamId,
directRecipientId: recipientId,
},
});
try {
// This handles sending emails and sealing the document if required.
await sendDocument({
@@ -3,7 +3,7 @@ import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_DAY } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password';
import { jobs } from '../../jobs/client';
export const forgotPassword = async ({ email }: { email: string }) => {
const user = await prisma.user.findFirst({
@@ -46,7 +46,10 @@ export const forgotPassword = async ({ email }: { email: string }) => {
},
});
await sendForgotPassword({
userId: user.id,
}).catch((err) => console.error(err));
await jobs.triggerJob({
name: 'send.forgot.password.email',
payload: {
userId: user.id,
},
});
};
@@ -1,5 +1,4 @@
import { adminSuperDeleteDocument } from '@documenso/lib/server-only/admin/admin-super-delete-document';
import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email';
import { adminProcedure } from '../trpc';
import {
@@ -19,10 +18,9 @@ export const deleteDocumentRoute = adminProcedure
},
});
await sendDeleteEmail({ envelopeId: id, reason });
await adminSuperDeleteDocument({
envelopeId: id,
reason,
requestMetadata: ctx.metadata.requestMetadata,
});
});
@@ -2,8 +2,8 @@ import { EnvelopeType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
import { jobs } from '@documenso/lib/jobs/client';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { request2FAEmailRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
@@ -30,8 +30,6 @@ export const accessAuthRequest2FAEmailRoute = procedure
assertRateLimit(rateLimitResult);
const user = ctx.user;
// Get document and recipient by token
const envelope = await prisma.envelope.findFirst({
where: {
@@ -72,18 +70,14 @@ export const accessAuthRequest2FAEmailRoute = procedure
});
}
// if (user && recipient.email !== user.email) {
// throw new TRPCError({
// code: 'UNAUTHORIZED',
// message: 'User does not match recipient',
// });
// }
const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES });
await send2FATokenEmail({
token,
envelopeId: envelope.id,
await jobs.triggerJob({
name: 'send.2fa.token.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
return {