fix: correctly log cc emails (#2913)

This commit is contained in:
David Nguyen
2026-06-02 15:04:02 +10:00
committed by GitHub
parent c50a01d004
commit d2f60b13fd
7 changed files with 284 additions and 85 deletions
@@ -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,7 +48,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
},
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, isOrganisationOwnerDisabled, organisationId, claims } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -57,6 +60,26 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
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,
@@ -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,7 +67,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, isOrganisationOwnerDisabled, organisationId, claims } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -74,6 +77,11 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
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;
const completedDocumentEmailAttachments = await Promise.all(
@@ -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,
@@ -55,6 +55,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
},
include: {
documentMeta: true,
user: {
select: {
disabled: true,
},
},
team: {
select: {
teamEmail: true,
@@ -84,8 +89,17 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
return;
}
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId, claims } =
await getEmailContext({
const {
branding,
emailLanguage,
settings,
organisationType,
senderEmail,
replyToEmail,
organisationId,
claims,
isOrganisationOwnerDisabled,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -94,6 +108,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
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;
@@ -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,6 +155,28 @@ 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}`;
// 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,
envelopeId: envelope.id,
});
return true;
});
if (!isRateLimited) {
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
@@ -194,6 +232,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
userId: envelope.userId,
teamId: envelope.teamId,
});
}
// Compute the next reminder time (repeat interval).
if (recipient.sentAt) {
@@ -74,6 +74,7 @@ export type EmailContextResponse = {
};
replyToEmail: string | undefined;
emailLanguage: string;
isOrganisationOwnerDisabled: boolean;
};
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
@@ -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,
};
};
@@ -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 }),
@@ -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, {