mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 02:15:05 +10:00
chore: merged main
This commit is contained in:
@@ -31,9 +31,13 @@ export const loadRecipientBrandingByTeamId = async ({
|
||||
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
let allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
|
||||
|
||||
if (!settings.brandingEnabled) {
|
||||
allowCustomBranding = false;
|
||||
}
|
||||
|
||||
if (!allowCustomBranding) {
|
||||
return {
|
||||
allowCustomBranding: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// This is closely related to `reject-document-with-token.ts` but is intentionally
|
||||
// kept as a separate method rather than merged into one. This file focuses on
|
||||
// rejection from an API/programmatic perspective (an authenticated API user acting
|
||||
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
|
||||
// from a recipient perspective (the recipient rejecting via their token).
|
||||
//
|
||||
// Code changes in one should probably be mirrored to the other, particularly in
|
||||
// relation to the jobs triggered after a rejection.
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type RejectDocumentOnBehalfOfOptions = {
|
||||
/**
|
||||
* The ID of the envelope the recipient belongs to. Required so the caller
|
||||
* targets an explicit envelope/recipient combination rather than resolving the
|
||||
* envelope implicitly from the recipient ID.
|
||||
*/
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
reason: string;
|
||||
/**
|
||||
* The email of a team member to attribute the rejection to. Must be a member
|
||||
* of the team. When omitted the rejection is attributed to the API user that
|
||||
* owns the token (`userId`).
|
||||
*
|
||||
* This exists so external applications can elect which team member is acting
|
||||
* on behalf of the recipient, rather than always defaulting to the API user.
|
||||
*/
|
||||
actAsEmail?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject a document on behalf of a recipient as an authenticated API user.
|
||||
*
|
||||
* This is used to programmatically record a rejection for cases where the
|
||||
* recipient declined to sign outside of the platform (e.g. before ever
|
||||
* reaching it). The rejection is flagged as `isExternal` in the audit log to
|
||||
* distinguish it from a rejection performed by the recipient directly.
|
||||
*
|
||||
* The action can optionally be attributed to a specific team member via
|
||||
* `actAsEmail`; otherwise it is attributed to the API user.
|
||||
*/
|
||||
export async function rejectDocumentOnBehalfOf({
|
||||
envelopeId,
|
||||
recipientId,
|
||||
userId,
|
||||
teamId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
requestMetadata,
|
||||
}: RejectDocumentOnBehalfOfOptions) {
|
||||
// Build the access-controlled envelope query. This enforces team membership
|
||||
// AND document visibility (and owner / team-email access), mirroring the
|
||||
// canonical envelope access checks used across the app.
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = recipient?.envelope;
|
||||
|
||||
if (!recipient || !envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document or recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document ${envelope.id} must be pending to reject`,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} has already actioned this document`,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// Resolve the user the rejection should be attributed to. When `actAsEmail`
|
||||
// is supplied it must resolve to a member of the team; otherwise the rejection
|
||||
// is attributed to the API user that owns the token.
|
||||
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
|
||||
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
|
||||
|
||||
// Update the recipient status to rejected and record an external rejection
|
||||
// audit log within the same transaction.
|
||||
const [updatedRecipient] = await prisma.$transaction([
|
||||
prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
signedAt: new Date(),
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
rejectionReason: reason,
|
||||
},
|
||||
}),
|
||||
prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
// Always attribute the audit log to a concrete user: the elected team
|
||||
// member when supplied, otherwise the API user that owns the token.
|
||||
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
reason,
|
||||
isExternal: true,
|
||||
// Only set when a member was explicitly elected via `actAsEmail`.
|
||||
onBehalfOfUserEmail: electedUser?.email,
|
||||
onBehalfOfUserName: electedUser?.name,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Trigger the seal document job to process the document asynchronously.
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email notifications to the rejecting recipient.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.rejected.emails',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
documentId: legacyDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Send cancellation emails to other recipients.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
|
||||
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
|
||||
* user). Throws when the email does not resolve to a member of the team.
|
||||
*/
|
||||
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
|
||||
if (!actAsEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const electedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: actAsEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (!electedUser) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'The user to act on behalf of must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
const isTeamMember = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
|
||||
});
|
||||
|
||||
if (!isTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'The user to act on behalf of must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
return electedUser;
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
|
||||
// kept as a separate method rather than merged into one. This file focuses on
|
||||
// rejection from a recipient perspective (the recipient rejecting via their token),
|
||||
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
|
||||
// perspective (an authenticated API user acting on behalf of a recipient).
|
||||
//
|
||||
// Code changes in one should probably be mirrored to the other, particularly in
|
||||
// relation to the jobs triggered after a rejection.
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
@@ -30,6 +31,7 @@ import { buildEnvelopeEmailHeaders } from '../email/build-envelope-email-headers
|
||||
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';
|
||||
import { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder';
|
||||
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -117,7 +119,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the expiresAt on each resent recipient.
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
const recipientsToRemind = envelope.recipients.filter(
|
||||
@@ -127,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
recipient.role !== RecipientRole.CC,
|
||||
);
|
||||
|
||||
// Extend the expiration deadline for recipients being resent.
|
||||
if (expiresAt && recipientsToRemind.length > 0) {
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
@@ -142,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
});
|
||||
}
|
||||
|
||||
// A manual resend restarts the reminder cycle from scratch, mirroring the
|
||||
// initial send, so a recipient that hit the threshold can be reminded again.
|
||||
const resentAt = new Date();
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map((recipient) =>
|
||||
updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt: resentAt,
|
||||
lastReminderSentAt: null,
|
||||
resetReminderCount: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
@@ -276,6 +292,18 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
}),
|
||||
});
|
||||
|
||||
// Mark the recipient as sent if they were not already sent.
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
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 { 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 interface SendPendingEmailOptions {
|
||||
id: EnvelopeIdOptions;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
// Don't send any emails if the organisation has email sending disabled.
|
||||
if (emailsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentPending;
|
||||
|
||||
if (!isDocumentPendingEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
// Skip sending email if recipient has no email address
|
||||
if (!isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
documentName: envelope.title,
|
||||
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: email,
|
||||
name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Waiting for others to complete signing.`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,28 +6,21 @@ 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,
|
||||
getRecipientSigningOrder,
|
||||
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 { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
@@ -92,16 +84,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,
|
||||
);
|
||||
@@ -294,67 +276,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.
|
||||
|
||||
@@ -6,22 +6,20 @@ import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constant
|
||||
/**
|
||||
* Compute and store `nextReminderAt` for a single recipient.
|
||||
*
|
||||
* Call this after:
|
||||
* - Sending the signing email (sentAt is set)
|
||||
* - Sending a reminder (lastReminderSentAt is updated)
|
||||
*
|
||||
* If `reminderSettings` is provided it's used directly, avoiding a query.
|
||||
* Otherwise it's read from the envelope's documentMeta (already resolved
|
||||
* from the org/team cascade at envelope creation time).
|
||||
* Pass `resetReminderCount: true` to restart the reminder cycle (e.g. on a
|
||||
* manual resend): the count is zeroed and the schedule recomputed as if the
|
||||
* request was freshly sent at `sentAt`.
|
||||
*/
|
||||
export const updateRecipientNextReminder = async (options: {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderCount?: number;
|
||||
resetReminderCount?: boolean;
|
||||
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
|
||||
}) => {
|
||||
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
|
||||
const { recipientId, envelopeId, sentAt, lastReminderSentAt, reminderCount = 0, resetReminderCount } = options;
|
||||
|
||||
let settings = options.reminderSettings;
|
||||
|
||||
@@ -40,11 +38,15 @@ export const updateRecipientNextReminder = async (options: {
|
||||
config: settings,
|
||||
sentAt,
|
||||
lastReminderSentAt,
|
||||
reminderCount: resetReminderCount ? 0 : reminderCount,
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipientId },
|
||||
data: { nextReminderAt },
|
||||
data: {
|
||||
nextReminderAt,
|
||||
...(resetReminderCount ? { reminderCount: 0 } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,7 +84,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
// Don't reschedule reminders for recipients whose deadline has passed.
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
select: { id: true, sentAt: true, lastReminderSentAt: true },
|
||||
select: { id: true, sentAt: true, lastReminderSentAt: true, reminderCount: true },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
@@ -95,6 +97,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
config: settings,
|
||||
sentAt: recipient.sentAt,
|
||||
lastReminderSentAt: recipient.lastReminderSentAt,
|
||||
reminderCount: recipient.reminderCount,
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
|
||||
Reference in New Issue
Block a user