mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: correctly log cc emails (#2913)
This commit is contained in:
@@ -3,12 +3,13 @@ import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
|||||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { msg } from '@lingui/core/macro';
|
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 { createElement } from 'react';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
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 { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||||
@@ -32,6 +33,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
|
|||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
documentMeta: 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',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
@@ -57,6 +60,26 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
|
|||||||
|
|
||||||
const { documentMeta, user: documentOwner } = envelope;
|
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
|
// Check if document cancellation emails are enabled
|
||||||
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
|
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
|
||||||
|
|
||||||
@@ -66,9 +89,13 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
|
|||||||
|
|
||||||
const i18n = await getI18nInstance(emailLanguage);
|
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(
|
const recipientsToNotify = envelope.recipients.filter(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
|
recipient.role !== RecipientRole.CC &&
|
||||||
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
||||||
recipient.signingStatus !== SigningStatus.REJECTED &&
|
recipient.signingStatus !== SigningStatus.REJECTED &&
|
||||||
isRecipientEmailValidForSending(recipient),
|
isRecipientEmailValidForSending(recipient),
|
||||||
@@ -77,6 +104,28 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
|
|||||||
await io.runTask('send-cancellation-emails', async () => {
|
await io.runTask('send-cancellation-emails', async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientsToNotify.map(async (recipient) => {
|
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, {
|
const template = createElement(DocumentCancelTemplate, {
|
||||||
documentName: envelope.title,
|
documentName: envelope.title,
|
||||||
inviterName: documentOwner.name || undefined,
|
inviterName: documentOwner.name || undefined,
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { mailer } from '@documenso/email/mailer';
|
|||||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { DocumentSource, EnvelopeType } from '@prisma/client';
|
import { DocumentSource, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||||
@@ -44,6 +45,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
|
|||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
disabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
@@ -65,7 +67,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
|
|||||||
throw new Error('Document has no recipients');
|
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',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
@@ -74,6 +77,11 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
|
|||||||
meta: envelope.documentMeta,
|
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 { user: owner } = envelope;
|
||||||
|
|
||||||
const completedDocumentEmailAttachments = await Promise.all(
|
const completedDocumentEmailAttachments = await Promise.all(
|
||||||
@@ -172,6 +180,30 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientsToNotify.map(async (recipient) => {
|
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 = {
|
const customEmailTemplate = {
|
||||||
'signer.name': recipient.name,
|
'signer.name': recipient.name,
|
||||||
'signer.email': recipient.email,
|
'signer.email': recipient.email,
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
teamEmail: true,
|
teamEmail: true,
|
||||||
@@ -84,8 +89,17 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId, claims } =
|
const {
|
||||||
await getEmailContext({
|
branding,
|
||||||
|
emailLanguage,
|
||||||
|
settings,
|
||||||
|
organisationType,
|
||||||
|
senderEmail,
|
||||||
|
replyToEmail,
|
||||||
|
organisationId,
|
||||||
|
claims,
|
||||||
|
isOrganisationOwnerDisabled,
|
||||||
|
} = await getEmailContext({
|
||||||
emailType: 'RECIPIENT',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
@@ -94,6 +108,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
|||||||
meta: envelope.documentMeta,
|
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 customEmail = envelope?.documentMeta;
|
||||||
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
|
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 { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
||||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
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 { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
||||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
|
const {
|
||||||
|
branding,
|
||||||
|
emailLanguage,
|
||||||
|
organisationType,
|
||||||
|
senderEmail,
|
||||||
|
replyToEmail,
|
||||||
|
isOrganisationOwnerDisabled,
|
||||||
|
organisationId,
|
||||||
|
claims,
|
||||||
|
} = await getEmailContext({
|
||||||
emailType: 'RECIPIENT',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
@@ -109,6 +119,12 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
|||||||
meta: envelope.documentMeta,
|
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 i18n = await getI18nInstance(emailLanguage);
|
||||||
|
|
||||||
const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase();
|
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 assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
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(
|
io.logger.info(
|
||||||
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
|
`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,
|
userId: envelope.userId,
|
||||||
teamId: envelope.teamId,
|
teamId: envelope.teamId,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Compute the next reminder time (repeat interval).
|
// Compute the next reminder time (repeat interval).
|
||||||
if (recipient.sentAt) {
|
if (recipient.sentAt) {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export type EmailContextResponse = {
|
|||||||
};
|
};
|
||||||
replyToEmail: string | undefined;
|
replyToEmail: string | undefined;
|
||||||
emailLanguage: string;
|
emailLanguage: string;
|
||||||
|
isOrganisationOwnerDisabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
|
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
|
||||||
@@ -135,6 +136,11 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
|
|||||||
id: organisationId,
|
id: organisationId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
organisationClaim: true,
|
organisationClaim: true,
|
||||||
organisationGlobalSettings: true,
|
organisationGlobalSettings: true,
|
||||||
emailDomains: {
|
emailDomains: {
|
||||||
@@ -167,6 +173,7 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
|
|||||||
claims,
|
claims,
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
organisationType: organisation.type,
|
organisationType: organisation.type,
|
||||||
|
isOrganisationOwnerDisabled: organisation.owner.disabled,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,6 +186,12 @@ const handleTeamEmailContext = async (teamId: number) => {
|
|||||||
teamGlobalSettings: true,
|
teamGlobalSettings: true,
|
||||||
organisation: {
|
organisation: {
|
||||||
include: {
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
organisationClaim: true,
|
organisationClaim: true,
|
||||||
organisationGlobalSettings: true,
|
organisationGlobalSettings: true,
|
||||||
emailDomains: {
|
emailDomains: {
|
||||||
@@ -212,6 +225,7 @@ const handleTeamEmailContext = async (teamId: number) => {
|
|||||||
claims,
|
claims,
|
||||||
organisationId: organisation.id,
|
organisationId: organisation.id,
|
||||||
organisationType: organisation.type,
|
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 type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { EnvelopeType, SendStatus } from '@prisma/client';
|
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
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 { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
|
|
||||||
export interface DeleteEnvelopeRecipientOptions {
|
export interface DeleteEnvelopeRecipientOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -137,6 +139,7 @@ export const deleteEnvelopeRecipient = async ({
|
|||||||
// Send email to deleted recipient.
|
// Send email to deleted recipient.
|
||||||
if (
|
if (
|
||||||
recipientToDelete.sendStatus === SendStatus.SENT &&
|
recipientToDelete.sendStatus === SendStatus.SENT &&
|
||||||
|
recipientToDelete.role !== RecipientRole.CC &&
|
||||||
isRecipientRemovedEmailEnabled &&
|
isRecipientRemovedEmailEnabled &&
|
||||||
envelope.type === EnvelopeType.DOCUMENT &&
|
envelope.type === EnvelopeType.DOCUMENT &&
|
||||||
isRecipientEmailValidForSending(recipientToDelete)
|
isRecipientEmailValidForSending(recipientToDelete)
|
||||||
@@ -149,7 +152,7 @@ export const deleteEnvelopeRecipient = async ({
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({
|
||||||
emailType: 'RECIPIENT',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
@@ -158,6 +161,27 @@ export const deleteEnvelopeRecipient = async ({
|
|||||||
meta: envelope.documentMeta,
|
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([
|
const [html, text] = await Promise.all([
|
||||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
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 { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
|
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||||
|
|
||||||
export interface SetDocumentRecipientsOptions {
|
export interface SetDocumentRecipientsOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -83,7 +85,7 @@ export const setDocumentRecipients = async ({
|
|||||||
throw new Error('Document already complete');
|
throw new Error('Document already complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims } = await getEmailContext({
|
||||||
emailType: 'RECIPIENT',
|
emailType: 'RECIPIENT',
|
||||||
source: {
|
source: {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
@@ -287,6 +289,26 @@ export const setDocumentRecipients = async ({
|
|||||||
return;
|
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 assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
|
|
||||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||||
|
|||||||
Reference in New Issue
Block a user