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 { 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; teamId: number; recipientId: number; requestMetadata: ApiRequestMetadata; } export const deleteEnvelopeRecipient = async ({ userId, teamId, recipientId, requestMetadata, }: DeleteEnvelopeRecipientOptions) => { const envelope = await prisma.envelope.findFirst({ where: { recipients: { some: { id: recipientId, }, }, team: buildTeamWhereQuery({ teamId, userId }), }, include: { documentMeta: true, team: true, recipients: { where: { id: recipientId, }, include: { fields: true, }, }, }, }); const user = await prisma.user.findFirst({ where: { id: userId, }, select: { id: true, name: true, email: true, }, }); if (!envelope) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Document not found', }); } assertEnvelopeMutable(envelope); if (envelope.completedAt) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Document already complete', }); } if (!user) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found', }); } const recipientToDelete = envelope.recipients[0]; if (!recipientToDelete || recipientToDelete.id !== recipientId) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Recipient not found', }); } if (!canRecipientBeModified(recipientToDelete, recipientToDelete.fields)) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Recipient has already interacted with the document.', }); } const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { type: 'envelopeId', id: envelope.id, }, type: null, userId, teamId, }); const deletedRecipient = await prisma.$transaction(async (tx) => { await assertEnvelopeMutable(envelope, tx); if (envelope.type === EnvelopeType.DOCUMENT) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED, envelopeId: envelope.id, metadata: requestMetadata, data: { recipientEmail: recipientToDelete.email, recipientName: recipientToDelete.name, recipientId: recipientToDelete.id, recipientRole: recipientToDelete.role, }, }), }); } return await tx.recipient.delete({ where: { id: recipientId, envelope: envelopeWhereInput, }, }); }); const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved; // Send email to deleted recipient. if ( recipientToDelete.sendStatus === SendStatus.SENT && recipientToDelete.role !== RecipientRole.CC && isRecipientRemovedEmailEnabled && 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, 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, }, from: senderEmail, replyTo: replyToEmail, subject: i18n._(msg`You have been removed from a document`), html, text, }); } return deletedRecipient; };