From 844af17ec25c7b6611dfc61b97259ef45fcfaeb5 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 20 Feb 2026 23:25:37 +1100 Subject: [PATCH] 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. --- packages/lib/jobs/client.ts | 18 ++ .../emails/send-2fa-token-email.handler.ts | 133 +++++++++++ .../emails/send-2fa-token-email.ts | 32 +++ .../emails/send-completed-email.handler.ts | 20 ++ .../emails/send-completed-email.ts | 33 +++ ...d-direct-template-created-email.handler.ts | 105 +++++++++ .../send-direct-template-created-email.ts | 33 +++ .../send-document-cancelled-emails.handler.ts | 62 +++-- ...end-document-super-delete-email.handler.ts | 68 ++++++ .../send-document-super-delete-email.ts | 34 +++ .../send-forgot-password-email.handler.ts | 72 ++++++ .../emails/send-forgot-password-email.ts | 31 +++ ...rganisation-member-joined-email.handler.ts | 61 +++-- ...-organisation-member-left-email.handler.ts | 59 +++-- .../emails/send-pending-email.handler.ts | 20 ++ .../definitions/emails/send-pending-email.ts | 32 +++ .../send-recipient-signed-email.handler.ts | 36 ++- .../send-resend-document-email.handler.ts | 215 ++++++++++++++++++ .../emails/send-resend-document-email.ts | 35 +++ .../internal/seal-document.handler.ts | 24 +- .../admin/admin-super-delete-document.ts | 100 ++------ .../document/complete-document-with-token.ts | 9 +- .../server-only/document/resend-document.ts | 184 ++------------- .../document/send-completed-email.ts | 14 +- .../create-document-from-direct-template.ts | 56 ++--- .../lib/server-only/user/forgot-password.ts | 11 +- .../server/admin-router/delete-document.ts | 4 +- .../access-auth-request-2fa-email.ts | 20 +- 28 files changed, 1080 insertions(+), 441 deletions(-) create mode 100644 packages/lib/jobs/definitions/emails/send-2fa-token-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-2fa-token-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-completed-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-completed-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-direct-template-created-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-direct-template-created-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-document-super-delete-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-document-super-delete-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-forgot-password-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-forgot-password-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-pending-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-pending-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-resend-document-email.handler.ts create mode 100644 packages/lib/jobs/definitions/emails/send-resend-document-email.ts diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 0471e2018..b1822ae09 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -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; diff --git a/packages/lib/jobs/definitions/emails/send-2fa-token-email.handler.ts b/packages/lib/jobs/definitions/emails/send-2fa-token-email.handler.ts new file mode 100644 index 000000000..1e724bfb3 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-2fa-token-email.handler.ts @@ -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, + }, + }), + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-2fa-token-email.ts b/packages/lib/jobs/definitions/emails/send-2fa-token-email.ts new file mode 100644 index 000000000..58b985317 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-2fa-token-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/emails/send-completed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-completed-email.handler.ts new file mode 100644 index 000000000..75b9a7eb2 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-completed-email.handler.ts @@ -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, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-completed-email.ts b/packages/lib/jobs/definitions/emails/send-completed-email.ts new file mode 100644 index 000000000..78e4918cb --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-completed-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/emails/send-direct-template-created-email.handler.ts b/packages/lib/jobs/definitions/emails/send-direct-template-created-email.handler.ts new file mode 100644 index 000000000..67d8926b4 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-direct-template-created-email.handler.ts @@ -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, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-direct-template-created-email.ts b/packages/lib/jobs/definitions/emails/send-direct-template-created-email.ts new file mode 100644 index 000000000..a4e4cd672 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-direct-template-created-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts index 3bd743a0a..6932ee2f1 100644 --- a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.handler.ts @@ -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, + }); + }), + ); }; diff --git a/packages/lib/jobs/definitions/emails/send-document-super-delete-email.handler.ts b/packages/lib/jobs/definitions/emails/send-document-super-delete-email.handler.ts new file mode 100644 index 000000000..d6429dc0c --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-super-delete-email.handler.ts @@ -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, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-super-delete-email.ts b/packages/lib/jobs/definitions/emails/send-document-super-delete-email.ts new file mode 100644 index 000000000..d9a433f12 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-super-delete-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/emails/send-forgot-password-email.handler.ts b/packages/lib/jobs/definitions/emails/send-forgot-password-email.handler.ts new file mode 100644 index 000000000..1c4d39583 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-forgot-password-email.handler.ts @@ -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, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-forgot-password-email.ts b/packages/lib/jobs/definitions/emails/send-forgot-password-email.ts new file mode 100644 index 000000000..7cb509dd6 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-forgot-password-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts index db8ac2980..54d9995ed 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-joined-email.handler.ts @@ -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, + }); } }; diff --git a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts index babfc7396..36bd96082 100644 --- a/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-organisation-member-left-email.handler.ts @@ -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, + }); } }; diff --git a/packages/lib/jobs/definitions/emails/send-pending-email.handler.ts b/packages/lib/jobs/definitions/emails/send-pending-email.handler.ts new file mode 100644 index 000000000..323d7839b --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-pending-email.handler.ts @@ -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, + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-pending-email.ts b/packages/lib/jobs/definitions/emails/send-pending-email.ts new file mode 100644 index 000000000..883027ad4 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-pending-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts index 2587103ef..8b94c1687 100644 --- a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.handler.ts @@ -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, }); }; diff --git a/packages/lib/jobs/definitions/emails/send-resend-document-email.handler.ts b/packages/lib/jobs/definitions/emails/send-resend-document-email.handler.ts new file mode 100644 index 000000000..8639aad35 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-resend-document-email.handler.ts @@ -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, + }, + }), + }); + }), + ); +}; diff --git a/packages/lib/jobs/definitions/emails/send-resend-document-email.ts b/packages/lib/jobs/definitions/emails/send-resend-document-email.ts new file mode 100644 index 000000000..4bb677e5b --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-resend-document-email.ts @@ -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 +>; diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 786de3a9c..8fa249738 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -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: { diff --git a/packages/lib/server-only/admin/admin-super-delete-document.ts b/packages/lib/server-only/admin/admin-super-delete-document.ts index 1adb0bb43..564e3c155 100644 --- a/packages/lib/server-only/admin/admin-super-delete-document.ts +++ b/packages/lib/server-only/admin/admin-super-delete-document.ts @@ -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; }; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 3b1ff67b8..f2ad83b16 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -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; diff --git a/packages/lib/server-only/document/resend-document.ts b/packages/lib/server-only/document/resend-document.ts index 632532830..70ba4a1cc 100644 --- a/packages/lib/server-only/document/resend-document.ts +++ b/packages/lib/server-only/document/resend-document.ts @@ -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; }; diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index 3a427954f..7b12017f5 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -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( diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index e482d4d85..67946efe2 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -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({ diff --git a/packages/lib/server-only/user/forgot-password.ts b/packages/lib/server-only/user/forgot-password.ts index 3b19f1e33..f7cf9d921 100644 --- a/packages/lib/server-only/user/forgot-password.ts +++ b/packages/lib/server-only/user/forgot-password.ts @@ -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, + }, + }); }; diff --git a/packages/trpc/server/admin-router/delete-document.ts b/packages/trpc/server/admin-router/delete-document.ts index 83632b4e8..6396702e8 100644 --- a/packages/trpc/server/admin-router/delete-document.ts +++ b/packages/trpc/server/admin-router/delete-document.ts @@ -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, }); }); diff --git a/packages/trpc/server/document-router/access-auth-request-2fa-email.ts b/packages/trpc/server/document-router/access-auth-request-2fa-email.ts index 986705b98..05c3643c1 100644 --- a/packages/trpc/server/document-router/access-auth-request-2fa-email.ts +++ b/packages/trpc/server/document-router/access-auth-request-2fa-email.ts @@ -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 {