diff --git a/packages/ee/server-only/signing/csc/execute-tsp-sign.ts b/packages/ee/server-only/signing/csc/execute-tsp-sign.ts index 5045b33d8..3c95720f6 100644 --- a/packages/ee/server-only/signing/csc/execute-tsp-sign.ts +++ b/packages/ee/server-only/signing/csc/execute-tsp-sign.ts @@ -1,6 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; -import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -474,9 +473,12 @@ export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise 0) { - await sendPendingEmail({ - id: { type: 'envelopeId', id: envelope.id }, - recipientId: recipient.id, + await jobs.triggerJob({ + name: 'send.document.pending.email', + payload: { + envelopeId: envelope.id, + recipientId: recipient.id, + }, }); // TSP envelopes are forced SEQUENTIAL at send-time; this branch always diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index ae23cb092..397108f61 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -4,11 +4,14 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails'; import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails'; import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email'; +import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails'; +import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email'; import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-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_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_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email'; @@ -44,9 +47,12 @@ export const jobsClient = new JobClient([ SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, + SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION, SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION, + SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION, SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION, SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION, + SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION, SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION, BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION, BULK_SEND_TEMPLATE_JOB_DEFINITION, diff --git a/packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts new file mode 100644 index 000000000..4e1aa9fea --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts @@ -0,0 +1,70 @@ +import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; +import { msg } from '@lingui/core/macro'; +import { createElement } from 'react'; + +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 { isRecipientEmailValidForSending } from '../../../utils/recipients'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendDocumentDeletedEmailsJobDefinition } from './send-document-deleted-emails'; + +export const run = async ({ payload, io }: { payload: TSendDocumentDeletedEmailsJobDefinition; io: JobRunIO }) => { + const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload; + + if (recipients.length === 0) { + return; + } + + const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId, + }, + meta, + }); + + // Don't send cancellation emails if the organisation has email sending + // disabled. Re-checked here (not just at enqueue time) because the org can be + // disabled between the delete request and this job running. + if (emailsDisabled) { + return; + } + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const i18n = await getI18nInstance(emailLanguage); + + for (const recipient of recipients) { + await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => { + if (!isRecipientEmailValidForSending(recipient)) { + return; + } + + const template = createElement(DocumentCancelTemplate, { + documentName, + inviterName: inviterName || undefined, + inviterEmail, + assetBaseUrl, + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), + ]); + + await emailTransport.sendMail({ + to: { + address: recipient.email, + name: recipient.name, + }, + from: senderEmail, + replyTo: replyToEmail, + subject: i18n._(msg`Document Cancelled`), + html, + text, + }); + }); + } +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-deleted-emails.ts b/packages/lib/jobs/definitions/emails/send-document-deleted-emails.ts new file mode 100644 index 000000000..e63de8f6e --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-deleted-emails.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails'; + +const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({ + teamId: z.number(), + documentName: z.string(), + inviterName: z.string().optional(), + inviterEmail: z.string(), + /** + * The document's email meta (sender, reply-to, language). Captured before the + * envelope is hard-deleted so `getEmailContext` resolves the exact same + * sender/reply-to/language the inline send used. + */ + meta: z + .object({ + emailId: z.string().nullable().optional(), + emailReplyTo: z.string().nullable().optional(), + language: z.string().optional(), + }) + .nullable(), + recipients: z + .object({ + email: z.string(), + name: z.string(), + }) + .array(), +}); + +export type TSendDocumentDeletedEmailsJobDefinition = z.infer< + typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA +>; + +export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = { + id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID, + name: 'Send Document Deleted Emails', + version: '1.0.0', + trigger: { + name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID, + schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-document-deleted-emails.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID, + TSendDocumentDeletedEmailsJobDefinition +>; diff --git a/packages/lib/server-only/document/send-pending-email.ts b/packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts similarity index 66% rename from packages/lib/server-only/document/send-pending-email.ts rename to packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts index 4d803f5dd..3a7bf3e96 100644 --- a/packages/lib/server-only/document/send-pending-email.ts +++ b/packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts @@ -1,27 +1,24 @@ import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending'; +import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { msg } from '@lingui/core/macro'; import { EnvelopeType } from '@prisma/client'; import { createElement } from 'react'; +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 { isRecipientEmailValidForSending } from '../../../utils/recipients'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email'; -import { getI18nInstance } from '../../client-only/providers/i18n-server'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; -import type { EnvelopeIdOptions } from '../../utils/envelope'; -import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; -import { isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; -import { getEmailContext } from '../email/get-email-context'; +export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => { + const { envelopeId, recipientId } = payload; -export interface SendPendingEmailOptions { - id: EnvelopeIdOptions; - recipientId: number; -} - -export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => { const envelope = await prisma.envelope.findFirst({ where: { - ...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), + ...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT), recipients: { some: { id: recipientId, @@ -38,12 +35,8 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti }, }); - if (!envelope) { - throw new Error('Document not found'); - } - - if (envelope.recipients.length === 0) { - throw new Error('Document has no recipients'); + if (!envelope || envelope.recipients.length === 0) { + return; } const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({ diff --git a/packages/lib/jobs/definitions/emails/send-document-pending-email.ts b/packages/lib/jobs/definitions/emails/send-document-pending-email.ts new file mode 100644 index 000000000..ff6b885c7 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-pending-email.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email'; + +const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + envelopeId: z.string(), + recipientId: z.number(), +}); + +export type TSendDocumentPendingEmailJobDefinition = z.infer; + +export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = { + id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID, + name: 'Send Document Pending Email', + version: '1.0.0', + trigger: { + name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID, + schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-document-pending-email.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID, + TSendDocumentPendingEmailJobDefinition +>; diff --git a/packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts new file mode 100644 index 000000000..8f402704e --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts @@ -0,0 +1,105 @@ +import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; +import { prisma } from '@documenso/prisma'; +import { msg } from '@lingui/core/macro'; +import { createElement } from 'react'; + +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 { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; +import { isRecipientEmailValidForSending } from '../../../utils/recipients'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email'; + +export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => { + const { envelopeId, recipientEmail, recipientName, inviterName } = payload; + + const envelope = await prisma.envelope.findFirst({ + where: { + id: envelopeId, + }, + include: { + documentMeta: true, + }, + }); + + // The envelope may have been deleted between the recipient removal and this + // job running. Treat as a no-op so the job doesn't retry forever. + if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) { + return; + } + + // Re-checked at send time (not just at enqueue) so the email honors the + // document's current "recipient removed" setting. + const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved; + + if (!isRecipientRemovedEmailEnabled) { + return; + } + + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); + + // Don't send the removal email if the organisation has email sending disabled. + if (emailsDisabled) { + return; + } + + // Meter the removal email against the organisation email quota/stats. + // Add/remove churn can be used to blast unsolicited removal emails outside + // the email limits. + try { + await assertOrganisationRatesAndLimits({ + organisationId, + organisationClaim: claims, + type: 'email', + count: 1, + }); + } catch (_err) { + io.logger.warn({ + msg: 'Recipient removed email dropped: org email limit exceeded', + organisationId, + envelopeId: envelope.id, + }); + + return; + } + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(RecipientRemovedFromDocumentTemplate, { + documentName: envelope.title, + inviterName: inviterName || undefined, + assetBaseUrl, + }); + + const i18n = await getI18nInstance(emailLanguage); + + await io.runTask('send-recipient-removed-email', async () => { + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), + ]); + + await emailTransport.sendMail({ + to: { + address: recipientEmail, + name: recipientName, + }, + from: senderEmail, + replyTo: replyToEmail, + subject: i18n._(msg`You have been removed from a document`), + html, + text, + }); + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-recipient-removed-email.ts b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.ts new file mode 100644 index 000000000..484c70ac0 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email'; + +const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + envelopeId: z.string(), + recipientEmail: z.string(), + recipientName: z.string(), + inviterName: z.string().optional(), +}); + +export type TSendRecipientRemovedEmailJobDefinition = z.infer< + typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA +>; + +export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = { + id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID, + name: 'Send Recipient Removed Email', + version: '1.0.0', + trigger: { + name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID, + schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-recipient-removed-email.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID, + TSendRecipientRemovedEmailJobDefinition +>; 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 531cc59f4..10aefd189 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -29,7 +29,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; @@ -389,7 +388,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/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 3711921c6..8eed2702f 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -1,13 +1,9 @@ -import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import { prisma } from '@documenso/prisma'; -import { msg } from '@lingui/core/macro'; import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client'; import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client'; -import { createElement } from 'react'; -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 { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; @@ -16,7 +12,6 @@ import { isDocumentCompleted } from '../../utils/document'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getEmailContext } from '../email/get-email-context'; import { getMemberRoles } from '../team/get-member-roles'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -125,7 +120,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha return; } - const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({ + const { emailLanguage, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -192,50 +187,40 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha return deletedEnvelope; } - // Send cancellation emails to recipients. - await Promise.all( - envelope.recipients.map(async (recipient) => { - if ( - recipient.sendStatus !== SendStatus.SENT || - !isRecipientEmailValidForSending(recipient) || - recipient.role === RecipientRole.CC - ) { - return; - } + // Enqueue cancellation emails as a background job. The envelope (and its + // documentMeta) is hard-deleted above, so the job can't look it up later — + // pass a self-contained payload with the recipients to notify. + const recipientsToNotify = envelope.recipients + .filter( + (recipient) => + recipient.sendStatus === SendStatus.SENT && + recipient.role !== RecipientRole.CC && + isRecipientEmailValidForSending(recipient), + ) + .map((recipient) => ({ + email: recipient.email, + name: recipient.name, + })); - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(DocumentCancelTemplate, { + if (recipientsToNotify.length > 0) { + await jobs.triggerJob({ + name: 'send.document.deleted.emails', + payload: { + teamId: envelope.teamId, documentName: envelope.title, inviterName: user.name || undefined, inviterEmail: user.email, - 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 emailTransport.sendMail({ - to: { - address: recipient.email, - name: recipient.name, - }, - from: senderEmail, - replyTo: replyToEmail, - subject: i18n._(msg`Document Cancelled`), - html, - text, - }); - }), - ); + meta: envelope.documentMeta + ? { + emailId: envelope.documentMeta.emailId, + emailReplyTo: envelope.documentMeta.emailReplyTo, + language: emailLanguage, + } + : null, + recipients: recipientsToNotify, + }, + }); + } return deletedEnvelope; }; diff --git a/packages/lib/server-only/recipient/delete-envelope-recipient.ts b/packages/lib/server-only/recipient/delete-envelope-recipient.ts index 06d90ca46..e2e99feed 100644 --- a/packages/lib/server-only/recipient/delete-envelope-recipient.ts +++ b/packages/lib/server-only/recipient/delete-envelope-recipient.ts @@ -1,24 +1,16 @@ -import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; -import { msg } from '@lingui/core/macro'; import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; -import { createElement } from 'react'; -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 { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { logger } from '../../utils/logger'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { buildTeamWhereQuery } from '../../utils/teams'; -import { getEmailContext } from '../email/get-email-context'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; -import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; export interface DeleteEnvelopeRecipientOptions { userId: number; @@ -148,75 +140,16 @@ export const deleteEnvelopeRecipient = async ({ envelope.type === EnvelopeType.DOCUMENT && isRecipientEmailValidForSending(recipientToDelete) ) { - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(RecipientRemovedFromDocumentTemplate, { - documentName: envelope.title, - inviterName: envelope.team?.name || user.name || undefined, - assetBaseUrl, - }); - - const { - branding, - emailLanguage, - senderEmail, - replyToEmail, - organisationId, - claims, - emailsDisabled, - emailTransport, - } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); - - // Don't send the removal email if the organisation has email sending disabled. - if (emailsDisabled) { - return deletedRecipient; - } - - // Meter the removal email against the organisation email quota/stats. - // Add/remove churn can be used to blast unsolicited removal emails - // outside the email limits. - try { - await assertOrganisationRatesAndLimits({ - organisationId, - organisationClaim: claims, - type: 'email', - count: 1, - }); - } catch (_err) { - logger.warn({ - msg: 'Recipient removed email dropped: org email limit exceeded', - organisationId, - recipientId: recipientToDelete.id, + // Enqueue the "removed from document" email as a background job so a + // transient mail outage doesn't fail the request and the send is retried. + await jobs.triggerJob({ + name: 'send.recipient.removed.email', + payload: { envelopeId: envelope.id, - }); - - return deletedRecipient; - } - - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang: emailLanguage, branding }), - renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), - ]); - - const i18n = await getI18nInstance(emailLanguage); - - await emailTransport.sendMail({ - to: { - address: recipientToDelete.email, - name: recipientToDelete.name, + recipientEmail: recipientToDelete.email, + recipientName: recipientToDelete.name, + inviterName: envelope.team?.name || user.name || undefined, }, - from: senderEmail, - replyTo: replyToEmail, - subject: i18n._(msg`You have been removed from a document`), - html, - text, }); } diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 2e58936ab..ca2ca666d 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -1,4 +1,3 @@ -import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; @@ -7,25 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs'; import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; -import { msg } from '@lingui/core/macro'; import type { Recipient } from '@prisma/client'; import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; -import { createElement } from 'react'; import { isDeepEqual } from 'remeda'; -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 { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; -import { logger } from '../../utils/logger'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; -import { getEmailContext } from '../email/get-email-context'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; -import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; export interface SetDocumentRecipientsOptions { userId: number; @@ -88,16 +80,6 @@ export const setDocumentRecipients = async ({ throw new Error('Document already complete'); } - const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } = - await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId, - }, - meta: envelope.documentMeta, - }); - const recipientsHaveActionAuth = recipients.some( (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, ); @@ -290,67 +272,29 @@ export const setDocumentRecipients = async ({ const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved; - // Send emails to deleted recipients who have emails. - await Promise.all( - removedRecipients.map(async (recipient) => { - if ( - emailsDisabled || - recipient.sendStatus !== SendStatus.SENT || - recipient.role === RecipientRole.CC || - !isRecipientRemovedEmailEnabled || - !isRecipientEmailValidForSending(recipient) - ) { - return; - } + if (isRecipientRemovedEmailEnabled) { + await Promise.all( + removedRecipients.map(async (recipient) => { + if ( + recipient.sendStatus !== SendStatus.SENT || + recipient.role === RecipientRole.CC || + !isRecipientEmailValidForSending(recipient) + ) { + return; + } - // Meter against the organisation email quota/stats so add/remove churn - // can't be used to send unsolicited "removed" emails outside the limits. - try { - await assertOrganisationRatesAndLimits({ - organisationId, - organisationClaim: claims, - type: 'email', - count: 1, + await jobs.triggerJob({ + name: 'send.recipient.removed.email', + payload: { + envelopeId: envelope.id, + recipientEmail: recipient.email, + recipientName: recipient.name, + inviterName: user.name || undefined, + }, }); - } catch (_err) { - logger.warn({ - msg: 'Recipient removed email dropped: org email limit exceeded', - organisationId, - recipientId: recipient.id, - envelopeId: envelope.id, - }); - - return; - } - - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(RecipientRemovedFromDocumentTemplate, { - documentName: envelope.title, - inviterName: user.name || undefined, - 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 emailTransport.sendMail({ - to: { - address: recipient.email, - name: recipient.name, - }, - from: senderEmail, - replyTo: replyToEmail, - subject: i18n._(msg`You have been removed from a document`), - html, - text, - }); - }), - ); + }), + ); + } } // Filter out recipients that have been removed or have been updated.