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