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 5c8869569..1a2f5539a 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 @@ -3,12 +3,13 @@ import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients'; import { prisma } from '@documenso/prisma'; import { msg } from '@lingui/core/macro'; -import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/client'; +import { EnvelopeType, ReadStatus, RecipientRole, SendStatus, SigningStatus } 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 { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; @@ -32,6 +33,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai id: true, email: true, name: true, + disabled: true, }, }, documentMeta: true, @@ -46,17 +48,38 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai }, }); - const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); + const { branding, emailLanguage, senderEmail, replyToEmail, isOrganisationOwnerDisabled, organisationId, claims } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); const { documentMeta, user: documentOwner } = envelope; + // Don't send cancellation emails on behalf of a disabled (e.g. banned) account. + if (isOrganisationOwnerDisabled || documentOwner.disabled) { + return; + } + + // A recipientCount of 0 means unlimited recipients are allowed. + const maximumRecipientCount = claims.recipientCount; + + if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) { + io.logger.warn({ + msg: 'Cancellation email dropped: org recipient limit exceeded', + organisationId, + recipientCount: envelope.recipients.length, + maximumRecipientCount, + envelopeId: envelope.id, + }); + + return; + } + // Check if document cancellation emails are enabled const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted; @@ -66,9 +89,13 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai const i18n = await getI18nInstance(emailLanguage); - // Send cancellation emails to all recipients who have been sent the document or viewed it + // Send cancellation emails to recipients who have been sent the document or viewed it. + // CC recipients are excluded because they were never actually emailed about the document + // (CC recipients are created with sendStatus=SENT by default but never receive a signing + // invitation), so notifying them about a cancellation they never knew about is unsolicited. const recipientsToNotify = envelope.recipients.filter( (recipient) => + recipient.role !== RecipientRole.CC && (recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) && recipient.signingStatus !== SigningStatus.REJECTED && isRecipientEmailValidForSending(recipient), @@ -77,6 +104,28 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai await io.runTask('send-cancellation-emails', async () => { await Promise.all( recipientsToNotify.map(async (recipient) => { + // Meter the cancellation email against the organisation email quota/stats. + // The recipient never opted in, so this notification is unsolicited and + // must be bounded by the same org limits as other outbound emails. + try { + await assertOrganisationRatesAndLimits({ + organisationId, + organisationClaim: claims, + type: 'email', + count: 1, + }); + } catch (_err) { + io.logger.warn({ + msg: 'Cancellation email dropped: org email limit exceeded', + organisationId, + recipientId: recipient.id, + envelopeId: envelope.id, + }); + + // On rate/quota exceeded, skip this recipient and continue with the rest. + return; + } + const template = createElement(DocumentCancelTemplate, { documentName: envelope.title, inviterName: documentOwner.name || undefined, diff --git a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts index d48f7b639..538b4e9d3 100644 --- a/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts @@ -2,12 +2,13 @@ import { mailer } from '@documenso/email/mailer'; import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed'; import { prisma } from '@documenso/prisma'; import { msg } from '@lingui/core/macro'; -import { DocumentSource, EnvelopeType } from '@prisma/client'; +import { DocumentSource, EnvelopeType, RecipientRole } 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 { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { getFileServerSide } from '../../../universal/upload/get-file.server'; @@ -44,6 +45,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai id: true, email: true, name: true, + disabled: true, }, }, team: { @@ -65,14 +67,20 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai throw new Error('Document has no recipients'); } - const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); + const { branding, emailLanguage, senderEmail, replyToEmail, isOrganisationOwnerDisabled, organisationId, claims } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); + + // Don't send completion emails on behalf of a disabled (e.g. banned) account. + if (envelope.user.disabled || isOrganisationOwnerDisabled) { + return; + } const { user: owner } = envelope; @@ -172,6 +180,30 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai await Promise.all( recipientsToNotify.map(async (recipient) => { + // A CC recipient never asked to be part of this document, so their completion + // email is effectively unsolicited. Meter it against the organisation email + // quota/stats so it is correctly logged. + if (recipient.role === RecipientRole.CC) { + try { + await assertOrganisationRatesAndLimits({ + organisationId, + organisationClaim: claims, + type: 'email', + count: 1, + }); + } catch (_err) { + io.logger.warn({ + msg: 'CC completion email dropped: org email limit exceeded', + organisationId, + recipientId: recipient.id, + envelopeId: envelope.id, + }); + + // On rate/quota exceeded, early return to allow other recipients to be processed. + return; + } + } + const customEmailTemplate = { 'signer.name': recipient.name, 'signer.email': recipient.email, diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index ae1d52480..dd009d14f 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -55,6 +55,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini }, include: { documentMeta: true, + user: { + select: { + disabled: true, + }, + }, team: { select: { teamEmail: true, @@ -84,15 +89,29 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini return; } - const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId, claims } = - await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); + const { + branding, + emailLanguage, + settings, + organisationType, + senderEmail, + replyToEmail, + organisationId, + claims, + isOrganisationOwnerDisabled, + } = await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); + + // Don't send signing invitations on behalf of a disabled (e.g. banned) account. + if (envelope.user.disabled || isOrganisationOwnerDisabled) { + return; + } const customEmail = envelope?.documentMeta; const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK; diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts index 39e7fc177..ef6d52662 100644 --- a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -17,6 +17,7 @@ import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles'; import { getEmailContext } from '../../../server-only/email/get-email-context'; +import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits'; import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder'; import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs'; @@ -100,7 +101,16 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob return; } - const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({ + const { + branding, + emailLanguage, + organisationType, + senderEmail, + replyToEmail, + isOrganisationOwnerDisabled, + organisationId, + claims, + } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -109,6 +119,12 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob meta: envelope.documentMeta, }); + // Don't send reminders on behalf of a disabled (e.g. banned) account. + if (envelope.user.disabled || isOrganisationOwnerDisabled) { + io.logger.info(`Envelope ${envelope.id} owner is disabled, skipping reminder`); + return; + } + const i18n = await getI18nInstance(emailLanguage); const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase(); @@ -139,61 +155,84 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; - io.logger.info( - `Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`, - ); - - const template = createElement(DocumentReminderEmailTemplate, { - recipientName: recipient.name, - documentName: envelope.title, - assetBaseUrl, - signDocumentLink, - customBody: emailMessage, - role: recipient.role, - }); - - 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: emailSubject, - html, - text, - }); - - await prisma.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - envelopeId: envelope.id, - data: { - recipientEmail: recipient.email, - recipientName: recipient.name, + // Meter reminder emails against the organisation email quota/stats. Reminders + // are unsolicited (the recipient didn't opt in to them) and can recur, so they + // must be bounded by the same org limits as other outbound emails. + const isRateLimited = await assertOrganisationRatesAndLimits({ + organisationId, + organisationClaim: claims, + type: 'email', + count: 1, + }) + .then(() => false) + .catch((_err) => { + io.logger.warn({ + msg: 'Signing reminder dropped: org email limit exceeded', + organisationId, recipientId: recipient.id, - recipientRole: recipient.role, - emailType: DOCUMENT_EMAIL_TYPE.REMINDER, - isResending: false, - }, - }), - }); + envelopeId: envelope.id, + }); - await triggerWebhook({ - event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT, - data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)), - userId: envelope.userId, - teamId: envelope.teamId, - }); + return true; + }); + + if (!isRateLimited) { + io.logger.info( + `Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`, + ); + + const template = createElement(DocumentReminderEmailTemplate, { + recipientName: recipient.name, + documentName: envelope.title, + assetBaseUrl, + signDocumentLink, + customBody: emailMessage, + role: recipient.role, + }); + + 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: emailSubject, + html, + text, + }); + + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + envelopeId: envelope.id, + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + emailType: DOCUMENT_EMAIL_TYPE.REMINDER, + isResending: false, + }, + }), + }); + + await triggerWebhook({ + event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT, + data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)), + userId: envelope.userId, + teamId: envelope.teamId, + }); + } // Compute the next reminder time (repeat interval). if (recipient.sentAt) { diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index 4e1ae3dba..86e7ee2d7 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -74,6 +74,7 @@ export type EmailContextResponse = { }; replyToEmail: string | undefined; emailLanguage: string; + isOrganisationOwnerDisabled: boolean; }; export const getEmailContext = async (options: GetEmailContextOptions): Promise => { @@ -135,6 +136,11 @@ const handleOrganisationEmailContext = async (organisationId: string) => { id: organisationId, }, include: { + owner: { + select: { + disabled: true, + }, + }, organisationClaim: true, organisationGlobalSettings: true, emailDomains: { @@ -167,6 +173,7 @@ const handleOrganisationEmailContext = async (organisationId: string) => { claims, organisationId: organisation.id, organisationType: organisation.type, + isOrganisationOwnerDisabled: organisation.owner.disabled, }; }; @@ -179,6 +186,12 @@ const handleTeamEmailContext = async (teamId: number) => { teamGlobalSettings: true, organisation: { include: { + owner: { + select: { + id: true, + disabled: true, + }, + }, organisationClaim: true, organisationGlobalSettings: true, emailDomains: { @@ -212,6 +225,7 @@ const handleTeamEmailContext = async (teamId: number) => { claims, organisationId: organisation.id, organisationType: organisation.type, + isOrganisationOwnerDisabled: organisation.owner.disabled, }; }; diff --git a/packages/lib/server-only/recipient/delete-envelope-recipient.ts b/packages/lib/server-only/recipient/delete-envelope-recipient.ts index c5d74ad44..0a50086e5 100644 --- a/packages/lib/server-only/recipient/delete-envelope-recipient.ts +++ b/packages/lib/server-only/recipient/delete-envelope-recipient.ts @@ -4,7 +4,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; import { msg } from '@lingui/core/macro'; -import { EnvelopeType, SendStatus } from '@prisma/client'; +import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; import { createElement } from 'react'; import { getI18nInstance } from '../../client-only/providers/i18n-server'; @@ -12,11 +12,13 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; 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 { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; export interface DeleteEnvelopeRecipientOptions { userId: number; @@ -137,6 +139,7 @@ export const deleteEnvelopeRecipient = async ({ // Send email to deleted recipient. if ( recipientToDelete.sendStatus === SendStatus.SENT && + recipientToDelete.role !== RecipientRole.CC && isRecipientRemovedEmailEnabled && envelope.type === EnvelopeType.DOCUMENT && isRecipientEmailValidForSending(recipientToDelete) @@ -149,7 +152,7 @@ export const deleteEnvelopeRecipient = async ({ assetBaseUrl, }); - const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -158,6 +161,27 @@ export const deleteEnvelopeRecipient = async ({ meta: envelope.documentMeta, }); + // 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, + envelopeId: envelope.id, + }); + + return deletedRecipient; + } + const [html, text] = await Promise.all([ renderEmailWithI18N(template, { lang: emailLanguage, branding }), renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index ef9db39de..c09827ef5 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -19,10 +19,12 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; 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 { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; export interface SetDocumentRecipientsOptions { userId: number; @@ -83,7 +85,7 @@ export const setDocumentRecipients = async ({ throw new Error('Document already complete'); } - const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -287,6 +289,26 @@ export const setDocumentRecipients = async ({ 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, + }); + } 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, {