Merge branch 'main' into feature/pdf-placeholder-selection-fields

This commit is contained in:
Catalin Pit
2026-06-17 11:50:50 +03:00
committed by GitHub
357 changed files with 24913 additions and 2969 deletions
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -79,7 +78,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -108,7 +107,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,5 +1,6 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Prisma } from '@prisma/client';
import { z } from 'zod';
import type { FindResultResponse } from '../../types/search-params';
@@ -9,6 +10,16 @@ export interface AdminFindDocumentsOptions {
perPage?: number;
}
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
const emptyResponse = {
data: [],
count: 0,
currentPage: 1,
perPage: 10,
totalPages: 0,
};
export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => {
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
? undefined
@@ -19,7 +30,35 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
},
};
if (query && query.startsWith('envelope_')) {
if (query?.startsWith('user:')) {
const parsedUserId = ZPositiveIntegerSchema.safeParse(query.slice('user:'.length));
if (parsedUserId.success) {
termFilters = {
userId: {
equals: parsedUserId.data,
},
};
} else {
return emptyResponse;
}
}
if (query?.startsWith('team:')) {
const parsedTeamId = ZPositiveIntegerSchema.safeParse(query.slice('team:'.length));
if (parsedTeamId.success) {
termFilters = {
teamId: {
equals: parsedTeamId.data,
},
};
} else {
return emptyResponse;
}
}
if (query && query?.startsWith('envelope_')) {
termFilters = {
id: {
equals: query,
@@ -27,7 +66,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
};
}
if (query && query.startsWith('document_')) {
if (query && query?.startsWith('document_')) {
termFilters = {
secondaryId: {
equals: query,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -45,7 +44,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
const { branding, settings, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -89,7 +88,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -10,6 +10,7 @@ export type OrganisationSummary = {
activeDocuments: number;
completedDocuments: number;
volumeThisPeriod: number;
documentsThisPeriod: number;
volumeAllTime: number;
};
@@ -305,6 +306,10 @@ async function getOrganisationSummary(
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)`
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`
).as('volumeThisPeriod'),
(createdAtFrom
? sql<number>`count(case when e."createdAt" >= ${createdAtFrom} then 1 end)`
: sql<number>`count(e.id)`
).as('documentsThisPeriod'),
])
.executeTakeFirst();
@@ -321,6 +326,7 @@ async function getOrganisationSummary(
activeDocuments: Number(envelopeStats?.activeDocuments || 0),
completedDocuments: Number(envelopeStats?.completedDocuments || 0),
volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0),
documentsThisPeriod: Number(envelopeStats?.documentsThisPeriod || 0),
volumeAllTime: Number(envelopeStats?.volumeAllTime || 0),
};
}
@@ -32,9 +32,12 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
throw new Error('User not found');
}
const token = user.passwordResetTokens[0].token;
const token = user.passwordResetTokens[0]?.token;
if (!token) {
throw new Error('Password reset token not found for user');
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
@@ -8,7 +8,10 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
export type CreateDocumentMetaOptions = {
userId: number;
@@ -73,6 +76,22 @@ export const updateDocumentMeta = async ({
});
}
await assertEnvelopeMutable(envelope);
if (signingOrder !== undefined) {
assertCompatibleSigningOrder({
signatureLevel: envelope.signatureLevel,
signingOrder,
});
}
if (allowDictateNextSigner !== undefined) {
assertCompatibleDictateNextSigner({
signatureLevel: envelope.signatureLevel,
allowDictateNextSigner,
});
}
const { documentMeta: originalDocumentMeta } = envelope;
// Validate the emailId belongs to the organisation.
@@ -92,6 +111,8 @@ export const updateDocumentMeta = async ({
}
return await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const upsertedDocumentMeta = await tx.documentMeta.update({
where: {
id: envelope.documentMetaId,
@@ -1,9 +1,8 @@
import { mailer } from '@documenso/email/mailer';
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, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
@@ -126,7 +125,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -187,14 +186,20 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
if (!isEnvelopeDeleteEmailEnabled) {
// Skip sending if the email is disabled for this document or the organisation
// has email sending disabled entirely.
if (!isEnvelopeDeleteEmailEnabled || emailsDisabled) {
return deletedEnvelope;
}
// Send cancellation emails to recipients.
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientEmailValidForSending(recipient) ||
recipient.role === RecipientRole.CC
) {
return;
}
@@ -218,7 +223,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -1,7 +1,7 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError } from '@documenso/lib/errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -26,8 +26,11 @@ import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
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 { assertUserNotDisabled } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
@@ -47,9 +50,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
id: true,
email: true,
name: true,
disabled: true,
},
});
// Refuse to resend on behalf of a disabled account. Guards
// document.redistribute / envelope.redistribute and the API v1 equivalent.
assertUserNotDisabled(user);
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -66,6 +74,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
select: {
teamEmail: true,
name: true,
organisation: {
select: {
organisationClaim: {
select: {
recipientCount: true,
},
},
},
},
},
},
},
@@ -87,6 +104,19 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
throw new Error('Can not send completed document');
}
// A recipientCount of 0 means unlimited recipients are allowed. Block resending
// when the document has more recipients than the organisation is allowed to send
// to, mirroring the check in `sendDocument`. This prevents bypassing the limit by
// adding recipients to an already-sent document and then resending.
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
statusCode: 400,
});
}
// Refresh the expiresAt on each resent recipient.
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
@@ -120,7 +150,17 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
const {
branding,
emailLanguage,
organisationType,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -129,6 +169,19 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
meta: envelope.documentMeta,
});
// Don't resend any emails if the organisation has email sending disabled.
if (user.disabled || emailsDisabled) {
return envelope;
}
// Assert that there is enough quota to send the emails.
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
count: recipientsToRemind.length,
type: 'email',
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
@@ -171,6 +224,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title,
@@ -186,6 +240,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
selfSigner,
organisationType,
teamName: envelope.team?.name,
reportUrl,
});
const [html, text] = await Promise.all([
@@ -202,7 +257,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name,
@@ -214,6 +269,11 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
: emailSubject,
html,
text,
headers: buildEnvelopeEmailHeaders({
userId: envelope.userId,
envelopeId: envelope.id,
teamId: envelope.teamId,
}),
});
await prisma.documentAuditLog.create({
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -46,7 +45,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -76,7 +75,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name: name || '',
@@ -1,3 +1,4 @@
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -29,6 +30,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { isTspEnvelope } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -39,6 +41,7 @@ import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = {
@@ -50,6 +53,11 @@ export type SendDocumentOptions = {
};
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
// Refuse to send on behalf of a disabled account. Guards distribute /
// redistribute / template-use routes, the bulk-send job, and direct
// templates that auto-send on creation.
await assertUserNotDisabledById({ userId });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -78,6 +86,19 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
},
},
},
team: {
select: {
organisation: {
select: {
organisationClaim: {
select: {
recipientCount: true,
},
},
},
},
},
},
},
});
@@ -89,13 +110,42 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
throw new Error('Document has no recipients');
}
// A recipientCount of 0 means unlimited recipients are allowed.
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
statusCode: 400,
});
}
if (isDocumentCompleted(envelope.status)) {
throw new Error('Can not send completed document');
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) {
console.warn(
`[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
);
await prisma.documentMeta.update({
where: {
id: envelope.documentMeta.id,
},
data: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
signingOrder = DocumentSigningOrder.SEQUENTIAL;
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
}
let recipientsToNotify = envelope.recipients;
@@ -110,7 +160,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
throw new Error('Missing envelope items');
}
if (envelope.formValues) {
if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) {
await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => {
await injectFormValuesIntoDocument(envelope, envelopeItem);
@@ -196,6 +246,12 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
}
}
if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) {
await materializeTspAnchorsForEnvelope({
envelopeId: envelope.id,
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -47,7 +46,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -56,6 +55,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
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) {
@@ -89,7 +93,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: email,
name,
@@ -0,0 +1,26 @@
export type BuildEnvelopeEmailHeadersOptions = {
userId: number;
envelopeId: string;
teamId: number;
};
/**
* Builds opaque sender-attribution headers stamped onto outgoing user-triggered
* envelope emails. These appear in AWS SES bounce/complaint notifications (when
* "include original headers" is enabled) so an abusive send can be traced back
* to the originating Documenso user via the admin panel.
*
* Only opaque IDs are included so recipients cannot see the sender's email
* address or name in the delivered message source.
*/
export const buildEnvelopeEmailHeaders = ({
userId,
envelopeId,
teamId,
}: BuildEnvelopeEmailHeadersOptions): Record<string, string> => {
return {
'X-Documenso-Sender-User-Id': String(userId),
'X-Documenso-Envelope-Id': envelopeId,
'X-Documenso-Team-Id': String(teamId),
};
};
@@ -0,0 +1,107 @@
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { z } from 'zod';
/**
* Config keys that hold secret values across all transport types.
*
* Secrets are never sent back to the client, so on update an empty incoming
* value means "keep the existing secret". This list lets the update route know
* which fields to preserve when left blank.
*
* Keep in sync with the fields marked `Secret` in the schemas below.
*/
export const EMAIL_TRANSPORT_SECRET_KEYS = ['password', 'apiKey'] as const;
export const ZSmtpAuthConfigSchema = z.object({
type: z.literal('SMTP_AUTH'),
host: z.string().min(1),
port: z.number().int().positive(),
secure: z.boolean().default(false),
ignoreTLS: z.boolean().default(false),
username: z.string().optional(),
password: z.string().optional(), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
service: z.string().optional(),
});
export const ZSmtpApiConfigSchema = z.object({
type: z.literal('SMTP_API'),
host: z.string().min(1),
port: z.number().int().positive(),
secure: z.boolean().default(false),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
apiKeyUser: z.string().optional(),
});
export const ZResendConfigSchema = z.object({
type: z.literal('RESEND'),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
});
export const ZMailChannelsConfigSchema = z.object({
type: z.literal('MAILCHANNELS'),
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
endpoint: z.string().optional(),
});
export const ZEmailTransportConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema,
ZSmtpApiConfigSchema,
ZResendConfigSchema,
ZMailChannelsConfigSchema,
]);
export type TEmailTransportConfig = z.infer<typeof ZEmailTransportConfigSchema>;
/**
* Non-secret view of a transport config (secret fields removed).
*
* Safe to return to the client so the edit form can pre-fill the connection
* settings without exposing secrets.
*/
export const ZEmailTransportPublicConfigSchema = z.discriminatedUnion('type', [
ZSmtpAuthConfigSchema.omit({ password: true }),
ZSmtpApiConfigSchema.omit({ apiKey: true }),
ZResendConfigSchema.omit({ apiKey: true }),
ZMailChannelsConfigSchema.omit({ apiKey: true }),
]);
export type TEmailTransportPublicConfig = z.infer<typeof ZEmailTransportPublicConfigSchema>;
/**
* Strips secret fields (see EMAIL_TRANSPORT_SECRET_KEYS) from a transport
* config, returning only the non-secret connection settings.
*/
export const toPublicEmailTransportConfig = (config: TEmailTransportConfig): TEmailTransportPublicConfig => {
const publicConfig: Record<string, unknown> = { ...config };
for (const key of EMAIL_TRANSPORT_SECRET_KEYS) {
delete publicConfig[key];
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return publicConfig as TEmailTransportPublicConfig;
};
export const encryptEmailTransportConfig = (config: TEmailTransportConfig): string => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
return symmetricEncrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: JSON.stringify(config),
});
};
export const decryptEmailTransportConfig = (encrypted: string): TEmailTransportConfig => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const decrypted = Buffer.from(
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, data: encrypted }),
).toString('utf-8');
return ZEmailTransportConfigSchema.parse(JSON.parse(decrypted));
};
@@ -1,3 +1,4 @@
import { mailer } from '@documenso/email/mailer';
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type {
@@ -8,15 +9,18 @@ import type {
OrganisationType,
} from '@documenso/prisma/client';
import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import type { Transporter } from 'nodemailer';
import { match, P } from 'ts-pattern';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { extractDerivedTeamSettings } from '../../utils/teams';
import { resolveEmailTransport } from './resolve-email-transport';
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
@@ -66,7 +70,15 @@ export type EmailContextResponse = {
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
/**
* Whether the organisation is prevented from sending emails.
*
* When true, ALL emails sent on behalf of this organisation must be skipped.
*/
emailsDisabled: boolean;
organisationId: string;
organisationType: OrganisationType;
emailTransport: Transporter;
senderEmail: {
name: string;
address: string;
@@ -78,7 +90,7 @@ export type EmailContextResponse = {
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage' | 'emailTransport'>;
if (source.type === 'organisation') {
emailContext = await handleOrganisationEmailContext(source.organisationId);
@@ -88,13 +100,45 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
const transportResolution = emailContext.claims.emailTransportId
? await resolveEmailTransport(emailContext.claims.emailTransportId)
: null;
// A configured transport that fails to resolve is an operational problem, not
// "no transport". Surface it (alertable) before silently falling back to the
// system mailer + Documenso sender, so the degraded organisation is findable.
if (emailContext.claims.emailTransportId && !transportResolution) {
// Todo: Logging
logger.error({
msg: 'Configured email transport could not be resolved; falling back to the system mailer',
emailTransportId: emailContext.claims.emailTransportId,
organisationId: emailContext.organisationId,
});
}
const resolvedTransportData = transportResolution
? {
name: transportResolution.row.fromName,
address: transportResolution.row.fromAddress,
transport: transportResolution.transporter,
}
: {
name: DOCUMENSO_INTERNAL_EMAIL.name,
address: DOCUMENSO_INTERNAL_EMAIL.address,
transport: mailer,
};
// Immediate return for internal emails.
if (options.emailType === 'INTERNAL') {
return {
...emailContext,
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
emailTransport: resolvedTransportData.transport,
senderEmail: {
name: resolvedTransportData.name,
address: resolvedTransportData.address,
},
replyToEmail: undefined,
emailLanguage, // Not sure if we want to use this for internal emails.
emailLanguage,
};
}
@@ -113,16 +157,29 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
emailContext.settings.emailId = null;
}
const senderEmail = foundSenderEmail
? {
// Custom-domain sender (emailDomains): always use the env mailer (SES) and the
// custom sender; the per-plan transport is ignored entirely here.
if (foundSenderEmail) {
return {
...emailContext,
emailTransport: mailer,
senderEmail: {
name: foundSenderEmail.emailName,
address: foundSenderEmail.email,
}
: DOCUMENSO_INTERNAL_EMAIL;
},
replyToEmail,
emailLanguage,
};
}
// No custom-domain sender → per-plan transport (if any) supplies transport + from-address.
return {
...emailContext,
senderEmail,
emailTransport: resolvedTransportData.transport,
senderEmail: {
name: resolvedTransportData.name,
address: resolvedTransportData.address,
},
replyToEmail,
emailLanguage,
};
@@ -134,6 +191,11 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
id: organisationId,
},
include: {
owner: {
select: {
disabled: true,
},
},
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
@@ -164,6 +226,8 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
),
settings: organisation.organisationGlobalSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -177,6 +241,12 @@ const handleTeamEmailContext = async (teamId: number) => {
teamGlobalSettings: true,
organisation: {
include: {
owner: {
select: {
id: true,
disabled: true,
},
},
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
@@ -208,6 +278,8 @@ const handleTeamEmailContext = async (teamId: number) => {
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
settings: teamSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -0,0 +1,42 @@
import { buildTransport } from '@documenso/email/transports/build-transport';
import { prisma } from '@documenso/prisma';
import type { EmailTransport } from '@documenso/prisma/client';
import type { Transporter } from 'nodemailer';
import { logger } from '../../utils/logger';
import { decryptEmailTransportConfig } from './email-transport-config';
export type ResolvedEmailTransport = {
row: EmailTransport;
transporter: Transporter;
};
/**
* Loads an EmailTransport row, decrypts its config and builds a nodemailer
* Transporter. Returns null when the id does not resolve or the stored config
* cannot be decrypted/built (caller should fall back to the env mailer).
*/
export const resolveEmailTransport = async (emailTransportId: string): Promise<ResolvedEmailTransport | null> => {
const row = await prisma.emailTransport.findUnique({
where: { id: emailTransportId },
});
if (!row) {
return null;
}
try {
const config = decryptEmailTransportConfig(row.config);
const transporter = buildTransport(config);
return { row, transporter };
} catch (err) {
// Todo: Logging
logger.error({
msg: 'Failed to decrypt or build the configured email transport',
err,
emailTransportId,
});
return null;
}
};
@@ -22,7 +22,7 @@ export const createEmbeddingPresignToken = async ({
}: CreateEmbeddingPresignTokenOptions) => {
try {
// Validate the API token
const validatedToken = await getApiTokenByToken({ token: apiToken });
const validatedToken = await getApiTokenByToken({ token: apiToken, bypassRateLimit: true });
const now = DateTime.now();
@@ -6,6 +6,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { prisma } from '@documenso/prisma';
import type { Envelope, Field, Recipient } from '@prisma/client';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
@@ -96,6 +97,8 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const updatedItem = await tx.envelopeItem.update({
where: {
id: envelopeItemId,
@@ -0,0 +1,81 @@
import { DocumentStatus, type Envelope, type Prisma } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type EnvelopeMutableSnapshot = {
signatureLevel: string;
status: DocumentStatus;
};
type EnvelopeIdRef = Pick<Envelope, 'id'>;
/**
* Reject authoring mutations on an AES/QES envelope past DRAFT.
*
* The TSP mutation lock fires at distribution so the owner cannot replace the
* PDF between a recipient completing service-scope OAuth (against PDF_v1) and
* clicking Sign (now against PDF_v2). The SAD would authorise PDF_v2's digest
* while the recipient viewed PDF_v1 — a WYSIWYS break.
*
* SES envelopes pass through unchanged. The existing per-route guards still
* enforce COMPLETED/REJECTED rejection for them.
*
* Call this **twice** at every TSP-eligible authoring route:
*
* 1. Outside the transaction with the pre-fetched envelope snapshot —
* `assertEnvelopeMutable(envelope)` — fast-fail without a DB round-trip.
* 2. Inside the transaction with `tx` — `assertEnvelopeMutable(envelope, tx)`
* — re-fetches under the transaction's snapshot, closing the TOCTOU
* window against a concurrent `sendDocument` committing DRAFT → PENDING
* between the snapshot read and the mutation.
*
* Throws:
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
* the TSP lock — SES routes happily allow PENDING).
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` for those terminal states, to
* stay consistent with the existing envelope-state error vocabulary.
*/
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
export async function assertEnvelopeMutable(
envelope: EnvelopeMutableSnapshot | EnvelopeIdRef,
tx?: Prisma.TransactionClient,
): Promise<void> {
if (tx) {
return await refetchAndAssert(tx, (envelope as EnvelopeIdRef).id);
}
assertSnapshotMutable(envelope as EnvelopeMutableSnapshot);
}
const refetchAndAssert = async (tx: Prisma.TransactionClient, envelopeId: string): Promise<void> => {
const refetched = await tx.envelope.findFirstOrThrow({
where: { id: envelopeId },
select: { signatureLevel: true, status: true },
});
assertSnapshotMutable(refetched);
};
const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
if (!isTspEnvelope(envelope)) {
return;
}
if (envelope.status === DocumentStatus.DRAFT) {
return;
}
const errorCode = match(envelope.status)
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
message: `Envelope is locked — AES/QES envelopes cannot be modified after leaving DRAFT (current status: ${envelope.status}).`,
});
};
@@ -29,6 +29,7 @@ import type {
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import type { TSignatureLevel } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -36,7 +37,11 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
@@ -87,6 +92,7 @@ export type CreateEnvelopeOptions = {
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
delegatedDocumentOwner?: string;
signatureLevel?: TSignatureLevel;
};
attachments?: Array<{
label: string;
@@ -116,6 +122,11 @@ export const createEnvelope = async ({
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
// Refuse to create on behalf of a disabled account. Guards every route that
// funnels through here (document.create, envelope.use, template create,
// embedding template/document create, API v1) and the seed/job paths.
await assertUserNotDisabledById({ userId });
const {
type,
title,
@@ -130,8 +141,14 @@ export const createEnvelope = async ({
publicDescription,
visibility: visibilityOverride,
delegatedDocumentOwner,
signatureLevel: requestedSignatureLevel,
} = data;
const signatureLevel = resolveSignatureLevel({
requested: requestedSignatureLevel,
strict: true,
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
@@ -149,6 +166,17 @@ export const createEnvelope = async ({
});
}
// Enforce the organisation document-creation limit before doing any work.
// Only documents count towards the limit (templates are exempt).
if (type === EnvelopeType.DOCUMENT) {
await assertOrganisationRatesAndLimits({
organisationId: team.organisationId,
organisationClaim: team.organisation.organisationClaim,
type: 'document',
count: 1,
});
}
// Verify that the folder exists and is associated with the team.
if (folderId) {
const folder = await prisma.folder.findUnique({
@@ -177,6 +205,17 @@ export const createEnvelope = async ({
});
}
// CSC / TSP signing flows assume the V2 envelope shape: per-recipient
// anchors, materialised PDF lineage, sequential signing, mutation lock.
// The legacy V1 (Document) model can't carry that state, so AES/QES on V1
// is structurally unsupported and must fail at create time — not later at
// sign or seal time when the cause is harder to attribute.
if (signatureLevel !== 'SES' && internalVersion === 1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' require internalVersion=2; the legacy V1 envelope shape cannot host TSP signing.`,
});
}
let envelopeItems = data.envelopeItems;
// Todo: Envelopes - Remove
@@ -237,6 +276,10 @@ export const createEnvelope = async ({
});
}
for (const recipient of data.recipients ?? []) {
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
}
const visibility = visibilityOverride || settings.documentVisibility;
const emailId = meta?.emailId;
@@ -293,10 +336,14 @@ export const createEnvelope = async ({
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
...meta,
timezone: timezoneToUse,
}),
data: extractDerivedDocumentMeta(
settings,
{
...meta,
timezone: timezoneToUse,
},
signatureLevel,
),
}),
type === EnvelopeType.DOCUMENT
? incrementDocumentId().then((v) => v.formattedDocumentId)
@@ -313,6 +360,7 @@ export const createEnvelope = async ({
internalVersion,
type,
title,
signatureLevel,
qrToken: prefixedId('qr'),
externalId,
envelopeItems: {
@@ -4,11 +4,14 @@ import pMap from 'p-map';
import { omit } from 'remeda';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export interface DuplicateEnvelopeOptions {
@@ -25,7 +28,7 @@ export interface DuplicateEnvelopeOptions {
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
const { envelopeWhereInput } = await getEnvelopeWhereInput({
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
id,
type: null,
userId,
@@ -39,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
title: true,
userId: true,
internalVersion: true,
signatureLevel: true,
templateType: true,
publicTitle: true,
publicDescription: true,
@@ -83,6 +87,15 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
// Enforce the organisation document-creation limit before creating the duplicate.
if (targetType === EnvelopeType.DOCUMENT) {
await assertOrganisationRatesAndLimits({
organisationId: team.organisationId,
type: 'document',
count: 1,
});
}
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
targetType === EnvelopeType.DOCUMENT
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
@@ -106,12 +119,21 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
? 'PRIVATE'
: (envelope.templateType ?? undefined);
// The source level is a free-form TEXT column — parse defensively before
// handing to the resolver. Coerce (not strict) because instance mode may have
// changed since the source envelope was created.
const duplicatedSignatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(envelope.signatureLevel),
strict: false,
});
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
type: targetType,
internalVersion: envelope.internalVersion,
signatureLevel: duplicatedSignatureLevel,
userId,
teamId,
title: envelope.title + ' (copy)',
@@ -36,6 +36,7 @@ export const ZEnvelopeForSigningResponse = z.object({
authOptions: true,
userId: true,
teamId: true,
signatureLevel: true,
}).extend({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
@@ -15,7 +15,10 @@ import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../uti
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { assertEnvelopeMutable } from './assert-envelope-mutable';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = {
@@ -76,6 +79,22 @@ export const updateEnvelope = async ({
});
}
assertEnvelopeMutable(envelope);
if (meta.signingOrder !== undefined) {
assertCompatibleSigningOrder({
signatureLevel: envelope.signatureLevel,
signingOrder: meta.signingOrder,
});
}
if (meta.allowDictateNextSigner !== undefined) {
assertCompatibleDictateNextSigner({
signatureLevel: envelope.signatureLevel,
allowDictateNextSigner: meta.allowDictateNextSigner,
});
}
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the template fields for document type envelopes',
@@ -297,6 +316,8 @@ export const updateEnvelope = async ({
// }
const updatedEnvelope = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const result = await tx.envelope.update({
where: {
id: envelope.id,
@@ -12,6 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
@@ -93,6 +94,8 @@ export const createEnvelopeFields = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -242,6 +245,8 @@ export const createEnvelopeFields = async ({
});
const createdFields = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
@@ -6,6 +6,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentFieldOptions {
@@ -59,6 +60,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -81,6 +84,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
}
return await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const deletedField = await tx.field.delete({
where: {
id: fieldId,
@@ -9,6 +9,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateEnvelopeFieldsOptions {
@@ -60,6 +61,8 @@ export const updateEnvelopeFields = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -115,6 +118,8 @@ export const updateEnvelopeFields = async ({
});
const updatedFields = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
const updatedField = await tx.field.update({
@@ -0,0 +1,69 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { LicenseFlag, TCachedLicense } from '../../types/license';
import { env } from '../../utils/env';
import { LicenseClient } from './license-client';
type AssertLicensedForOptions = {
/**
* Override the AppError code thrown when the assertion fails.
*
* Defaults to `AppErrorCode.FORBIDDEN`. Callers that need a more specific
* surface — for example the CSC transport throwing `CSC_UNLICENSED` at
* transport-create time — pass their own code here.
*/
errorCode?: string;
/**
* Override the AppError message thrown when the assertion fails.
*/
message?: string;
};
/**
* Assert the configured Documenso licence grants `flag`. Reads the
* {@link LicenseClient} cache; never re-pings the licence server.
*
* - No `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` → throws. No licensing intent.
* - Key set, claim unverifiable (no client, null cache, read throws,
* `license: null`) → passes. Mirrors how org-claim gates keep running on
* last known state when the licence server is unreachable; paying
* operators shouldn't be locked out by transient infra.
* - Key set, claim loaded and denies the flag (bad standing or flag falsy)
* → throws.
*/
export const assertLicensedFor = async (flag: LicenseFlag, options?: AssertLicensedForOptions): Promise<void> => {
const denied = (): AppError =>
new AppError(options?.errorCode ?? AppErrorCode.FORBIDDEN, {
message: options?.message ?? `License does not include the "${flag}" feature.`,
});
// No licence key configured = no licensing intent. Fail closed unconditionally
// so unlicensed instances cannot reach gated features simply because the
// licence cache is empty.
if (!env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY')) {
throw denied();
}
let cached: TCachedLicense | null = null;
const licenseClient = LicenseClient.getInstance();
if (licenseClient) {
cached = await licenseClient?.getCachedLicense().catch(() => null);
}
// Licence key is configured but we have no positively-verified claim to
// check. Fail-open — see block comment for the full set of conditions and
// rationale.
if (!cached?.license) {
return;
}
const inGoodStanding = cached.derivedStatus === 'ACTIVE' || cached.derivedStatus === 'PAST_DUE';
const flagGranted = Boolean(cached.license.flags[flag]);
if (!inGoodStanding || !flagGranted) {
throw denied();
}
};
@@ -2,7 +2,6 @@ import {
assertMemberCountWithinCap,
syncMemberCountWithStripeSeatPlan,
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
@@ -187,7 +186,7 @@ export const sendOrganisationMemberInviteEmail = async ({
organisationName: organisation.name,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -195,6 +194,12 @@ export const sendOrganisationMemberInviteEmail = async ({
},
});
// Member invites can be sent to anyone, so block them when the organisation has email
// sending disabled.
if (emailsDisabled) {
return;
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: emailLanguage,
@@ -209,7 +214,7 @@ export const sendOrganisationMemberInviteEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
@@ -1,12 +1,12 @@
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberRole, OrganisationType, Prisma } from '@prisma/client';
import { OrganisationMemberRole, OrganisationType, Prisma, type SubscriptionClaim } from '@prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { InternalClaim } from '../../types/subscription';
import { INTERNAL_CLAIM_ID, internalClaims } from '../../types/subscription';
import { INTERNAL_CLAIM_ID } from '../../types/subscription';
import { generateDatabaseId, prefixedId } from '../../universal/id';
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
import { createTeam } from '../team/create-team';
@@ -17,7 +17,7 @@ type CreateOrganisationOptions = {
type: OrganisationType;
url?: string;
customerId?: string;
claim: InternalClaim;
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
};
export const createOrganisation = async ({ name, url, type, userId, customerId, claim }: CreateOrganisationOptions) => {
@@ -151,12 +151,14 @@ export const createPersonalOrganisation = async ({
inheritMembers = true,
type = OrganisationType.PERSONAL,
}: CreatePersonalOrganisationOptions) => {
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
const organisation = await createOrganisation({
name: 'Personal Organisation',
userId,
url: orgUrl,
type,
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
claim: freeSubscriptionClaim,
}).catch((err) => {
console.error(err);
@@ -184,16 +186,27 @@ export const createPersonalOrganisation = async ({
return organisation;
};
export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalClaim) => {
export const createOrganisationClaimUpsertData = (
subscriptionClaim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>,
) => {
// Done like this to ensure type errors are thrown if items are added.
const data: Omit<Prisma.SubscriptionClaimCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> = {
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
};
const data: Omit<Prisma.SubscriptionClaimUncheckedCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> =
{
flags: {
...subscriptionClaim.flags,
},
envelopeItemCount: subscriptionClaim.envelopeItemCount,
recipientCount: subscriptionClaim.recipientCount,
teamCount: subscriptionClaim.teamCount,
memberCount: subscriptionClaim.memberCount,
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
documentQuota: subscriptionClaim.documentQuota,
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
emailQuota: subscriptionClaim.emailQuota,
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
apiQuota: subscriptionClaim.apiQuota,
emailTransportId: subscriptionClaim.emailTransportId ?? null,
};
return {
...data,
@@ -5,6 +5,7 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import type { EmailContextResponse } from '../email/get-email-context';
@@ -12,7 +13,7 @@ export type SendOrganisationDeleteEmailOptions = {
email: string;
organisationName: string;
deletedByAdmin?: boolean;
emailContext: EmailContextResponse;
emailContext: Omit<EmailContextResponse, 'emailTransport'>;
};
/**
@@ -30,7 +31,7 @@ export const sendOrganisationDeleteEmail = async ({
deletedByAdmin,
});
const { branding, emailLanguage, senderEmail } = emailContext;
const { branding, emailLanguage } = emailContext;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
@@ -39,9 +40,13 @@ export const sendOrganisationDeleteEmail = async ({
const i18n = await getI18nInstance(emailLanguage);
// This is sent through the global Documenso mailer (the org's transport is
// intentionally not used during deletion), so use the Documenso sender to keep
// the From-address aligned with the sending infrastructure (SPF/DKIM). Note the
// org's `senderEmail` on `emailContext` could be a custom transport address.
await mailer.sendMail({
to: email,
from: senderEmail,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
html,
text,
@@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../constants/organisations';
import { jobs } from '../../jobs/client';
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
export type DeleteOrganisationOptions = {
organisation: {
id: string;
teams: { id: number }[];
subscription: { planId: string } | null;
};
};
/**
* Fully tears down an organisation:
*
* 1. Orphans every team's envelopes (so foreign key constraints don't block the delete).
* 2. Removes the organisation's account rows and the organisation itself in a transaction.
* 3. Schedules the Stripe subscription to be cancelled at the end of the billing period
* (when one exists). The job runs asynchronously so a Stripe outage doesn't block the
* delete, and is retried by the job runner if Stripe is temporarily unavailable.
*
* Authorization must be handled by the caller. This is the shared implementation used by
* the organisation delete route, the admin delete-organisation job, and account deletion.
*/
export const deleteOrganisation = async ({ organisation }: DeleteOrganisationOptions) => {
// Orphan all envelopes to get rid of foreign key constraints.
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
type: ORGANISATION_USER_ACCOUNT_TYPE,
provider: organisation.id,
},
});
await tx.organisation.delete({
where: {
id: organisation.id,
},
});
});
if (organisation.subscription) {
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId: organisation.subscription.planId,
organisationId: organisation.id,
},
});
}
};
@@ -2,8 +2,20 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { hashString } from '../auth/hash';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export const getApiTokenByToken = async ({ token }: { token: string }) => {
type GetApiTokenByTokenOptions = {
token: string;
/**
* Defaults to false.
*
* Will assert that the API request limit is not exceeded.
*/
bypassRateLimit?: boolean;
};
export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: GetApiTokenByTokenOptions) => {
const hashedToken = hashString(token);
const apiToken = await prisma.apiToken.findFirst({
@@ -15,6 +27,7 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
include: {
organisation: {
include: {
organisationClaim: true,
owner: {
select: {
id: true,
@@ -45,6 +58,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
});
}
if (apiToken.user?.disabled || apiToken.team.organisation.owner.disabled) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'User is disabled',
statusCode: 401,
});
}
if (apiToken.expires && apiToken.expires < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'Expired token',
@@ -52,6 +72,15 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
});
}
if (!bypassRateLimit) {
await assertOrganisationRatesAndLimits({
organisationId: apiToken.team.organisationId,
organisationClaim: apiToken.team.organisation.organisationClaim,
type: 'api',
count: 1,
});
}
// Handle a silly choice from many moons ago
if (apiToken.team && !apiToken.user) {
apiToken.user = apiToken.team.organisation.owner;
@@ -0,0 +1,89 @@
import { prisma } from '@documenso/prisma';
import type { OrganisationClaim } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZRateLimitArraySchema } from '../../types/subscription';
import { checkMonthlyQuota } from './check-monthly-quota';
import { checkOrganisationRateLimits } from './check-organisation-rate-limits';
import type { LimitCounter } from './types';
type AssertOrganisationRatesAndLimitsOptions = {
organisationId: string;
/**
* The organisation claim to use. If not provided, it will be loaded from the database.
*/
organisationClaim?: OrganisationClaim;
/**
* Units to reserve. Must be >= 0.
*/
count: number;
/**
* The type of rate limit to assert.
*/
type: LimitCounter;
};
export const assertOrganisationRatesAndLimits = async (
opts: AssertOrganisationRatesAndLimitsOptions,
): Promise<void> => {
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
return;
}
let { organisationClaim, count } = opts;
// Nothing to reserve, treat as a no-op.
if (count === 0) {
return;
}
if (count < 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Count must be greater than or equal to 0',
});
}
if (!organisationClaim) {
const organisation = await prisma.organisation.findUniqueOrThrow({
where: { id: opts.organisationId },
select: {
id: true,
organisationClaim: true,
},
});
organisationClaim = organisation.organisationClaim;
}
const { rateLimits, quota } = match(opts.type)
.with('api', () => ({
rateLimits: ZRateLimitArraySchema.parse(organisationClaim.apiRateLimits),
quota: organisationClaim.apiQuota,
}))
.with('document', () => ({
rateLimits: ZRateLimitArraySchema.parse(organisationClaim.documentRateLimits),
quota: organisationClaim.documentQuota,
}))
.with('email', () => ({
rateLimits: ZRateLimitArraySchema.parse(organisationClaim.emailRateLimits),
quota: organisationClaim.emailQuota,
}))
.exhaustive();
await checkOrganisationRateLimits({
organisationId: opts.organisationId,
counter: opts.type,
entries: rateLimits,
count,
});
await checkMonthlyQuota({
organisationId: opts.organisationId,
counter: opts.type,
quota,
count,
});
};
@@ -0,0 +1,101 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { currentMonthlyPeriod } from '../../universal/monthly-period';
import { getQuotaAlertKind } from './get-quota-alert-kind';
import type { LimitCounter } from './types';
type CheckMonthlyQuotaOptions = {
organisationId: string;
counter: LimitCounter;
quota: number | null;
count: number;
};
const COUNTER_COLUMN = {
document: 'documentCount',
email: 'emailCount',
api: 'apiCount',
} as const satisfies Record<LimitCounter, string>;
export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise<void> => {
if (opts.quota === 0) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message:
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
// Not tossing headers here to avoid confusion, this isn't rate limits.
});
}
const period = currentMonthlyPeriod();
const column = COUNTER_COLUMN[opts.counter];
const latestMonthlyStat = await prisma.organisationMonthlyStat.upsert({
where: {
organisationId_period: {
organisationId: opts.organisationId,
period,
},
},
update: {
[column]: { increment: opts.count },
},
create: {
id: generateDatabaseId('org_monthly_stat'),
organisationId: opts.organisationId,
period,
[column]: opts.count,
},
});
// For unlimited quotas, we still allow the request to send so we can collect the monthly stat.
if (opts.quota === null) {
return;
}
const newCount = latestMonthlyStat[column];
const previousCount = newCount - opts.count;
// Returns 'quota' on the single request that reached (or jumped past) the quota,
// 'quotaNearing' on the single request that reached the warning threshold,
// otherwise null. See getQuotaAlertKind for the exactly-once guarantee.
const alertKind = getQuotaAlertKind({
previousCount,
newCount,
quota: opts.quota,
});
// Trigger the alert before the over-quota check — the 'quota' alert usually fires
// on the successful request that consumes the last unit of allowance, but when a
// batch jumps past the boundary it fires on this rejected request. Either way it
// will never fire again this period, so it must be enqueued before any throw.
if (alertKind) {
await jobsClient
.triggerJob({
name: 'send.organisation-limit-alert.email',
payload: {
organisationId: opts.organisationId,
counter: opts.counter,
kind: alertKind,
period,
},
})
.catch((error) => {
console.error({
msg: 'Failed to send organisation limit alert email',
error,
});
// Do nothing.
});
}
if (newCount > opts.quota) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message:
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
// Not tossing headers here to avoid confusion, this isn't rate limits.
});
}
};
@@ -0,0 +1,48 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRateLimitArray } from '../../types/subscription';
import { createRateLimit } from './rate-limit';
import type { LimitCounter, RateLimitEntry } from './types';
type CheckOrganisationRateLimitsOptions = {
organisationId: string;
counter: LimitCounter;
entries: TRateLimitArray;
count: number;
};
/**
* Enforce an organisation's windowed rate limits.
*
* Each window is checked against a bucketed counter keyed to the organisation.
* `count` units are consumed per check (e.g. a batch of reminder emails).
*/
export const checkOrganisationRateLimits = async (opts: CheckOrganisationRateLimitsOptions): Promise<void> => {
for (const entry of opts.entries) {
// Zod has validated the window against /^\d+[smhd]$/, which matches WindowStr.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const window = entry.window as RateLimitEntry['window'];
const limiter = createRateLimit({
action: `org.${opts.counter}.${window}`,
max: entry.max,
window,
});
// There's no real IP, so we just use the organisation ID as a key.
const result = await limiter.check({ ip: `org:${opts.organisationId}`, count: opts.count });
if (result.isLimited) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
// Note: Update the organisation-rate-limits.spec.ts message if you change this value.
// Used in the test to differentiate between the global and organisation rate limits.
message: 'Too many requests, please try again later. Contact support if you require higher limits.',
headers: {
'X-RateLimit-Limit': String(entry.max),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
'Retry-After': String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
},
});
}
}
};
@@ -0,0 +1,67 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
isEmailQuotaExceeded: boolean;
isApiQuotaExceeded: boolean;
isDocumentQuotaNearing: boolean;
isEmailQuotaNearing: boolean;
isApiQuotaNearing: boolean;
};
type ComputeQuotaFlagsOptions = {
quotas: {
documentQuota: number | null;
emailQuota: number | null;
apiQuota: number | null;
};
usage?: {
documentCount?: number;
emailCount?: number;
apiCount?: number;
};
};
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
return false;
}
if (quota === 0) {
return true;
}
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaExceeded: isQuotaExceeded(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaExceeded: isQuotaExceeded(quotas.apiQuota, usage?.apiCount ?? 0),
isDocumentQuotaNearing: isQuotaNearing(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaNearing: isQuotaNearing(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaNearing: isQuotaNearing(quotas.apiQuota, usage?.apiCount ?? 0),
};
};
@@ -0,0 +1,40 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
export type QuotaAlertKind = 'quota' | 'quotaNearing';
type GetQuotaAlertKindOptions = {
previousCount: number;
newCount: number;
quota: number;
};
/**
* Determines whether the request that moved the counter from `previousCount` to
* `newCount` crossed an alert threshold.
*
* - 'quota': this request reached (or jumped past) the monthly quota.
* - 'quotaNearing': this request reached the warning threshold (80% of quota).
* - null: no threshold crossed by this request.
*
* Precondition: callers must handle `quota === null` (unlimited) and `quota === 0`
* (blocked) before calling — this function assumes a positive quota.
*/
export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKind | null => {
const { previousCount, newCount, quota } = opts;
if (newCount >= quota) {
// Only the single request that reached the quota boundary should alert. If the
// same request also skipped past the warning threshold, the quota alert
// supersedes the warning.
return previousCount < quota ? 'quota' : null;
}
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
return didCrossWarning ? 'quotaNearing' : null;
};
@@ -15,6 +15,8 @@ type RateLimitConfig = {
type CheckParams = {
ip: string;
identifier?: string;
/** Number of units to consume in this check. Defaults to 1. */
count?: number;
};
export type RateLimitCheckResult = {
@@ -66,6 +68,7 @@ export const createRateLimit = (config: RateLimitConfig) => {
const bucket = getBucket(windowMs);
const reset = new Date(bucket.getTime() + windowMs);
const ipLimit = config.globalMax ?? config.max;
const count = params.count ?? 1;
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
return {
@@ -90,10 +93,10 @@ export const createRateLimit = (config: RateLimitConfig) => {
key: `ip:${params.ip}`,
action: config.action,
bucket,
count: 1,
count,
},
update: {
count: { increment: 1 },
count: { increment: count },
},
});
@@ -136,10 +139,10 @@ export const createRateLimit = (config: RateLimitConfig) => {
key: `id:${params.identifier}`,
action: config.action,
bucket,
count: 1,
count,
},
update: {
count: { increment: 1 },
count: { increment: count },
},
});
@@ -66,6 +66,20 @@ export const linkOrgAccountRateLimit = createRateLimit({
window: '1h',
});
export const reportSenderRateLimit = createRateLimit({
action: 'recipient.report-sender',
max: 1,
window: '7d',
});
// ---- Billing ----
export const syncSubscriptionRateLimit = createRateLimit({
action: 'billing.sync-subscription',
max: 10,
window: '15m',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
@@ -0,0 +1,6 @@
export type LimitCounter = 'api' | 'document' | 'email';
export type RateLimitEntry = {
window: `${number}${'s' | 'm' | 'h' | 'd'}`;
max: number;
};
@@ -10,7 +10,9 @@ import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface CreateEnvelopeRecipientsOptions {
userId: number;
@@ -63,6 +65,8 @@ export const createEnvelopeRecipients = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -80,12 +84,21 @@ export const createEnvelopeRecipients = async ({
});
}
for (const recipient of recipientsToCreate) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
@@ -1,10 +1,9 @@
import { mailer } from '@documenso/email/mailer';
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, SendStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
@@ -12,11 +11,14 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface DeleteEnvelopeRecipientOptions {
userId: number;
@@ -71,6 +73,8 @@ export const deleteEnvelopeRecipient = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -108,6 +112,8 @@ export const deleteEnvelopeRecipient = async ({
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -137,6 +143,7 @@ export const deleteEnvelopeRecipient = async ({
// Send email to deleted recipient.
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
recipientToDelete.role !== RecipientRole.CC &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
@@ -149,7 +156,16 @@ export const deleteEnvelopeRecipient = async ({
assetBaseUrl,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -158,6 +174,32 @@ export const deleteEnvelopeRecipient = async ({
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return deletedRecipient;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails
// outside the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipientToDelete.id,
envelopeId: envelope.id,
});
return deletedRecipient;
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
@@ -165,7 +207,7 @@ export const deleteEnvelopeRecipient = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
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';
@@ -19,10 +18,14 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface SetDocumentRecipientsOptions {
userId: number;
@@ -79,18 +82,21 @@ export const setDocumentRecipients = async ({
throw new Error('Document not found');
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
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,
@@ -103,6 +109,13 @@ export const setDocumentRecipients = async ({
});
}
for (const recipient of recipients) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -137,6 +150,8 @@ export const setDocumentRecipients = async ({
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
@@ -279,6 +294,7 @@ export const setDocumentRecipients = async ({
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
emailsDisabled ||
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientRemovedEmailEnabled ||
@@ -287,6 +303,26 @@ export const setDocumentRecipients = async ({
return;
}
// Meter against the organisation email quota/stats so add/remove churn
// can't be used to send unsolicited "removed" emails outside the limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
@@ -302,7 +338,7 @@ export const setDocumentRecipients = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export type SetTemplateRecipientsOptions = {
userId: number;
@@ -60,6 +61,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
});
}
for (const recipient of recipients) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipients.map((recipient) => {
// Force replace any changes to the name or email of the direct recipient.
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
@@ -12,7 +12,9 @@ import { extractLegacyIds } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
@@ -67,6 +69,8 @@ export const updateEnvelopeRecipients = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -84,6 +88,17 @@ export const updateEnvelopeRecipients = async ({
});
}
for (const recipient of recipients) {
if (recipient.role === undefined) {
continue;
}
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
@@ -106,6 +121,8 @@ export const updateEnvelopeRecipients = async ({
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
@@ -0,0 +1,35 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleDictateNextSignerOptions = {
signatureLevel: string;
allowDictateNextSigner: boolean | null | undefined;
};
/**
* Reject `allowDictateNextSigner = true` on AES/QES envelopes.
*
* The TSP sign path has no nextSigner dictation — `prepareCscRecipientSigning`
* doesn't accept one and `executeTspSign` always advances to the strict
* SEQUENTIAL next signer. Allowing the flag to persist on a TSP envelope
* would advertise a UX feature the sign-time flow silently drops.
*
* SES envelopes pass through unchanged. A `null` / `undefined` / `false`
* value also passes through.
*/
export const assertCompatibleDictateNextSigner = ({
signatureLevel,
allowDictateNextSigner,
}: AssertCompatibleDictateNextSignerOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (allowDictateNextSigner !== true) {
return;
}
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' do not support next-signer dictation — the TSP sign path always advances to the strict SEQUENTIAL next recipient.`,
});
};
@@ -0,0 +1,33 @@
import { RecipientRole } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleRecipientRoleOptions = {
signatureLevel: string;
role: RecipientRole;
};
/**
* Reject `RecipientRole.ASSISTANT` on AES/QES envelopes.
*
* Assistant recipients pre-fill fields on behalf of downstream signers. The
* TSP flow signs each recipient's complete PDF state with their own CSC
* credential, so an assistant role has no sign-time identity to bind to and
* `prepareCscRecipientSigning` has no handler for it.
*
* SES envelopes pass through unchanged.
*/
export const assertCompatibleRecipientRole = ({ signatureLevel, role }: AssertCompatibleRecipientRoleOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (role === RecipientRole.ASSISTANT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' do not support the ASSISTANT role — the TSP flow signs each recipient's bytes with their own CSC credential and has no sign-time path for an assistant.`,
});
}
return;
};
@@ -0,0 +1,41 @@
import { DocumentSigningOrder } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleSigningOrderOptions = {
signatureLevel: string;
signingOrder: DocumentSigningOrder | null | undefined;
};
/**
* Reject `signingOrder = PARALLEL` on AES/QES envelopes.
*
* Parallel signing produces conflicting incremental PDF updates over the
* same base state, breaking the per-recipient `/ByteRange` invariant that
* lets each TSP signature verify independently. Sequential is the only safe
* order for TSP-signed envelopes.
*
* SES envelopes pass through unchanged — PARALLEL remains the SES default.
* A `null` / `undefined` signingOrder also passes through (the create-envelope
* caller decides the default).
*
* Schema-layer guard. {@link sendDocument} re-coerces at distribution time
* as a defence-in-depth backstop.
*/
export const assertCompatibleSigningOrder = ({
signatureLevel,
signingOrder,
}: AssertCompatibleSigningOrderOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (signingOrder !== DocumentSigningOrder.PARALLEL) {
return;
}
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' require signingOrder=SEQUENTIAL — PARALLEL breaks the per-recipient /ByteRange invariant required for TSP signatures to verify independently.`,
});
};
@@ -0,0 +1,87 @@
import { CSC_INSTANCE_SIGNATURE_LEVEL, IS_INSTANCE_CSC_MODE } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { SignatureLevel, type TSignatureLevel } from '../../types/signature-level';
type ResolveSignatureLevelOptions = {
/**
* The signature level the caller wants the envelope created at. Optional;
* when omitted the resolver returns the instance-mode default (`SES` for
* non-CSC instances, `AES` for CSC instances).
*/
requested?: TSignatureLevel;
/**
* When `true`, a conflict between `requested` and the current instance mode
* throws `CSC_INSTANCE_MODE_MISMATCH` rather than being silently coerced.
* When `false` (default), the resolver coerces incompatible inputs to the
* instance default without throwing.
*
* Omitting `requested` is accepted in both modes — the resolver returns the
* instance default rather than throwing.
*
* Use `strict: true` at call sites that take the level from external input
* (e.g. a public API) where silent coercion would mask caller mistakes.
*/
strict?: boolean;
};
/**
* Resolve the signature level for a new envelope.
*
* Server-only. Reads the `NEXT_PRIVATE_SIGNING_TRANSPORT` env var via
* {@link IS_INSTANCE_CSC_MODE} so call sites do not have to thread the
* instance mode through their own arguments. On CSC instances the coerced
* default also reads {@link CSC_INSTANCE_SIGNATURE_LEVEL} so operators can
* pick `AES` (default) or `QES` per their TSP capability.
*
* Source of truth for the `Envelope.signatureLevel` write at create-time. The
* column has no DB default by design — every caller flows through here so the
* instance-mode contract is enforced consistently.
*
* Coerce mode (default, `strict: false`):
*
* | Instance | requested | Result |
* |----------|----------------|-------------------------------------|
* | non-CSC | omitted | `SES` |
* | non-CSC | `SES` | `SES` |
* | non-CSC | `AES` / `QES` | `SES` (coerced) |
* | CSC | omitted | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
* | CSC | `SES` | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
* | CSC | `AES` / `QES` | passes through |
*
* Strict mode (`strict: true`): same instance defaults for the omitted case,
* but any conflict between `requested` and the instance mode throws
* `CSC_INSTANCE_MODE_MISMATCH` instead of silently coercing.
*
* Note: on CSC instances an explicit `AES`/`QES` request always passes
* through, even when it disagrees with `CSC_INSTANCE_SIGNATURE_LEVEL`. The
* env var sets the *default* legal tier; it doesn't restrict what callers
* can ask for. Cert-capability checks live at the TSP boundary.
*/
export const resolveSignatureLevel = ({
requested,
strict = false,
}: ResolveSignatureLevelOptions = {}): TSignatureLevel => {
const isCscInstance = IS_INSTANCE_CSC_MODE();
const instanceDefault = isCscInstance ? CSC_INSTANCE_SIGNATURE_LEVEL() : SignatureLevel.SES;
if (requested === undefined) {
return instanceDefault;
}
const isCompatible = isCscInstance ? requested !== SignatureLevel.SES : requested === SignatureLevel.SES;
if (isCompatible) {
return requested;
}
if (strict) {
throw new AppError(AppErrorCode.CSC_INSTANCE_MODE_MISMATCH, {
message: isCscInstance
? `signatureLevel '${requested}' is not supported on a CSC-mode instance — every recipient must sign through the configured Trust Service Provider.`
: `signatureLevel '${requested}' is not supported on a non-CSC instance — only 'SES' is permitted unless the CSC signing transport is configured.`,
});
}
return instanceDefault;
};
@@ -0,0 +1,36 @@
import { DocumentSigningOrder } from '@prisma/client';
import { isTspEnvelope } from '../../types/signature-level';
import { assertCompatibleSigningOrder } from './assert-compatible-signing-order';
type ResolveSigningOrderOptions = {
signatureLevel: string;
requested?: DocumentSigningOrder | null;
};
/**
* Resolve the persisted `signingOrder` for a new envelope's meta.
*
* - Explicit `requested` value: validated via
* {@link assertCompatibleSigningOrder} (throws on TSP + `PARALLEL`) and
* returned as-is.
* - Omitted `requested`: returns the level-appropriate default —
* `SEQUENTIAL` for AES/QES (the TSP `/ByteRange` invariant requires it),
* `PARALLEL` for SES (preserves existing SES default behaviour).
*
* Use at every create-time call site instead of the bare `|| PARALLEL`
* fallback. Mirrors {@link resolveSignatureLevel} in shape — the two pair
* up to keep create-time defaulting + TSP-mode coercion uniform.
*/
export const resolveSigningOrder = ({
signatureLevel,
requested,
}: ResolveSigningOrderOptions): DocumentSigningOrder => {
if (requested) {
assertCompatibleSigningOrder({ signatureLevel, signingOrder: requested });
return requested;
}
return isTspEnvelope({ signatureLevel }) ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL;
};
@@ -0,0 +1,31 @@
import { prisma } from '@documenso/prisma';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID, ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
/**
* Returns the list of admin-configured email domains that should be treated as
* disposable / blocked, in addition to the bundled `mailchecker` list.
*
* Returns an empty array when the setting has not been configured, is
* disabled, or fails to parse — so a misconfigured setting can never block
* signups outright.
*/
export const getEmailBlocklistDomains = async (): Promise<string[]> => {
const setting = await prisma.siteSettings.findFirst({
where: {
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
},
});
if (!setting || !setting.enabled) {
return [];
}
const parsed = ZSiteSettingsEmailBlocklistSchema.safeParse(setting);
if (!parsed.success) {
return [];
}
return parsed.data.data.domains;
};
@@ -1,9 +1,14 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
export const ZSiteSettingSchema = z.union([ZSiteSettingsBannerSchema, ZSiteSettingsTelemetrySchema]);
export const ZSiteSettingSchema = z.union([
ZSiteSettingsBannerSchema,
ZSiteSettingsEmailBlocklistSchema,
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_EMAIL_BLOCKLIST_ID = 'email.blocklist-domains';
/**
* Normalises a single domain entry: trims whitespace, lowercases, strips
* a leading "@" if present (so users can paste either "bad.com" or "@bad.com").
*/
const normaliseDomain = (value: string): string => value.trim().toLowerCase().replace(/^@/, '');
const ZBlocklistDomainsSchema = z
.array(z.string())
.transform((values) => Array.from(new Set(values.map(normaliseDomain).filter((value) => value.length > 0))));
export const ZSiteSettingsEmailBlocklistSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_EMAIL_BLOCKLIST_ID),
data: z
.object({
domains: ZBlocklistDomainsSchema.default([]),
})
.optional()
.default({
domains: [],
}),
});
export type TSiteSettingsEmailBlocklistSchema = z.infer<typeof ZSiteSettingsEmailBlocklistSchema>;
@@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
import type { SubscriptionClaim } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
export const getSubscriptionClaim = async (
claimId: string,
): Promise<Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>> => {
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
where: { id: claimId },
});
if (!subscriptionClaim) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Subscription claim ${claimId} not found`,
});
}
return subscriptionClaim;
};
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
@@ -117,7 +116,7 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
token,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -136,7 +135,7 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`A request to use your email has been initiated by ${team.name} on Documenso`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
@@ -24,7 +23,7 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -87,7 +86,7 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
+2 -3
View File
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -120,7 +119,7 @@ export const sendTeamDeleteEmail = async ({ email, team, organisationId }: SendT
teamUrl: team.url,
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -135,7 +134,7 @@ export const sendTeamDeleteEmail = async ({ email, team, organisationId }: SendT
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: email,
from: senderEmail,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
@@ -25,6 +25,7 @@ import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
@@ -42,6 +43,8 @@ import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -115,6 +118,20 @@ export const createDocumentFromDirectTemplate = async ({
name: true,
},
},
team: {
select: {
organisationId: true,
organisation: {
select: {
organisationClaim: {
select: {
recipientCount: true,
},
},
},
},
},
},
},
});
@@ -181,7 +198,31 @@ export const createDocumentFromDirectTemplate = async ({
const nonDirectTemplateRecipients = directTemplateEnvelope.recipients.filter(
(recipient) => recipient.id !== directTemplateRecipient.id,
);
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
// Carry the template's level forward, coercing if the instance mode has
// changed since the template was created. ZSignatureLevelSchema parses the
// free-form TEXT column defensively. Resolved before meta extraction so
// signingOrder picks up the TSP-appropriate default + assertion.
const signatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(directTemplateEnvelope.signatureLevel),
strict: false,
});
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta, signatureLevel);
// The resulting document contains every non-direct template recipient plus the
// direct recipient that is signing now. A recipientCount of 0 means unlimited.
// This mirrors the check in `sendDocument`, but must be done here because this
// flow creates the document directly in PENDING and swallows `sendDocument` errors.
const maximumRecipientCount = directTemplateEnvelope.team.organisation.organisationClaim.recipientCount;
const resultingRecipientCount = nonDirectTemplateRecipients.length + 1;
if (maximumRecipientCount > 0 && resultingRecipientCount > maximumRecipientCount) {
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
statusCode: 400,
});
}
// Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
@@ -269,6 +310,13 @@ export const createDocumentFromDirectTemplate = async ({
const directTemplateSignatureFields = createDirectRecipientFieldArgs.filter(({ signature }) => signature !== null);
// Enforce the organisation document-creation limit before creating the document.
await assertOrganisationRatesAndLimits({
organisationId: directTemplateEnvelope.team.organisationId,
type: 'document',
count: 1,
});
const initialRequestTime = new Date();
// Key = original envelope item ID
@@ -315,6 +363,7 @@ export const createDocumentFromDirectTemplate = async ({
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: directTemplateEnvelope.internalVersion,
signatureLevel,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: directTemplateEnvelopeLegacyId,
@@ -33,6 +33,7 @@ import type {
TTextFieldMeta,
} from '../../types/field-meta';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
@@ -50,6 +51,8 @@ import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
@@ -503,25 +506,45 @@ export const createDocumentFromTemplate = async ({
}),
);
// Enforce the organisation document-creation limit before creating the document.
await assertOrganisationRatesAndLimits({
organisationId: callerTeam.organisationId,
type: 'document',
count: 1,
});
const incrementedDocumentId = await incrementDocumentId();
// Carry the template's level forward, coercing if the instance mode has
// changed since the template was created. ZSignatureLevelSchema parses the
// free-form TEXT column defensively. Resolved before meta extraction so
// signingOrder picks up the TSP-appropriate default + assertion.
const signatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(template.signatureLevel),
strict: false,
});
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.documentMeta?.subject,
message: override?.message || template.documentMeta?.message,
timezone: override?.timezone || template.documentMeta?.timezone,
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
}),
data: extractDerivedDocumentMeta(
settings,
{
subject: override?.subject || template.documentMeta?.subject,
message: override?.message || template.documentMeta?.message,
timezone: override?.timezone || template.documentMeta?.timezone,
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
},
signatureLevel,
),
});
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
@@ -531,6 +554,7 @@ export const createDocumentFromTemplate = async ({
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: template.internalVersion,
signatureLevel,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
@@ -0,0 +1,48 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
/**
* Throws if the supplied user object is disabled.
*
* Synchronous variant for hot paths where the `disabled` field has already
* been loaded (e.g. TRPC middleware where the user comes from the session
* query or API token lookup).
*/
export const assertUserNotDisabled = (user: { disabled: boolean }): void => {
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
statusCode: 403,
});
}
};
export type AssertUserNotDisabledByIdOptions = {
userId: number;
};
/**
* Throws if the user with the given id does not exist or is disabled.
*
* Used as a defence-in-depth guard for sign-in chokepoints and server-side
* actions that should not be performed on behalf of a disabled account
* (e.g. creating or sending documents). It deliberately re-queries from the
* database rather than relying on cached context so a freshly-disabled user
* cannot continue to act through a stale session or token.
*/
export const assertUserNotDisabledById = async ({ userId }: AssertUserNotDisabledByIdOptions): Promise<void> => {
const user = await prisma.user.findFirst({
where: { id: userId },
select: { disabled: true },
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
statusCode: 404,
});
}
assertUserNotDisabled(user);
};
+13 -6
View File
@@ -1,7 +1,7 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
import { deleteOrganisation } from '../organisation/delete-organisation';
export type DeleteUserOptions = {
id: number;
@@ -20,6 +20,11 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
id: true,
},
},
subscription: {
select: {
planId: true,
},
},
},
},
organisationMember: {
@@ -44,9 +49,6 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
});
}
// Get team IDs from organisations the user owns.
const ownedTeamIds = user.ownedOrganisations.flatMap((org) => org.teams.map((team) => team.id));
// Get team IDs from organisations the user is a member of (but not owner).
const memberTeams = user.organisationMember
.filter((member) => member.organisation.ownerUserId !== user.id)
@@ -57,8 +59,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
})),
);
// For teams where user is the org owner - orphan their envelopes.
await Promise.all(ownedTeamIds.map(async (teamId) => orphanEnvelopes({ teamId })));
// For organisations the user owns - fully tear them down (orphan envelopes,
// delete the organisation, and cancel any Stripe subscription). Without this
// the organisations would only cascade away when the user row is deleted,
// leaving their subscriptions billing and account rows behind.
for (const organisation of user.ownedOrganisations) {
await deleteOrganisation({ organisation });
}
// For teams where user is a member (not owner) - transfer envelopes to team owner.
await Promise.all(
@@ -13,7 +13,7 @@ export const validateApiToken = async ({ authorization }: ValidateApiTokenOption
throw new Error('Missing API token');
}
return await getApiTokenByToken({ token });
return await getApiTokenByToken({ token, bypassRateLimit: true });
} catch (err) {
throw new Error(`Failed to validate API token`);
}