mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
chore: merged main
This commit is contained in:
@@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
|
||||
*/
|
||||
export const MAX_REMINDER_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum number of automated reminders sent to a recipient before reminders
|
||||
* stop. A manual resend resets the count, re-arming reminders.
|
||||
*/
|
||||
export const MAX_REMINDERS_BEFORE_RESEND = 5;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
@@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer
|
||||
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
|
||||
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
|
||||
*
|
||||
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
|
||||
* any computed reminder beyond that point returns null so reminders stop.
|
||||
* Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS`
|
||||
* from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent.
|
||||
*
|
||||
* `sentAt` is when the signing request was sent to this specific recipient.
|
||||
*
|
||||
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
|
||||
* Returns the next Date the reminder should be sent, or null if none.
|
||||
*/
|
||||
export const resolveNextReminderAt = (options: {
|
||||
config: TEnvelopeReminderSettings | null;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderCount: number;
|
||||
}): Date | null => {
|
||||
const { config, sentAt, lastReminderSentAt } = options;
|
||||
const { config, sentAt, lastReminderSentAt, reminderCount } = options;
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
|
||||
|
||||
let candidate: Date;
|
||||
|
||||
@@ -4,11 +4,14 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
|
||||
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
|
||||
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
|
||||
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email';
|
||||
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
|
||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
@@ -44,9 +47,12 @@ export const jobsClient = new JobClient([
|
||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION,
|
||||
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
|
||||
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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 { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendDocumentDeletedEmailsJobDefinition } from './send-document-deleted-emails';
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TSendDocumentDeletedEmailsJobDefinition; io: JobRunIO }) => {
|
||||
const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
|
||||
// Don't send cancellation emails if the organisation has email sending
|
||||
// disabled. Re-checked here (not just at enqueue time) because the org can be
|
||||
// disabled between the delete request and this job running.
|
||||
if (emailsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => {
|
||||
if (!isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName,
|
||||
inviterName: inviterName || undefined,
|
||||
inviterEmail,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails';
|
||||
|
||||
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
|
||||
teamId: z.number(),
|
||||
documentName: z.string(),
|
||||
inviterName: z.string().optional(),
|
||||
inviterEmail: z.string(),
|
||||
/**
|
||||
* The document's email meta (sender, reply-to, language). Captured before the
|
||||
* envelope is hard-deleted so `getEmailContext` resolves the exact same
|
||||
* sender/reply-to/language the inline send used.
|
||||
*/
|
||||
meta: z
|
||||
.object({
|
||||
emailId: z.string().nullable().optional(),
|
||||
emailReplyTo: z.string().nullable().optional(),
|
||||
language: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
recipients: z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type TSendDocumentDeletedEmailsJobDefinition = z.infer<
|
||||
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = {
|
||||
id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
|
||||
name: 'Send Document Deleted Emails',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
|
||||
schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-document-deleted-emails.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
|
||||
TSendDocumentDeletedEmailsJobDefinition
|
||||
>;
|
||||
+14
-21
@@ -1,27 +1,24 @@
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
|
||||
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 { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email';
|
||||
|
||||
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 const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => {
|
||||
const { envelopeId, recipientId } = payload;
|
||||
|
||||
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),
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
@@ -38,12 +35,8 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
if (!envelope || envelope.recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
|
||||
|
||||
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
envelopeId: z.string(),
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendDocumentPendingEmailJobDefinition = z.infer<typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Document Pending Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-document-pending-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendDocumentPendingEmailJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,105 @@
|
||||
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
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 { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email';
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => {
|
||||
const { envelopeId, recipientEmail, recipientName, inviterName } = payload;
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The envelope may have been deleted between the recipient removal and this
|
||||
// job running. Treat as a no-op so the job doesn't retry forever.
|
||||
if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-checked at send time (not just at enqueue) so the email honors the
|
||||
// document's current "recipient removed" setting.
|
||||
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
|
||||
|
||||
if (!isRecipientRemovedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
io.logger.warn({
|
||||
msg: 'Recipient removed email dropped: org email limit exceeded',
|
||||
organisationId,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||
documentName: envelope.title,
|
||||
inviterName: inviterName || undefined,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await io.runTask('send-recipient-removed-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: recipientEmail,
|
||||
name: recipientName,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email';
|
||||
|
||||
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
envelopeId: z.string(),
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
inviterName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TSendRecipientRemovedEmailJobDefinition = z.infer<
|
||||
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Recipient Removed Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-recipient-removed-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendRecipientRemovedEmailJobDefinition
|
||||
>;
|
||||
@@ -52,6 +52,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
||||
data: {
|
||||
lastReminderSentAt: now,
|
||||
nextReminderAt: null,
|
||||
reminderCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -243,13 +244,15 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
||||
});
|
||||
}
|
||||
|
||||
// Compute the next reminder time (repeat interval).
|
||||
// reminderCount was incremented in the atomic claim above, so the value read
|
||||
// here includes the reminder we just sent and gates the next one.
|
||||
if (recipient.sentAt) {
|
||||
await updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt: recipient.sentAt,
|
||||
lastReminderSentAt: now,
|
||||
reminderCount: recipient.reminderCount,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+564
-285
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -560,12 +560,24 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document).
|
||||
* Event: Document recipient rejected the document.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
reason: z.string(),
|
||||
/**
|
||||
* Whether the rejection was recorded externally on behalf of the recipient
|
||||
* via the API, rather than by the recipient directly on the platform.
|
||||
*/
|
||||
isExternal: z.boolean().optional(),
|
||||
/**
|
||||
* The team member the external rejection was recorded on behalf of, when
|
||||
* the API caller elected a specific member to attribute the action to.
|
||||
* Absent when the rejection is attributed to the API user/token itself.
|
||||
*/
|
||||
onBehalfOfUserEmail: z.string().optional(),
|
||||
onBehalfOfUserName: z.string().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -509,11 +509,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
user: msg`${user} completed their task`,
|
||||
}));
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
|
||||
if (data.isExternal) {
|
||||
const onBehalfOf = data.onBehalfOfUserName || data.onBehalfOfUserEmail;
|
||||
|
||||
if (onBehalfOf) {
|
||||
return {
|
||||
anonymous: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
|
||||
you: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`,
|
||||
user: msg`The document was rejected externally by ${onBehalfOf} on behalf of ${user}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous: msg`Recipient rejected the document externally`,
|
||||
you: msg`The document was rejected externally on behalf of the recipient`,
|
||||
user: msg`The document was rejected externally on behalf of ${user}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
anonymous: msg`Recipient rejected the document`,
|
||||
you: msg`You rejected the document`,
|
||||
user: msg`${user} rejected the document`,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a 2FA token for the document`,
|
||||
you: msg`You requested a 2FA token for the document`,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Utilities for detecting overlapping fields in the envelope editor.
|
||||
*
|
||||
* Fields can be unintentionally placed on top of each other during the authoring
|
||||
* process. This does not render well in the editor and behaves unpredictably during
|
||||
* signing (fields can sit on top of one another depending on their state), so we warn
|
||||
* the user when a significant overlap is detected.
|
||||
*
|
||||
* All positional values are expected as percentages (0-100) of the page dimensions,
|
||||
* matching how fields are stored in the editor and database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The minimum proportion (0-1) of the smaller field's area that must be covered by
|
||||
* another field for the pair to be considered an "overlap" worth warning about.
|
||||
*
|
||||
* A small amount of overlap (e.g. touching edges) is common and harmless, so we only
|
||||
* flag pairs where one field covers at least this fraction of the other.
|
||||
*/
|
||||
export const FIELD_OVERLAP_THRESHOLD = 0.4;
|
||||
|
||||
type OverlapFieldInput = {
|
||||
/**
|
||||
* A stable identifier used to reference the field in the returned pairs.
|
||||
* Use the client-side `formId` in the editor, or the database `id` elsewhere.
|
||||
*/
|
||||
id: string | number;
|
||||
envelopeItemId: string;
|
||||
page: number;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type TFieldOverlapPair<T extends OverlapFieldInput> = {
|
||||
fieldA: T;
|
||||
fieldB: T;
|
||||
/**
|
||||
* The proportion (0-1) of the smaller field's area covered by the intersection.
|
||||
*/
|
||||
overlapRatio: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the area of the intersection between two fields, in squared percentage units.
|
||||
*
|
||||
* Returns 0 when the fields do not intersect.
|
||||
*/
|
||||
const getIntersectionArea = (fieldA: OverlapFieldInput, fieldB: OverlapFieldInput): number => {
|
||||
const overlapX = Math.max(
|
||||
0,
|
||||
Math.min(fieldA.positionX + fieldA.width, fieldB.positionX + fieldB.width) -
|
||||
Math.max(fieldA.positionX, fieldB.positionX),
|
||||
);
|
||||
|
||||
const overlapY = Math.max(
|
||||
0,
|
||||
Math.min(fieldA.positionY + fieldA.height, fieldB.positionY + fieldB.height) -
|
||||
Math.max(fieldA.positionY, fieldB.positionY),
|
||||
);
|
||||
|
||||
return overlapX * overlapY;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects pairs of fields that overlap by at least the given threshold.
|
||||
*
|
||||
* Two fields are only compared when they share the same envelope item and page.
|
||||
* The overlap ratio is measured against the smaller of the two fields, so a small
|
||||
* field that is mostly covered by a large field is still flagged.
|
||||
*
|
||||
* @param fields The fields to check. Positional values must be percentages (0-100).
|
||||
* @param threshold The minimum overlap ratio (0-1) to flag. Defaults to {@link FIELD_OVERLAP_THRESHOLD}.
|
||||
*/
|
||||
export const getOverlappingFieldPairs = <T extends OverlapFieldInput>(
|
||||
fields: T[],
|
||||
threshold: number = FIELD_OVERLAP_THRESHOLD,
|
||||
): TFieldOverlapPair<T>[] => {
|
||||
const pairs: TFieldOverlapPair<T>[] = [];
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
for (let j = i + 1; j < fields.length; j++) {
|
||||
const fieldA = fields[i];
|
||||
const fieldB = fields[j];
|
||||
|
||||
if (fieldA.envelopeItemId !== fieldB.envelopeItemId || fieldA.page !== fieldB.page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldAArea = fieldA.width * fieldA.height;
|
||||
const fieldBArea = fieldB.width * fieldB.height;
|
||||
|
||||
if (fieldAArea <= 0 || fieldBArea <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intersectionArea = getIntersectionArea(fieldA, fieldB);
|
||||
|
||||
if (intersectionArea <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapRatio = intersectionArea / Math.min(fieldAArea, fieldBArea);
|
||||
|
||||
if (overlapRatio >= threshold) {
|
||||
pairs.push({ fieldA, fieldB, overlapRatio });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if any pair of fields overlaps by at least the given threshold.
|
||||
*/
|
||||
export const hasOverlappingFields = <T extends OverlapFieldInput>(
|
||||
fields: T[],
|
||||
threshold: number = FIELD_OVERLAP_THRESHOLD,
|
||||
): boolean => {
|
||||
return getOverlappingFieldPairs(fields, threshold).length > 0;
|
||||
};
|
||||
Reference in New Issue
Block a user