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.
This commit is contained in:
Lucas Smith
2026-02-20 23:25:37 +11:00
parent 653ab3678a
commit 844af17ec2
28 changed files with 1080 additions and 441 deletions
@@ -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(