Merge remote-tracking branch 'origin/main' into pr-2468

# Conflicts:
#	packages/lib/types/document-audit-logs.ts
#	packages/lib/utils/document-audit-logs.ts
#	packages/prisma/schema.prisma
This commit is contained in:
ephraimduncan
2026-07-02 06:48:23 +00:00
584 changed files with 38071 additions and 6339 deletions
@@ -1,3 +1,4 @@
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
@@ -21,11 +22,49 @@ const LocalRecipientSchema = z.object({
type TLocalRecipient = z.infer<typeof LocalRecipientSchema>;
export const ZEditorRecipientsFormSchema = z.object({
signers: z.array(LocalRecipientSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
/**
* Backstop validation that mirrors the CSC-mode UI overrides in
* `EnvelopeEditorProvider`. If anything bypasses the disabled controls (URL
* tampering, legacy form state, embedded host) the form refuses to submit
* rather than persisting values the TSP flow can't honour.
*/
export const ZEditorRecipientsFormSchema = z
.object({
signers: z.array(LocalRecipientSchema),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
})
.superRefine((data, ctx) => {
if (!IS_INSTANCE_CSC_MODE()) {
return;
}
if (data.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes must use SEQUENTIAL signing order.',
path: ['signingOrder'],
});
}
if (data.allowDictateNextSigner) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support next-signer dictation.',
path: ['allowDictateNextSigner'],
});
}
data.signers.forEach((signer, index) => {
if (signer.role === RecipientRole.ASSISTANT) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CSC envelopes do not support the assistant role.',
path: ['signers', index, 'role'],
});
}
});
});
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
@@ -1,3 +1,4 @@
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import {
DEFAULT_EDITOR_CONFIG,
@@ -36,6 +37,12 @@ type EnvelopeEditorProviderValue = {
isEmbedded: boolean;
isDocument: boolean;
isTemplate: boolean;
/**
* Whether the instance is running in CSC (Cloud Signature Consortium) mode.
* Components can branch on this for any additional CSC-only UI gating
* beyond the overrides already baked into `editorConfig`.
*/
isCscMode: boolean;
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
@@ -91,7 +98,7 @@ export const useCurrentEnvelopeEditor = () => {
export const EnvelopeEditorProvider = ({
children,
editorConfig = DEFAULT_EDITOR_CONFIG,
editorConfig: providedEditorConfig = DEFAULT_EDITOR_CONFIG,
initialEnvelope,
organisationEmails,
}: EnvelopeEditorProviderProps) => {
@@ -103,6 +110,31 @@ export const EnvelopeEditorProvider = ({
const [envelope, _setEnvelope] = useState(initialEnvelope);
const [autosaveError, setAutosaveError] = useState<boolean>(false);
const isCscMode = IS_INSTANCE_CSC_MODE();
/**
* CSC-mode overrides applied on top of any caller-supplied editor config.
* TSP envelopes are forced SEQUENTIAL at send-time and the sign path has no
* nextSigner dictation; the assistant role's pre-fill semantics don't map
* onto each recipient signing their own complete PDF state. Hide all three
* up-front so authors don't pick options that would get silently coerced.
*/
const editorConfig = useMemo<EnvelopeEditorConfig>(() => {
if (!isCscMode || !providedEditorConfig.recipients) {
return providedEditorConfig;
}
return {
...providedEditorConfig,
recipients: {
...providedEditorConfig.recipients,
allowConfigureSigningOrder: false,
allowConfigureDictateNextSigner: false,
allowAssistantRole: false,
},
};
}, [isCscMode, providedEditorConfig]);
const envelopeRef = useRef(initialEnvelope);
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
@@ -467,6 +499,7 @@ export const EnvelopeEditorProvider = ({
isEmbedded,
isDocument: envelope.type === EnvelopeType.DOCUMENT,
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
isCscMode,
setLocalEnvelope,
getRecipientColorKey,
updateEnvelope,
@@ -7,7 +7,7 @@ import type React from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import type { FieldRenderMode } from '../../universal/field-renderer/field-renderer';
/**
* The signature data for an inserted signature field.
+53
View File
@@ -1,4 +1,6 @@
import { env } from '@documenso/lib/utils/env';
import { AppError, AppErrorCode } from '../errors/app-error';
import { SignatureLevel, type TSignatureLevel } from '../types/signature-level';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
@@ -33,3 +35,54 @@ export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID')
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
/**
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
*
* CSC mode routes signing through a third-party Trust Service Provider for
* Advanced and Qualified Electronic Signatures (AES/QES). It is instance-wide
* and mutually exclusive with the other signing transports.
*/
export const IS_INSTANCE_CSC_MODE = (): boolean => {
if (typeof window === 'undefined') {
return env('NEXT_PRIVATE_SIGNING_TRANSPORT') === 'csc';
}
return env('NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC') === 'true';
};
/**
* The default signature level applied to envelopes created on a CSC-mode
* instance when the caller doesn't specify one (or asks for `SES` and the
* resolver is in loose-coerce mode).
*
* Set via `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL`; accepts `AES` or `QES`
* only; defaults to `AES` when unset. An explicit `AES`/`QES` request on
* envelope create still passes through unchanged — this constant only affects
* the coerced default.
*
* Throws on an invalid value rather than silently falling back. A typo here
* (e.g. `qes`) would otherwise silently downgrade qualified-tier instances
* to advanced-tier, which has legal consequences.
*
* Only consulted on CSC-mode instances. Non-CSC instances always default to
* `SES` regardless of this var.
*/
export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
// Cast through `string | undefined` because shells can deliver
// `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=` as an empty string at runtime
// — the typed env signature narrows to `'AES' | 'QES' | undefined` only.
const value = env('NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL');
if (!value) {
return SignatureLevel.AES;
}
if (value !== SignatureLevel.AES && value !== SignatureLevel.QES) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL must be '${SignatureLevel.AES}' or '${SignatureLevel.QES}', got '${value}'.`,
});
}
return value;
};
+76
View File
@@ -1,3 +1,4 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
@@ -40,6 +41,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean(
export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL');
/**
* Opt-out flag for the automatic OIDC redirect.
*
* When OIDC is the only enabled signin transport we redirect to the provider
* automatically. Set this to "true" to keep rendering the signin page instead.
*/
export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true';
export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
ACCOUNT_SSO_LINK: 'Linked account to SSO',
ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO',
@@ -121,6 +130,54 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
return allowedDomains.includes(emailDomain);
};
/**
* Check if the given email belongs to a known disposable / throwaway provider
* (e.g. mailinator, yopmail, 10minutemail, ...).
*
* Backed by the `mailchecker` package which bundles a static list of 55k+
* disposable domains. The check is offline and synchronous.
*
* Matching also covers subdomains (e.g. `foo.mailinator.com` resolves to
* `mailinator.com`).
*
* An optional `additionalBlockedDomains` list can be supplied to layer
* admin-configured custom domains on top of the bundled list. These are
* matched with the same subdomain-walking behaviour and are expected to be
* pre-normalised (trimmed + lowercased) by the caller.
*
* Returns `true` when the email is disposable and should be rejected.
* Email format validation is intentionally NOT performed here — that is
* handled by Zod upstream.
*/
export const isDisposableEmail = (email: string, additionalBlockedDomains: string[] = []): boolean => {
const domain = email.toLowerCase().split('@').pop();
if (!domain) {
return false;
}
const blacklist = MailChecker.blacklist();
const blocklist = new Set(additionalBlockedDomains);
let currentDomain: string | undefined = domain;
while (currentDomain) {
if (blacklist.has(currentDomain) || blocklist.has(currentDomain)) {
return true;
}
const nextDot = currentDomain.indexOf('.');
if (nextDot === -1) {
break;
}
currentDomain = currentDomain.slice(nextDot + 1);
}
return false;
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
@@ -139,3 +196,22 @@ export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'micro
return env(flagMap[provider]) !== 'true';
};
/**
* Check if signin is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
*/
export const isSigninEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
if (env('NEXT_PUBLIC_DISABLE_SIGNIN') === 'true') {
return false;
}
const flagMap = {
email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN',
google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN',
microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN',
oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNIN',
} as const;
return env(flagMap[provider]) !== 'true';
};
+3
View File
@@ -23,6 +23,9 @@ export const DOCUMENT_STATUS: {
[DocumentStatus.REJECTED]: {
description: msg`Rejected`,
},
[DocumentStatus.CANCELLED]: {
description: msg`Cancelled`,
},
[DocumentStatus.DRAFT]: {
description: msg`Draft`,
},
+15 -4
View File
@@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
*/
export const MAX_REMINDER_WINDOW_DAYS = 30;
/**
* Maximum number of automated reminders sent to a recipient before reminders
* stop. A manual resend resets the count, re-arming reminders.
*/
export const MAX_REMINDERS_BEFORE_RESEND = 5;
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
day: 'days',
week: 'weeks',
@@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
*
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
* any computed reminder beyond that point returns null so reminders stop.
* Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS`
* from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent.
*
* `sentAt` is when the signing request was sent to this specific recipient.
*
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
* Returns the next Date the reminder should be sent, or null if none.
*/
export const resolveNextReminderAt = (options: {
config: TEnvelopeReminderSettings | null;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderCount: number;
}): Date | null => {
const { config, sentAt, lastReminderSentAt } = options;
const { config, sentAt, lastReminderSentAt, reminderCount } = options;
if (!config) {
return null;
}
if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) {
return null;
}
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
let candidate: Date;
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { getSignatureFontFamily } from './pdf';
describe('getSignatureFontFamily', () => {
const expectCaveat = (family: string) => expect(family).toBe('Caveat');
const expectNotoChain = (family: string) => {
expect(family).toContain('"Noto Sans"');
expect(family).toContain('"Noto Sans Chinese"');
expect(family).toContain('"Noto Sans Japanese"');
expect(family).toContain('"Noto Sans Korean"');
expect(family).toContain('sans-serif');
expect(family).not.toContain('Caveat');
};
it('returns Caveat for ASCII-only text', () => {
expectCaveat(getSignatureFontFamily('John Doe'));
expectCaveat(getSignatureFontFamily(''));
});
it('returns the Noto chain for any non-ASCII character', () => {
expectNotoChain(getSignatureFontFamily('François'));
expectNotoChain(getSignatureFontFamily('Müller'));
expectNotoChain(getSignatureFontFamily('Søren'));
expectNotoChain(getSignatureFontFamily('Иванов'));
expectNotoChain(getSignatureFontFamily('Ελληνικά'));
expectNotoChain(getSignatureFontFamily('عربي'));
expectNotoChain(getSignatureFontFamily('עברית'));
expectNotoChain(getSignatureFontFamily('도큐멘소'));
expectNotoChain(getSignatureFontFamily('中文签名'));
expectNotoChain(getSignatureFontFamily('こんにちは'));
});
it('returns the Noto chain for mixed ASCII + non-ASCII input', () => {
expectNotoChain(getSignatureFontFamily('Hello 안녕'));
expectNotoChain(getSignatureFontFamily('Ivan Ωmega'));
});
it('returns the Noto chain for scripts not covered by a dedicated Noto file', () => {
expectNotoChain(getSignatureFontFamily('ሰላም')); // Ethiopic
expectNotoChain(getSignatureFontFamily('សួស្ដី')); // Khmer
expectNotoChain(getSignatureFontFamily('ᠮᠣᠩᠭᠣᠯ')); // Mongolian
});
});
+14
View File
@@ -9,6 +9,20 @@ export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
const SIGNATURE_FONT_FAMILY_CAVEAT = 'Caveat';
// CN-before-JP: the JP Noto file's Han glyphs use JP shapes, so pure-CN
// text would otherwise render with JP forms. Family names sync with
// apps/remix/app/app.css and packages/lib/server-only/pdf/helpers.ts.
const SIGNATURE_FONT_FAMILY_NOTO =
'"Noto Sans", "Noto Sans Chinese", "Noto Sans Japanese", "Noto Sans Korean", sans-serif';
const isASCII = (str: string) => /^\p{ASCII}*$/u.test(str);
// Deliberately never mix handwriting + sans-serif within one signature.
export const getSignatureFontFamily = (typedSignatureText: string): string =>
isASCII(typedSignatureText) ? SIGNATURE_FONT_FAMILY_CAVEAT : SIGNATURE_FONT_FAMILY_NOTO;
export const PDF_SIZE_A4_72PPI = {
width: 595,
height: 842,
+59 -1
View File
@@ -14,6 +14,7 @@ export enum AppErrorCode {
NOT_FOUND = 'NOT_FOUND',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
NOT_SETUP = 'NOT_SETUP',
MISSING_ENV_VAR = 'MISSING_ENV_VAR',
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
@@ -26,7 +27,37 @@ export enum AppErrorCode {
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
ENVELOPE_CANCELLED = 'ENVELOPE_CANCELLED',
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
/**
* Authoring mutation rejected because the envelope is an AES/QES envelope
* past DRAFT — the TSP mutation lock fires at distribution to preserve
* WYSIWYS. SES envelopes never hit this code.
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
*/
CSC_INSTANCE_MODE_MISMATCH = 'CSC_INSTANCE_MODE_MISMATCH',
CSC_UNLICENSED = 'CSC_UNLICENSED',
CSC_PROVIDER_INFO_FAILED = 'CSC_PROVIDER_INFO_FAILED',
CSC_PROVIDER_NO_TSA = 'CSC_PROVIDER_NO_TSA',
CSC_CREDENTIAL_LIST_EMPTY = 'CSC_CREDENTIAL_LIST_EMPTY',
CSC_CERT_INVALID = 'CSC_CERT_INVALID',
CSC_ALGORITHM_REFUSED = 'CSC_ALGORITHM_REFUSED',
CSC_SAD_EXPIRED_PRE_SIGN = 'CSC_SAD_EXPIRED_PRE_SIGN',
CSC_TSP_TIMEOUT = 'CSC_TSP_TIMEOUT',
CSC_EMBED_FAILED = 'CSC_EMBED_FAILED',
CSC_BASE_DOCUMENT_MUTATED = 'CSC_BASE_DOCUMENT_MUTATED',
/**
* Generic catch-all for CSC HTTP transport failures — network error, non-2xx
* response without a more specific semantic match, malformed JSON, or
* response schema mismatch. Carries the TSP's HTTP status in `statusCode`
* and the TSP's `error` / `error_description` in the message when available.
*/
CSC_REQUEST_FAILED = 'CSC_REQUEST_FAILED',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = {
@@ -39,6 +70,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_ENV_VAR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -49,7 +81,25 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.CSC_PROVIDER_NO_TSA]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_CERT_INVALID]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_ALGORITHM_REFUSED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_TSP_TIMEOUT]: { code: 'TIMEOUT', status: 408 },
[AppErrorCode.CSC_EMBED_FAILED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_BASE_DOCUMENT_MUTATED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
// Generic transport failure — the TSP is upstream so server-side from our
// perspective; 500 keeps the caller surface conservative. The TSP's actual
// HTTP status rides along in AppError.statusCode for the few callers that
// need to discriminate (e.g. 401 → re-auth, 429 → backoff).
[AppErrorCode.CSC_REQUEST_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
};
export const ZAppErrorJsonSchema = z.object({
@@ -238,11 +288,19 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
AppErrorCode.CSC_ALGORITHM_REFUSED,
AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN,
AppErrorCode.CSC_EMBED_FAILED,
() => 400 as const,
)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
.otherwise(() => 500 as const);
+8
View File
@@ -4,10 +4,14 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email';
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
@@ -36,15 +40,19 @@ export const jobsClient = new JobClient([
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
+7 -3
View File
@@ -233,13 +233,17 @@ export class BullMQJobProvider extends BaseJobProvider {
backgroundJobId?: string;
};
let payload = jobData.payload;
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(jobData.payload);
const result = definition.trigger.schema.safeParse(payload);
if (!result.success) {
console.error(`[JOBS]: Payload validation failed for ${definitionId}`, result.error);
throw new Error(`Payload validation failed for ${definitionId}`);
}
payload = result.data;
}
const backgroundJobId = jobData.backgroundJobId;
@@ -260,11 +264,11 @@ export class BullMQJobProvider extends BaseJobProvider {
.catch(() => null);
}
console.log(`[JOBS]: Processing job ${definitionId} with payload`, jobData.payload);
console.log(`[JOBS]: Processing job ${definitionId} with payload`, payload);
try {
await definition.handler({
payload: jobData.payload,
payload,
io: this.createJobRunIO(backgroundJobId ?? job.id ?? definitionId),
});
+7 -3
View File
@@ -260,15 +260,19 @@ export class LocalJobProvider extends BaseJobProvider {
return c.text('Unauthorized', 401);
}
let payload = options.payload;
if (definition.trigger.schema) {
const result = definition.trigger.schema.safeParse(options.payload);
const result = definition.trigger.schema.safeParse(payload);
if (!result.success) {
return c.text('Bad request', 400);
}
payload = result.data;
}
console.log(`[JOBS]: Triggering job ${options.name} with payload`, options.payload);
console.log(`[JOBS]: Triggering job ${options.name} with payload`, payload);
let backgroundJob = await prisma.backgroundJob
.update({
@@ -292,7 +296,7 @@ export class LocalJobProvider extends BaseJobProvider {
try {
await definition.handler({
payload: options.payload,
payload,
io: this.createJobRunIO(jobId),
});
@@ -1,14 +1,14 @@
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { EnvelopeType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@@ -32,6 +32,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
id: true,
email: true,
name: true,
disabled: true,
},
},
documentMeta: true,
@@ -46,17 +47,38 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
},
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { documentMeta, user: documentOwner } = envelope;
// Don't send cancellation emails if the organisation has email sending disabled or the owner is disabled (e.g. banned).
if (emailsDisabled || documentOwner.disabled) {
return;
}
// A recipientCount of 0 means unlimited recipients are allowed.
const maximumRecipientCount = claims.recipientCount;
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
io.logger.warn({
msg: 'Cancellation email dropped: org recipient limit exceeded',
organisationId,
recipientCount: envelope.recipients.length,
maximumRecipientCount,
envelopeId: envelope.id,
});
return;
}
// Check if document cancellation emails are enabled
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).documentDeleted;
@@ -66,9 +88,13 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
const i18n = await getI18nInstance(emailLanguage);
// Send cancellation emails to all recipients who have been sent the document or viewed it
// Send cancellation emails to recipients who have been sent the document or viewed it.
// CC recipients are excluded because they were never actually emailed about the document
// (CC recipients are created with sendStatus=SENT by default but never receive a signing
// invitation), so notifying them about a cancellation they never knew about is unsolicited.
const recipientsToNotify = envelope.recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC &&
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
recipient.signingStatus !== SigningStatus.REJECTED &&
isRecipientEmailValidForSending(recipient),
@@ -77,6 +103,28 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
await io.runTask('send-cancellation-emails', async () => {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
// Meter the cancellation email against the organisation email quota/stats.
// The recipient never opted in, so this notification is unsolicited and
// must be bounded by the same org limits as other outbound emails.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'Cancellation email dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
// On rate/quota exceeded, skip this recipient and continue with the rest.
return;
}
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: documentOwner.name || undefined,
@@ -94,7 +142,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCancelledEmai
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -1,13 +1,13 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { DocumentSource, EnvelopeType } from '@prisma/client';
import { DocumentSource, EnvelopeType, RecipientRole } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
@@ -44,6 +44,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
id: true,
email: true,
name: true,
disabled: true,
},
},
team: {
@@ -65,14 +66,20 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send completion emails if the organisation has email sending disabled or the owner is disabled (e.g. banned).
if (envelope.user.disabled || emailsDisabled) {
return;
}
const { user: owner } = envelope;
@@ -131,7 +138,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: [
{
name: owner.name || '',
@@ -172,6 +179,30 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
await Promise.all(
recipientsToNotify.map(async (recipient) => {
// A CC recipient never asked to be part of this document, so their completion
// email is effectively unsolicited. Meter it against the organisation email
// quota/stats so it is correctly logged.
if (recipient.role === RecipientRole.CC) {
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'CC completion email dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
// On rate/quota exceeded, early return to allow other recipients to be processed.
return;
}
}
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
@@ -179,6 +210,8 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
};
const downloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}/complete`;
const reportUrl =
recipient.role === RecipientRole.CC ? `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}` : undefined;
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: envelope.title,
@@ -188,6 +221,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
isDirectTemplate && envelope.documentMeta?.message
? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
: undefined,
reportUrl,
});
const [html, text] = await Promise.all([
@@ -201,7 +235,7 @@ export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmai
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: [
{
name: recipient.name,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -51,7 +50,7 @@ export const run = async ({ payload }: { payload: TSendDocumentCreatedFromDirect
const [recipient] = envelope.recipients;
const { user: templateOwner } = envelope;
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -79,7 +78,7 @@ export const run = async ({ payload }: { payload: TSendDocumentCreatedFromDirect
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: [
{
name: templateOwner.name || '',
@@ -0,0 +1,70 @@
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentDeletedEmailsJobDefinition } from './send-document-deleted-emails';
export const run = async ({ payload, io }: { payload: TSendDocumentDeletedEmailsJobDefinition; io: JobRunIO }) => {
const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload;
if (recipients.length === 0) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta,
});
// Don't send cancellation emails if the organisation has email sending
// disabled. Re-checked here (not just at enqueue time) because the org can be
// disabled between the delete request and this job running.
if (emailsDisabled) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const i18n = await getI18nInstance(emailLanguage);
for (const recipient of recipients) {
await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => {
if (!isRecipientEmailValidForSending(recipient)) {
return;
}
const template = createElement(DocumentCancelTemplate, {
documentName,
inviterName: inviterName || undefined,
inviterEmail,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
});
}
};
@@ -0,0 +1,52 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails';
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
documentName: z.string(),
inviterName: z.string().optional(),
inviterEmail: z.string(),
/**
* The document's email meta (sender, reply-to, language). Captured before the
* envelope is hard-deleted so `getEmailContext` resolves the exact same
* sender/reply-to/language the inline send used.
*/
meta: z
.object({
emailId: z.string().nullable().optional(),
emailReplyTo: z.string().nullable().optional(),
language: z.string().optional(),
})
.nullable(),
recipients: z
.object({
email: z.string(),
name: z.string(),
})
.array(),
});
export type TSendDocumentDeletedEmailsJobDefinition = z.infer<
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = {
id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Document Deleted Emails',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-deleted-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
TSendDocumentDeletedEmailsJobDefinition
>;
@@ -1,28 +1,24 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => {
const { envelopeId, recipientId } = payload;
export interface SendPendingEmailOptions {
id: EnvelopeIdOptions;
recipientId: number;
}
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT),
recipients: {
some: {
id: recipientId,
@@ -39,15 +35,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
},
});
if (!envelope) {
throw new Error('Document not found');
if (!envelope || envelope.recipients.length === 0) {
return;
}
if (envelope.recipients.length === 0) {
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 +48,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 +86,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,30 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendDocumentPendingEmailJobDefinition = z.infer<typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA>;
export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Pending Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-pending-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
TSendDocumentPendingEmailJobDefinition
>;
@@ -0,0 +1,142 @@
import { mailer } from '@documenso/email/mailer';
import OrganisationLimitAlertEmailTemplate from '@documenso/email/templates/organisation-limit-alert';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { INTERNAL_CLAIM_ID } from '../../../types/subscription';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOrganisationLimitAlertEmailJobDefinition } from './send-organisation-limit-alert-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOrganisationLimitAlertEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.organisationId,
},
include: {
organisationClaim: true,
monthlyStats: {
where: {
period: payload.period,
},
select: {
documentCount: true,
emailCount: true,
apiCount: true,
},
},
members: {
where: {
organisationGroupMembers: {
some: {
group: {
organisationRole: {
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
},
},
},
},
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
},
});
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
// Do not send emails for "free" claims.
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE) {
io.logger.info({
msg: 'Skipping organisation limit alert email for "free" claim',
organisationId: organisation.id,
});
return;
}
const memberSubject =
payload.kind === 'quotaNearing' ? msg`Approaching Your Plan Limits` : msg`Organisation Review Required`;
for (const member of organisation.members) {
await io.runTask(`send-organisation-limit-alert-email-${member.id}`, async () => {
const emailContent = createElement(OrganisationLimitAlertEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
organisationName: organisation.name,
counter: payload.counter,
kind: payload.kind,
period: payload.period,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(memberSubject),
html,
text,
});
});
}
// Todo: Logging
const i18n = await getI18nInstance('en');
const supportSubject =
payload.kind === 'quotaNearing'
? msg`An organisation is nearing their fair use limits`
: msg`An organisation has exceeded their fair use limits`;
// Email our support team. Purposefully sent from the internal email since the
// global mailer is not authorized to send from custom per-plan transport addresses.
await io.runTask('send-organisation-limit-alert-support-email', async () => {
await mailer.sendMail({
to: SUPPORT_EMAIL,
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(supportSubject),
text: `
Organisation: ${organisation.name}
Organisation ID: ${organisation.id}
Organisation Claim Original ID: ${organisation.organisationClaim.originalSubscriptionClaimId}
Email Quota: ${organisation.monthlyStats[0]?.emailCount || 0}/${organisation.organisationClaim.emailQuota ?? 'Unlimited'}
API Quota: ${organisation.monthlyStats[0]?.apiCount || 0}/${organisation.organisationClaim.apiQuota ?? 'Unlimited'}
Document Quota: ${organisation.monthlyStats[0]?.documentCount || 0}/${organisation.organisationClaim.documentQuota ?? 'Unlimited'}
Counter: ${payload.counter}
Kind: ${payload.kind}
Period: ${payload.period}
`,
});
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-alert.email';
const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
counter: z.enum(['document', 'email', 'api']),
kind: z.enum(['rateLimit', 'quota', 'quotaNearing']),
period: z.string(),
});
export type TSendOrganisationLimitAlertEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Limit Alert Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-limit-alert-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationLimitAlertEmailJobDefinition
>;
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import OrganisationJoinEmailTemplate from '@documenso/email/templates/organisation-join';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -65,7 +64,7 @@ export const run = async ({
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -103,7 +102,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import OrganisationLeaveEmailTemplate from '@documenso/email/templates/organisation-leave';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -60,7 +59,7 @@ export const run = async ({
},
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
@@ -97,7 +96,7 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
await emailTransport.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { RecipientExpiredTemplate } from '@documenso/email/templates/recipient-expired';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -62,7 +61,7 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -71,6 +70,11 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired
meta: documentMeta,
});
// Don't send any emails if the organisation has email sending disabled.
if (emailsDisabled) {
return;
}
const i18n = await getI18nInstance(emailLanguage);
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team.url)}/${envelope.id}`;
@@ -93,7 +97,7 @@ export const run = async ({ payload, io }: { payload: TSendOwnerRecipientExpired
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: documentOwner.name || '',
address: documentOwner.email,
@@ -0,0 +1,105 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email';
export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => {
const { envelopeId, recipientEmail, recipientName, inviterName } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
documentMeta: true,
},
});
// The envelope may have been deleted between the recipient removal and this
// job running. Treat as a no-op so the job doesn't retry forever.
if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) {
return;
}
// Re-checked at send time (not just at enqueue) so the email honors the
// document's current "recipient removed" setting.
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
if (!isRecipientRemovedEmailEnabled) {
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails outside
// the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: inviterName || undefined,
assetBaseUrl,
});
const i18n = await getI18nInstance(emailLanguage);
await io.runTask('send-recipient-removed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await emailTransport.sendMail({
to: {
address: recipientEmail,
name: recipientName,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email';
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientEmail: z.string(),
recipientName: z.string(),
inviterName: z.string().optional(),
});
export type TSendRecipientRemovedEmailJobDefinition = z.infer<
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = {
id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Recipient Removed Email',
version: '1.0.0',
trigger: {
name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-recipient-removed-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
TSendRecipientRemovedEmailJobDefinition
>;
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -75,7 +74,7 @@ export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJ
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -105,7 +104,7 @@ export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJ
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
@@ -64,7 +64,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -75,8 +75,10 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
if (isRecipientEmailValidForSending(recipient)) {
// Send confirmation email to the recipient who rejected.
// Skipped when the organisation has email sending disabled, since this is sent on its behalf.
// The owner notification below intentionally uses the internal Documenso email, so it still sends.
if (!emailsDisabled && isRecipientEmailValidForSending(recipient)) {
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
@@ -95,7 +97,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
@@ -16,7 +15,9 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { buildEnvelopeEmailHeaders } from '../../../server-only/email/build-envelope-email-headers';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
@@ -54,6 +55,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
},
include: {
documentMeta: true,
user: {
select: {
disabled: true,
},
},
team: {
select: {
teamEmail: true,
@@ -83,7 +89,18 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
return;
}
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = await getEmailContext({
const {
branding,
emailLanguage,
settings,
organisationType,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -92,6 +109,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
meta: envelope.documentMeta,
});
// Don't send signing invitations if the organisation has email sending disabled or the owner is disabled (e.g. banned).
if (envelope.user.disabled || emailsDisabled) {
return;
}
const customEmail = envelope?.documentMeta;
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@@ -144,6 +166,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
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,
@@ -159,9 +182,29 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
teamName: team?.name,
teamEmail: team?.teamEmail?.email,
includeSenderDetails: settings.includeSenderDetails,
reportUrl,
});
if (isRecipientEmailValidForSending(recipient)) {
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
io.logger.warn({
msg: 'Recipient signing email dropped: org rate limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
// Job is consumed and NOT retried.
return;
}
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
@@ -172,7 +215,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
@@ -182,6 +225,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
subject: renderCustomEmailTemplate(documentMeta?.subject || emailSubject, customEmailTemplate),
html,
text,
headers: buildEnvelopeEmailHeaders({
userId,
envelopeId: envelope.id,
teamId: envelope.teamId,
}),
});
});
}
@@ -1,10 +1,8 @@
import { prisma } from '@documenso/prisma';
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes';
import { deleteOrganisation } from '../../../server-only/organisation/delete-organisation';
import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation';
@@ -52,41 +50,24 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
const ownerEmail = organisation.owner.email;
const emailContext = await io.runTask('get-email-context', async () => {
return await getEmailContext({
const { emailTransport: _emailTransport, ...serializableContext } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
return serializableContext;
});
// 1. Orphan all envelopes for every team.
for (const team of organisation.teams) {
await io.runTask(`orphan-envelopes--team-${team.id}`, async () => {
await orphanEnvelopes({ teamId: team.id });
});
}
// 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts.
// 1. Orphan envelopes, delete the organisation, and schedule the Stripe
// subscription cancellation. Shared with organisation-router/delete-organisation.ts.
await io.runTask('delete-organisation', async () => {
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,
},
});
});
await deleteOrganisation({ organisation });
});
// 3. Send the owner notification.
// 2. Send the owner notification.
if (sendEmailToOwner) {
await io.runTask('send-organisation-deleted-email', async () => {
await sendOrganisationDeleteEmail({
@@ -97,17 +78,4 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
});
});
}
// 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period.
if (organisation.subscription) {
const stripeSubscriptionId = organisation.subscription.planId;
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId,
organisationId: organisation.id,
},
});
}
};
@@ -20,6 +20,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
cfr21: z.literal(true).optional(),
hipaa: z.literal(true).optional(),
signingReminders: z.literal(true).optional(),
cscQesSigning: z.literal(true).optional(),
// Do NOT backport disableEmails.
// Todo: Envelopes - Do we need to check?
// authenticationPortal & emailDomains missing here.
}),
@@ -38,7 +40,7 @@ export const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION = {
handler: async ({ payload, io }) => {
const handler = await import('./backport-subscription-claims.handler');
await handler.run({ payload, io });
await handler.run({ payload: BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA.parse(payload), io });
},
} as const satisfies JobDefinition<
typeof BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_ID,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
@@ -164,7 +163,7 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const { branding, emailLanguage, senderEmail } = await getEmailContext({
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -186,7 +185,7 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
}),
]);
await mailer.sendMail({
await emailTransport.sendMail({
to: {
name: user.name || '',
address: user.email,
@@ -1,4 +1,3 @@
import { mailer } from '@documenso/email/mailer';
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
@@ -16,7 +15,9 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { buildEnvelopeEmailHeaders } from '../../../server-only/email/build-envelope-email-headers';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
@@ -51,6 +52,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
data: {
lastReminderSentAt: now,
nextReminderAt: null,
reminderCount: { increment: 1 },
},
});
@@ -100,7 +102,17 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
return;
}
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',
@@ -109,6 +121,13 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
meta: envelope.documentMeta,
});
// Don't send reminders if the owner is disabled (e.g. banned) or the organisation
// has email sending disabled.
if (envelope.user.disabled || emailsDisabled) {
io.logger.info(`Envelope ${envelope.id} skipping reminder: owner disabled or organisation emails disabled`);
return;
}
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase();
@@ -138,70 +157,102 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
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}`;
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
const template = createElement(DocumentReminderEmailTemplate, {
recipientName: recipient.name,
documentName: envelope.title,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
// Meter reminder emails against the organisation email quota/stats. Reminders
// are unsolicited (the recipient didn't opt in to them) and can recur, so they
// must be bounded by the same org limits as other outbound emails.
const isRateLimited = await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
})
.then(() => false)
.catch((_err) => {
io.logger.warn({
msg: 'Signing reminder dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
recipientRole: recipient.role,
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
isResending: false,
envelopeId: envelope.id,
});
return true;
});
if (!isRateLimited) {
io.logger.info(
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
);
const template = createElement(DocumentReminderEmailTemplate, {
recipientName: recipient.name,
documentName: envelope.title,
assetBaseUrl,
signDocumentLink,
customBody: emailMessage,
role: recipient.role,
reportUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await emailTransport.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
}),
});
from: senderEmail,
replyTo: replyToEmail,
subject: emailSubject,
html,
text,
headers: buildEnvelopeEmailHeaders({
userId: envelope.userId,
envelopeId: envelope.id,
teamId: envelope.teamId,
}),
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
isResending: false,
},
}),
});
// Compute the next reminder time (repeat interval).
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
}
// reminderCount was incremented in the atomic claim above, so the value read
// here includes the reminder we just sent and gates the next one.
if (recipient.sentAt) {
await updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: recipient.sentAt,
lastReminderSentAt: now,
reminderCount: recipient.reminderCount,
});
}
};
@@ -1,5 +1,6 @@
import path from 'node:path';
import { PDFDocument } from '@cantoo/pdf-lib';
import { finalizeTspEnvelopeCompletion } from '@documenso/ee/server-only/signing/csc/finalize-tsp-completion';
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
@@ -22,6 +23,7 @@ import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
import { isTspEnvelope } from '../../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
import { prefixedId } from '../../../universal/id';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
@@ -164,6 +166,33 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
if (isTspEnvelope(envelope)) {
if (isResealing) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message: 'Re-sealing TSP envelopes is not supported — recipient signatures cannot be regenerated externally.',
});
}
if (isRejected) {
throw new AppError(AppErrorCode.NOT_SETUP, {
message:
'TSP envelope rejection is not supported in V1 — rejection stamps would invalidate PAdES signatures.',
});
}
await finalizeTspEnvelopeCompletion({
envelope,
envelopeCompletedAuditLog,
requestMetadata,
});
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
}
// Pre-fetch all PDF data so we can read dimensions and pass it
// to decorateAndSignPdf without fetching again.
const prefetchedItems = await Promise.all(
+2
View File
@@ -44,6 +44,7 @@
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^6.1.0",
"inngest": "^3.54.0",
"ioredis": "^5.10.1",
@@ -51,6 +52,7 @@
"konva": "^10.0.9",
"kysely": "0.29.2",
"luxon": "^3.7.2",
"mailchecker": "^6.0.20",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"p-map": "^7.0.4",
@@ -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,
@@ -18,6 +18,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
@@ -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,
@@ -31,9 +31,13 @@ export const loadRecipientBrandingByTeamId = async ({
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
]);
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
let allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
if (!settings.brandingEnabled) {
allowCustomBranding = false;
}
if (!allowCustomBranding) {
return {
allowCustomBranding: false,
@@ -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,
@@ -0,0 +1,129 @@
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { isMemberManagerOrAbove } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CancelDocumentOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
reason?: string;
requestMetadata: ApiRequestMetadata;
};
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
// Resolve the envelope through the visibility-aware helper so the caller must
// have read access (ownership OR team membership with sufficient visibility OR
// team-email). This prevents cancelling a document the caller cannot see.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
documentMeta: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = envelope.userId === userId;
const teamRole = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
})
.then((roles) => roles.teamRole)
.catch(() => null);
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
// The document is visible to the caller, but cancelling requires elevated permissions.
if (!isUserOwner && !isPrivilegedTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only pending documents can be cancelled',
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
const updated = await tx.envelope.update({
where: {
id: envelope.id,
},
data: {
status: DocumentStatus.CANCELLED,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
metadata: requestMetadata,
data: {
reason,
},
}),
});
return updated;
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Send cancellation emails to recipients via the resilient background job.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Trigger the webhook with the updated (cancelled) envelope payload.
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload({
...envelope,
status: updatedEnvelope.status,
completedAt: updatedEnvelope.completedAt,
}),
),
userId,
teamId,
});
return updatedEnvelope;
};
@@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -406,7 +405,13 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
@@ -1,14 +1,9 @@
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 { createElement } from 'react';
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
@@ -17,9 +12,8 @@ import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
@@ -42,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
// Note: This is an unsafe request, we validate the ownership later in the function.
// Note: This is an unsafe request. It is used purely to resolve the recipient
// self-hide path below. The authoritative delete authorization is performed
// via the visibility-aware `getEnvelopeWhereInput` helper.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
include: {
@@ -57,36 +53,47 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
const isUserTeamMember = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
// Determine whether the user has authorized delete access using the
// visibility-aware helper. This enforces ownership OR (team membership AND
// the document's visibility is permitted for the member's role) OR team-email
// access. A bare team member without sufficient visibility will resolve to
// null here and therefore must not be able to delete the document.
const hasDeleteAccess = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
})
.then(() => true)
.then(({ envelopeWhereInput }) =>
prisma.envelope.findFirst({
where: envelopeWhereInput,
select: { id: true },
}),
)
.then((result) => Boolean(result))
.catch(() => false);
const isUserOwner = envelope.userId === userId;
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
if (!hasDeleteAccess && !userRecipient) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerDelete({
if (hasDeleteAccess) {
const updatedEnvelope = await handleDocumentOwnerDelete({
envelope,
user,
requestMetadata,
});
const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) };
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)),
userId,
teamId,
});
@@ -126,7 +133,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
const { emailLanguage, emailsDisabled } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -187,50 +194,46 @@ 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)) {
return;
}
// Enqueue cancellation emails as a background job. The envelope (and its
// documentMeta) is hard-deleted above, so the job can't look it up later —
// pass a self-contained payload with the recipients to notify.
const recipientsToNotify = envelope.recipients
.filter(
(recipient) =>
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC &&
isRecipientEmailValidForSending(recipient),
)
.map((recipient) => ({
email: recipient.email,
name: recipient.name,
}));
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
if (recipientsToNotify.length > 0) {
await jobs.triggerJob({
name: 'send.document.deleted.emails',
payload: {
teamId: envelope.teamId,
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
meta: envelope.documentMeta
? {
emailId: envelope.documentMeta.emailId,
emailReplyTo: envelope.documentMeta.emailReplyTo,
language: emailLanguage,
}
: null,
recipients: recipientsToNotify,
},
});
}
return deletedEnvelope;
};
@@ -220,7 +220,11 @@ export const findDocuments = async ({
eb.or([
eb('Envelope.userId', '=', user.id),
eb.and([
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
eb('Envelope.status', 'in', [
sql.lit(DocumentStatus.COMPLETED),
sql.lit(DocumentStatus.PENDING),
sql.lit(DocumentStatus.CANCELLED),
]),
recipientExists(eb, user.email),
]),
]),
@@ -291,6 +295,16 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
.where((eb) =>
eb.and([
personalDeletedFilter(eb),
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
]),
),
)
.exhaustive();
};
@@ -429,6 +443,18 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.exhaustive();
};
+18 -2
View File
@@ -239,6 +239,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// CANCELLED: team-owned cancelled + team-email received cancelled docs
const cancelledQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
@@ -260,21 +274,23 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, inbox] = await Promise.all([
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
cappedCount(cancelledQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.CANCELLED]: cancelled,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
@@ -0,0 +1,215 @@
// This is closely related to `reject-document-with-token.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from an API/programmatic perspective (an authenticated API user acting
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
// from a recipient perspective (the recipient rejecting via their token).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type RejectDocumentOnBehalfOfOptions = {
/**
* The ID of the envelope the recipient belongs to. Required so the caller
* targets an explicit envelope/recipient combination rather than resolving the
* envelope implicitly from the recipient ID.
*/
envelopeId: string;
recipientId: number;
userId: number;
teamId: number;
reason: string;
/**
* The email of a team member to attribute the rejection to. Must be a member
* of the team. When omitted the rejection is attributed to the API user that
* owns the token (`userId`).
*
* This exists so external applications can elect which team member is acting
* on behalf of the recipient, rather than always defaulting to the API user.
*/
actAsEmail?: string;
requestMetadata: ApiRequestMetadata;
};
/**
* Reject a document on behalf of a recipient as an authenticated API user.
*
* This is used to programmatically record a rejection for cases where the
* recipient declined to sign outside of the platform (e.g. before ever
* reaching it). The rejection is flagged as `isExternal` in the audit log to
* distinguish it from a rejection performed by the recipient directly.
*
* The action can optionally be attributed to a specific team member via
* `actAsEmail`; otherwise it is attributed to the API user.
*/
export async function rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId,
teamId,
reason,
actAsEmail,
requestMetadata,
}: RejectDocumentOnBehalfOfOptions) {
// Build the access-controlled envelope query. This enforces team membership
// AND document visibility (and owner / team-email access), mirroring the
// canonical envelope access checks used across the app.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
envelope: true,
},
});
const envelope = recipient?.envelope;
if (!recipient || !envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} has already actioned this document`,
});
}
assertRecipientNotExpired(recipient);
// Resolve the user the rejection should be attributed to. When `actAsEmail`
// is supplied it must resolve to a member of the team; otherwise the rejection
// is attributed to the API user that owns the token.
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
// Update the recipient status to rejected and record an external rejection
// audit log within the same transaction.
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
// Always attribute the audit log to a concrete user: the elected team
// member when supplied, otherwise the API user that owns the token.
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
metadata: requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
isExternal: true,
// Only set when a member was explicitly elected via `actAsEmail`.
onBehalfOfUserEmail: electedUser?.email,
onBehalfOfUserName: electedUser?.name,
},
}),
}),
]);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Trigger the seal document job to process the document asynchronously.
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: legacyDocumentId,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Send email notifications to the rejecting recipient.
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId: legacyDocumentId,
},
});
// Send cancellation emails to other recipients.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
return updatedRecipient;
}
/**
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
* user). Throws when the email does not resolve to a member of the team.
*/
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
if (!actAsEmail) {
return null;
}
const electedUser = await prisma.user.findFirst({
where: {
email: actAsEmail,
},
});
if (!electedUser) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
const isTeamMember = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
});
if (!isTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
return electedUser;
};
@@ -1,3 +1,11 @@
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from a recipient perspective (the recipient rejecting via their token),
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
// perspective (an authenticated API user acting on behalf of a recipient).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
@@ -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';
@@ -13,6 +13,7 @@ import {
EnvelopeType,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
@@ -26,8 +27,12 @@ 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 { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder';
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
@@ -47,9 +52,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 +76,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
select: {
teamEmail: true,
name: true,
organisation: {
select: {
organisationClaim: {
select: {
recipientCount: true,
},
},
},
},
},
},
},
@@ -87,7 +106,19 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
throw new Error('Can not send completed document');
}
// Refresh the expiresAt on each resent recipient.
// 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,
});
}
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
const recipientsToRemind = envelope.recipients.filter(
@@ -97,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
recipient.role !== RecipientRole.CC,
);
// Extend the expiration deadline for recipients being resent.
if (expiresAt && recipientsToRemind.length > 0) {
await prisma.recipient.updateMany({
where: {
@@ -112,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
});
}
// A manual resend restarts the reminder cycle from scratch, mirroring the
// initial send, so a recipient that hit the threshold can be reminded again.
const resentAt = new Date();
await Promise.all(
recipientsToRemind.map((recipient) =>
updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: resentAt,
lastReminderSentAt: null,
resetReminderCount: true,
}),
),
);
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
@@ -120,7 +166,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 +185,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 +240,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 +256,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 +273,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 +285,23 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
: emailSubject,
html,
text,
headers: buildEnvelopeEmailHeaders({
userId: envelope.userId,
envelopeId: envelope.id,
teamId: envelope.teamId,
}),
});
// Mark the recipient as sent if they were not already sent.
await prisma.recipient.updateMany({
where: {
id: recipient.id,
sendStatus: SendStatus.NOT_SENT,
},
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
},
});
await prisma.documentAuditLog.create({
@@ -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({
@@ -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,19 @@ 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 { IS_BILLING_ENABLED } from '../../constants/app';
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 +71,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 +91,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 +101,44 @@ 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) {
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: {
@@ -155,15 +217,25 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
const allowedEmails = getAllowedEmails(organisation);
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
);
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
if (!allowBrandedEmailColors) {
branding.brandingColors = undefined;
}
return {
allowedEmails,
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
branding,
settings: organisation.organisationGlobalSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -177,6 +249,12 @@ const handleTeamEmailContext = async (teamId: number) => {
teamGlobalSettings: true,
organisation: {
include: {
owner: {
select: {
id: true,
disabled: true,
},
},
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
@@ -203,11 +281,21 @@ const handleTeamEmailContext = async (teamId: number) => {
const teamSettings = extractDerivedTeamSettings(organisation.organisationGlobalSettings, team.teamGlobalSettings);
const branding = teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false);
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
if (!allowBrandedEmailColors) {
branding.brandingColors = undefined;
}
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
branding,
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();
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateAttachmentOptions = {
envelopeId: string;
@@ -15,11 +15,15 @@ export type CreateAttachmentOptions = {
};
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type DeleteAttachmentOptions = {
id: string;
@@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -1,7 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
@@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type UpdateAttachmentOptions = {
id: string;
@@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -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,83 @@
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` / `ENVELOPE_CANCELLED` 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)
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
.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 { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
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 { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
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,
},
});
}
};
@@ -14,6 +14,7 @@ import { renderSVG } from 'uqr';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import { getSignatureFontFamily } from '../../constants/pdf';
import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { svgToPng } from '../../utils/images/svg-to-png';
@@ -302,7 +303,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
x: 2,
text: recipient.signatureField?.signature?.typedSignature,
padding: 4,
fontFamily: 'Caveat',
fontFamily: getSignatureFontFamily(recipient.signatureField?.signature?.typedSignature),
fontSize: 16,
align: 'center',
verticalAlign: 'middle',
@@ -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;
@@ -1,44 +0,0 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
apiTokens: {
some: {
token: hashedToken,
},
},
},
include: {
apiTokens: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};
@@ -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,21 +1,15 @@
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 { createElement } from 'react';
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { 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';
export interface DeleteEnvelopeRecipientOptions {
@@ -71,6 +65,8 @@ export const deleteEnvelopeRecipient = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -108,6 +104,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,44 +135,21 @@ 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)
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: envelope.team?.name || user.name || undefined,
assetBaseUrl,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
// Enqueue the "removed from document" email as a background job so a
// transient mail outage doesn't fail the request and the send is retried.
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
inviterName: envelope.team?.name || user.name || undefined,
},
meta: envelope.documentMeta,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}
@@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetRecipientByIdOptions = {
recipientId: number;
@@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipient.envelopeId,
},
type,
userId,
teamId,
});
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const legacyId = {
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
@@ -1,5 +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';
import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
@@ -8,21 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { isDeepEqual } from 'remeda';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { 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';
export interface SetDocumentRecipientsOptions {
userId: number;
@@ -79,19 +74,12 @@ 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 recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@@ -103,6 +91,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 +132,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);
@@ -275,46 +272,29 @@ export const setDocumentRecipients = async ({
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
// Send emails to deleted recipients who have emails.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
if (isRecipientRemovedEmailEnabled) {
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
inviterName: user.name || undefined,
},
});
}),
);
}
}
// Filter out recipients that have been removed or have been updated.
@@ -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);
@@ -6,22 +6,20 @@ import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constant
/**
* Compute and store `nextReminderAt` for a single recipient.
*
* Call this after:
* - Sending the signing email (sentAt is set)
* - Sending a reminder (lastReminderSentAt is updated)
*
* If `reminderSettings` is provided it's used directly, avoiding a query.
* Otherwise it's read from the envelope's documentMeta (already resolved
* from the org/team cascade at envelope creation time).
* Pass `resetReminderCount: true` to restart the reminder cycle (e.g. on a
* manual resend): the count is zeroed and the schedule recomputed as if the
* request was freshly sent at `sentAt`.
*/
export const updateRecipientNextReminder = async (options: {
recipientId: number;
envelopeId: string;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderCount?: number;
resetReminderCount?: boolean;
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
}) => {
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
const { recipientId, envelopeId, sentAt, lastReminderSentAt, reminderCount = 0, resetReminderCount } = options;
let settings = options.reminderSettings;
@@ -40,11 +38,15 @@ export const updateRecipientNextReminder = async (options: {
config: settings,
sentAt,
lastReminderSentAt,
reminderCount: resetReminderCount ? 0 : reminderCount,
});
await prisma.recipient.update({
where: { id: recipientId },
data: { nextReminderAt },
data: {
nextReminderAt,
...(resetReminderCount ? { reminderCount: 0 } : {}),
},
});
};
@@ -82,7 +84,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
// Don't reschedule reminders for recipients whose deadline has passed.
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true, sentAt: true, lastReminderSentAt: true },
select: { id: true, sentAt: true, lastReminderSentAt: true, reminderCount: true },
});
await Promise.all(
@@ -95,6 +97,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
config: settings,
sentAt: recipient.sentAt,
lastReminderSentAt: recipient.lastReminderSentAt,
reminderCount: recipient.reminderCount,
});
await prisma.recipient.update({
@@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateSharingIdOptions =
| {
@@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
),
select: {
id: true,
teamId: true,
},
});
@@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
.then((recipient) => recipient?.email);
})
.with({ userId: P.number }, async ({ userId }) => {
// Ensure the authenticated user actually has visibility-aware access to the
// envelope before allowing them to create a share link. The share route does
// not carry a teamId, so we derive it from the envelope and reuse the canonical
// visibility check (owner OR team member with sufficient visibility).
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId: envelope.teamId,
type: EnvelopeType.DOCUMENT,
});
const accessibleEnvelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
id: true,
},
});
if (!accessibleEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.user
.findFirst({
where: {
@@ -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;
};

Some files were not shown because too many files have changed in this diff Show More