mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
chore: merge origin/main into pr-2889 (resolve cancelled/expired status conflicts)
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;
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ export const DOCUMENT_STATUS: {
|
||||
[DocumentStatus.REJECTED]: {
|
||||
description: msg`Rejected`,
|
||||
},
|
||||
[DocumentStatus.CANCELLED]: {
|
||||
description: msg`Cancelled`,
|
||||
},
|
||||
[DocumentStatus.DRAFT]: {
|
||||
description: msg`Draft`,
|
||||
},
|
||||
|
||||
@@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
|
||||
*/
|
||||
export const MAX_REMINDER_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum number of automated reminders sent to a recipient before reminders
|
||||
* stop. A manual resend resets the count, re-arming reminders.
|
||||
*/
|
||||
export const MAX_REMINDERS_BEFORE_RESEND = 5;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
@@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer
|
||||
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
|
||||
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
|
||||
*
|
||||
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
|
||||
* any computed reminder beyond that point returns null so reminders stop.
|
||||
* Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS`
|
||||
* from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent.
|
||||
*
|
||||
* `sentAt` is when the signing request was sent to this specific recipient.
|
||||
*
|
||||
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
|
||||
* Returns the next Date the reminder should be sent, or null if none.
|
||||
*/
|
||||
export const resolveNextReminderAt = (options: {
|
||||
config: TEnvelopeReminderSettings | null;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderCount: number;
|
||||
}): Date | null => {
|
||||
const { config, sentAt, lastReminderSentAt } = options;
|
||||
const { config, sentAt, lastReminderSentAt, reminderCount } = options;
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
|
||||
|
||||
let candidate: Date;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSignatureFontFamily } from './pdf';
|
||||
|
||||
describe('getSignatureFontFamily', () => {
|
||||
const expectCaveat = (family: string) => expect(family).toBe('Caveat');
|
||||
const expectNotoChain = (family: string) => {
|
||||
expect(family).toContain('"Noto Sans"');
|
||||
expect(family).toContain('"Noto Sans Chinese"');
|
||||
expect(family).toContain('"Noto Sans Japanese"');
|
||||
expect(family).toContain('"Noto Sans Korean"');
|
||||
expect(family).toContain('sans-serif');
|
||||
expect(family).not.toContain('Caveat');
|
||||
};
|
||||
|
||||
it('returns Caveat for ASCII-only text', () => {
|
||||
expectCaveat(getSignatureFontFamily('John Doe'));
|
||||
expectCaveat(getSignatureFontFamily(''));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for any non-ASCII character', () => {
|
||||
expectNotoChain(getSignatureFontFamily('François'));
|
||||
expectNotoChain(getSignatureFontFamily('Müller'));
|
||||
expectNotoChain(getSignatureFontFamily('Søren'));
|
||||
expectNotoChain(getSignatureFontFamily('Иванов'));
|
||||
expectNotoChain(getSignatureFontFamily('Ελληνικά'));
|
||||
expectNotoChain(getSignatureFontFamily('عربي'));
|
||||
expectNotoChain(getSignatureFontFamily('עברית'));
|
||||
expectNotoChain(getSignatureFontFamily('도큐멘소'));
|
||||
expectNotoChain(getSignatureFontFamily('中文签名'));
|
||||
expectNotoChain(getSignatureFontFamily('こんにちは'));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for mixed ASCII + non-ASCII input', () => {
|
||||
expectNotoChain(getSignatureFontFamily('Hello 안녕'));
|
||||
expectNotoChain(getSignatureFontFamily('Ivan Ωmega'));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for scripts not covered by a dedicated Noto file', () => {
|
||||
expectNotoChain(getSignatureFontFamily('ሰላም')); // Ethiopic
|
||||
expectNotoChain(getSignatureFontFamily('សួស្ដី')); // Khmer
|
||||
expectNotoChain(getSignatureFontFamily('ᠮᠣᠩᠭᠣᠯ')); // Mongolian
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,20 @@ export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
|
||||
|
||||
const SIGNATURE_FONT_FAMILY_CAVEAT = 'Caveat';
|
||||
|
||||
// CN-before-JP: the JP Noto file's Han glyphs use JP shapes, so pure-CN
|
||||
// text would otherwise render with JP forms. Family names sync with
|
||||
// apps/remix/app/app.css and packages/lib/server-only/pdf/helpers.ts.
|
||||
const SIGNATURE_FONT_FAMILY_NOTO =
|
||||
'"Noto Sans", "Noto Sans Chinese", "Noto Sans Japanese", "Noto Sans Korean", sans-serif';
|
||||
|
||||
const isASCII = (str: string) => /^\p{ASCII}*$/u.test(str);
|
||||
|
||||
// Deliberately never mix handwriting + sans-serif within one signature.
|
||||
export const getSignatureFontFamily = (typedSignatureText: string): string =>
|
||||
isASCII(typedSignatureText) ? SIGNATURE_FONT_FAMILY_CAVEAT : SIGNATURE_FONT_FAMILY_NOTO;
|
||||
|
||||
export const PDF_SIZE_A4_72PPI = {
|
||||
width: 595,
|
||||
height: 842,
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum AppErrorCode {
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
|
||||
NOT_SETUP = 'NOT_SETUP',
|
||||
MISSING_ENV_VAR = 'MISSING_ENV_VAR',
|
||||
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
@@ -26,7 +27,37 @@ export enum AppErrorCode {
|
||||
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
|
||||
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
|
||||
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
|
||||
ENVELOPE_CANCELLED = 'ENVELOPE_CANCELLED',
|
||||
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
|
||||
/**
|
||||
* Authoring mutation rejected because the envelope is an AES/QES envelope
|
||||
* past DRAFT — the TSP mutation lock fires at distribution to preserve
|
||||
* WYSIWYS. SES envelopes never hit this code.
|
||||
*/
|
||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||
|
||||
/**
|
||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||
* for the recovery taxonomy.
|
||||
*/
|
||||
CSC_INSTANCE_MODE_MISMATCH = 'CSC_INSTANCE_MODE_MISMATCH',
|
||||
CSC_UNLICENSED = 'CSC_UNLICENSED',
|
||||
CSC_PROVIDER_INFO_FAILED = 'CSC_PROVIDER_INFO_FAILED',
|
||||
CSC_PROVIDER_NO_TSA = 'CSC_PROVIDER_NO_TSA',
|
||||
CSC_CREDENTIAL_LIST_EMPTY = 'CSC_CREDENTIAL_LIST_EMPTY',
|
||||
CSC_CERT_INVALID = 'CSC_CERT_INVALID',
|
||||
CSC_ALGORITHM_REFUSED = 'CSC_ALGORITHM_REFUSED',
|
||||
CSC_SAD_EXPIRED_PRE_SIGN = 'CSC_SAD_EXPIRED_PRE_SIGN',
|
||||
CSC_TSP_TIMEOUT = 'CSC_TSP_TIMEOUT',
|
||||
CSC_EMBED_FAILED = 'CSC_EMBED_FAILED',
|
||||
CSC_BASE_DOCUMENT_MUTATED = 'CSC_BASE_DOCUMENT_MUTATED',
|
||||
/**
|
||||
* Generic catch-all for CSC HTTP transport failures — network error, non-2xx
|
||||
* response without a more specific semantic match, malformed JSON, or
|
||||
* response schema mismatch. Carries the TSP's HTTP status in `statusCode`
|
||||
* and the TSP's `error` / `error_description` in the message when available.
|
||||
*/
|
||||
CSC_REQUEST_FAILED = 'CSC_REQUEST_FAILED',
|
||||
}
|
||||
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = {
|
||||
@@ -39,6 +70,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
|
||||
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
|
||||
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_ENV_VAR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -49,7 +81,25 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.CSC_PROVIDER_NO_TSA]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_CERT_INVALID]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_ALGORITHM_REFUSED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_TSP_TIMEOUT]: { code: 'TIMEOUT', status: 408 },
|
||||
[AppErrorCode.CSC_EMBED_FAILED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_BASE_DOCUMENT_MUTATED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
// Generic transport failure — the TSP is upstream so server-side from our
|
||||
// perspective; 500 keeps the caller surface conservative. The TSP's actual
|
||||
// HTTP status rides along in AppError.statusCode for the few callers that
|
||||
// need to discriminate (e.g. 401 → re-auth, 429 → backoff).
|
||||
[AppErrorCode.CSC_REQUEST_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
@@ -238,11 +288,19 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_DRAFT,
|
||||
AppErrorCode.ENVELOPE_COMPLETED,
|
||||
AppErrorCode.ENVELOPE_REJECTED,
|
||||
AppErrorCode.ENVELOPE_CANCELLED,
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
AppErrorCode.CSC_ALGORITHM_REFUSED,
|
||||
AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN,
|
||||
AppErrorCode.CSC_EMBED_FAILED,
|
||||
() => 400 as const,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
|
||||
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
|
||||
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
|
||||
.otherwise(() => 500 as const);
|
||||
|
||||
@@ -4,10 +4,14 @@ import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/sen
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
|
||||
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
|
||||
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
|
||||
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email';
|
||||
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
|
||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
|
||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
@@ -36,15 +40,19 @@ export const jobsClient = new JobClient([
|
||||
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
|
||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
|
||||
SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION,
|
||||
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
|
||||
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
|
||||
@@ -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,70 @@
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendDocumentDeletedEmailsJobDefinition } from './send-document-deleted-emails';
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TSendDocumentDeletedEmailsJobDefinition; io: JobRunIO }) => {
|
||||
const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
|
||||
// Don't send cancellation emails if the organisation has email sending
|
||||
// disabled. Re-checked here (not just at enqueue time) because the org can be
|
||||
// disabled between the delete request and this job running.
|
||||
if (emailsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => {
|
||||
if (!isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName,
|
||||
inviterName: inviterName || undefined,
|
||||
inviterEmail,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails';
|
||||
|
||||
const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
|
||||
teamId: z.number(),
|
||||
documentName: z.string(),
|
||||
inviterName: z.string().optional(),
|
||||
inviterEmail: z.string(),
|
||||
/**
|
||||
* The document's email meta (sender, reply-to, language). Captured before the
|
||||
* envelope is hard-deleted so `getEmailContext` resolves the exact same
|
||||
* sender/reply-to/language the inline send used.
|
||||
*/
|
||||
meta: z
|
||||
.object({
|
||||
emailId: z.string().nullable().optional(),
|
||||
emailReplyTo: z.string().nullable().optional(),
|
||||
language: z.string().optional(),
|
||||
})
|
||||
.nullable(),
|
||||
recipients: z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type TSendDocumentDeletedEmailsJobDefinition = z.infer<
|
||||
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = {
|
||||
id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
|
||||
name: 'Send Document Deleted Emails',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
|
||||
schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-document-deleted-emails.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID,
|
||||
TSendDocumentDeletedEmailsJobDefinition
|
||||
>;
|
||||
+21
-24
@@ -1,28 +1,24 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { createElement } from 'react';
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => {
|
||||
const { envelopeId, recipientId } = payload;
|
||||
|
||||
export interface SendPendingEmailOptions {
|
||||
id: EnvelopeIdOptions;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT),
|
||||
recipients: {
|
||||
some: {
|
||||
id: recipientId,
|
||||
@@ -39,15 +35,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new Error('Document not found');
|
||||
if (!envelope || envelope.recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -56,6 +48,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
// Don't send any emails if the organisation has email sending disabled.
|
||||
if (emailsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentPending;
|
||||
|
||||
if (!isDocumentPendingEmailEnabled) {
|
||||
@@ -89,7 +86,7 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
|
||||
|
||||
const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
envelopeId: z.string(),
|
||||
recipientId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendDocumentPendingEmailJobDefinition = z.infer<typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Document Pending Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-document-pending-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendDocumentPendingEmailJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import OrganisationLimitAlertEmailTemplate from '@documenso/email/templates/organisation-limit-alert';
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { createElement } from 'react';
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { INTERNAL_CLAIM_ID } from '../../../types/subscription';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendOrganisationLimitAlertEmailJobDefinition } from './send-organisation-limit-alert-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TSendOrganisationLimitAlertEmailJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
monthlyStats: {
|
||||
where: {
|
||||
period: payload.period,
|
||||
},
|
||||
select: {
|
||||
documentCount: true,
|
||||
emailCount: true,
|
||||
apiCount: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
where: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
group: {
|
||||
organisationRole: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Do not send emails for "free" claims.
|
||||
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE) {
|
||||
io.logger.info({
|
||||
msg: 'Skipping organisation limit alert email for "free" claim',
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const memberSubject =
|
||||
payload.kind === 'quotaNearing' ? msg`Approaching Your Plan Limits` : msg`Organisation Review Required`;
|
||||
|
||||
for (const member of organisation.members) {
|
||||
await io.runTask(`send-organisation-limit-alert-email-${member.id}`, async () => {
|
||||
const emailContent = createElement(OrganisationLimitAlertEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
organisationName: organisation.name,
|
||||
counter: payload.counter,
|
||||
kind: payload.kind,
|
||||
period: payload.period,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await emailTransport.sendMail({
|
||||
to: member.user.email,
|
||||
from: senderEmail,
|
||||
subject: i18n._(memberSubject),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Todo: Logging
|
||||
const i18n = await getI18nInstance('en');
|
||||
|
||||
const supportSubject =
|
||||
payload.kind === 'quotaNearing'
|
||||
? msg`An organisation is nearing their fair use limits`
|
||||
: msg`An organisation has exceeded their fair use limits`;
|
||||
|
||||
// Email our support team. Purposefully sent from the internal email since the
|
||||
// global mailer is not authorized to send from custom per-plan transport addresses.
|
||||
await io.runTask('send-organisation-limit-alert-support-email', async () => {
|
||||
await mailer.sendMail({
|
||||
to: SUPPORT_EMAIL,
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject: i18n._(supportSubject),
|
||||
text: `
|
||||
Organisation: ${organisation.name}
|
||||
Organisation ID: ${organisation.id}
|
||||
Organisation Claim Original ID: ${organisation.organisationClaim.originalSubscriptionClaimId}
|
||||
Email Quota: ${organisation.monthlyStats[0]?.emailCount || 0}/${organisation.organisationClaim.emailQuota ?? 'Unlimited'}
|
||||
API Quota: ${organisation.monthlyStats[0]?.apiCount || 0}/${organisation.organisationClaim.apiQuota ?? 'Unlimited'}
|
||||
Document Quota: ${organisation.monthlyStats[0]?.documentCount || 0}/${organisation.organisationClaim.documentQuota ?? 'Unlimited'}
|
||||
Counter: ${payload.counter}
|
||||
Kind: ${payload.kind}
|
||||
Period: ${payload.period}
|
||||
`,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limit-alert.email';
|
||||
|
||||
const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
organisationId: z.string(),
|
||||
counter: z.enum(['document', 'email', 'api']),
|
||||
kind: z.enum(['rateLimit', 'quota', 'quotaNearing']),
|
||||
period: z.string(),
|
||||
});
|
||||
|
||||
export type TSendOrganisationLimitAlertEmailJobDefinition = z.infer<
|
||||
typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Organisation Limit Alert Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-organisation-limit-alert-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendOrganisationLimitAlertEmailJobDefinition
|
||||
>;
|
||||
+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,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email';
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => {
|
||||
const { envelopeId, recipientEmail, recipientName, inviterName } = payload;
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The envelope may have been deleted between the recipient removal and this
|
||||
// job running. Treat as a no-op so the job doesn't retry forever.
|
||||
if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-checked at send time (not just at enqueue) so the email honors the
|
||||
// document's current "recipient removed" setting.
|
||||
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
|
||||
|
||||
if (!isRecipientRemovedEmailEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
// Don't send the removal email if the organisation has email sending disabled.
|
||||
if (emailsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Meter the removal email against the organisation email quota/stats.
|
||||
// Add/remove churn can be used to blast unsolicited removal emails outside
|
||||
// the email limits.
|
||||
try {
|
||||
await assertOrganisationRatesAndLimits({
|
||||
organisationId,
|
||||
organisationClaim: claims,
|
||||
type: 'email',
|
||||
count: 1,
|
||||
});
|
||||
} catch (_err) {
|
||||
io.logger.warn({
|
||||
msg: 'Recipient removed email dropped: org email limit exceeded',
|
||||
organisationId,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||
documentName: envelope.title,
|
||||
inviterName: inviterName || undefined,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await io.runTask('send-recipient-removed-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: recipientEmail,
|
||||
name: recipientName,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email';
|
||||
|
||||
const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
envelopeId: z.string(),
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
inviterName: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TSendRecipientRemovedEmailJobDefinition = z.infer<
|
||||
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Recipient Removed Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-recipient-removed-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendRecipientRemovedEmailJobDefinition
|
||||
>;
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -75,7 +74,7 @@ export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJ
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -105,7 +104,7 @@ export const run = async ({ payload, io }: { payload: TSendRecipientSignedEmailJ
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
name: owner.name ?? '',
|
||||
address: owner.email,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -75,8 +75,10 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// Send confirmation email to the recipient who rejected
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
// Send confirmation email to the recipient who rejected.
|
||||
// Skipped when the organisation has email sending disabled, since this is sent on its behalf.
|
||||
// The owner notification below intentionally uses the internal Documenso email, so it still sends.
|
||||
if (!emailsDisabled && isRecipientEmailValidForSending(recipient)) {
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
@@ -95,7 +97,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningRejectionEmail
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -16,8 +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 { assertOrgEmailSendAllowed } from '../../../server-only/email/assert-org-email-send-allowed';
|
||||
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';
|
||||
@@ -55,6 +55,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
teamEmail: true,
|
||||
@@ -84,15 +89,30 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
const {
|
||||
branding,
|
||||
emailLanguage,
|
||||
settings,
|
||||
organisationType,
|
||||
senderEmail,
|
||||
replyToEmail,
|
||||
organisationId,
|
||||
claims,
|
||||
emailsDisabled,
|
||||
emailTransport,
|
||||
} = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
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;
|
||||
@@ -146,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,
|
||||
@@ -161,22 +182,26 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
teamName: team?.name,
|
||||
teamEmail: team?.teamEmail?.email,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
reportUrl,
|
||||
});
|
||||
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
||||
|
||||
if (!sendCheck.allowed) {
|
||||
// TEMPORARY: silent drop on rate-limit hit. Job is consumed and NOT retried.
|
||||
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,
|
||||
reason: sendCheck.reason,
|
||||
resetsAt: sendCheck.resetsAt,
|
||||
});
|
||||
|
||||
// Job is consumed and NOT retried.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,7 +215,7 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
@@ -200,6 +225,11 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
|
||||
subject: renderCustomEmailTemplate(documentMeta?.subject || emailSubject, customEmailTemplate),
|
||||
html,
|
||||
text,
|
||||
headers: buildEnvelopeEmailHeaders({
|
||||
userId,
|
||||
envelopeId: envelope.id,
|
||||
teamId: envelope.teamId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes';
|
||||
import { deleteOrganisation } from '../../../server-only/organisation/delete-organisation';
|
||||
import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email';
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation';
|
||||
|
||||
@@ -52,41 +50,24 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
|
||||
const ownerEmail = organisation.owner.email;
|
||||
|
||||
const emailContext = await io.runTask('get-email-context', async () => {
|
||||
return await getEmailContext({
|
||||
const { emailTransport: _emailTransport, ...serializableContext } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
return serializableContext;
|
||||
});
|
||||
|
||||
// 1. Orphan all envelopes for every team.
|
||||
for (const team of organisation.teams) {
|
||||
await io.runTask(`orphan-envelopes--team-${team.id}`, async () => {
|
||||
await orphanEnvelopes({ teamId: team.id });
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts.
|
||||
// 1. Orphan envelopes, delete the organisation, and schedule the Stripe
|
||||
// subscription cancellation. Shared with organisation-router/delete-organisation.ts.
|
||||
await io.runTask('delete-organisation', async () => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
await deleteOrganisation({ organisation });
|
||||
});
|
||||
|
||||
// 3. Send the owner notification.
|
||||
// 2. Send the owner notification.
|
||||
if (sendEmailToOwner) {
|
||||
await io.runTask('send-organisation-deleted-email', async () => {
|
||||
await sendOrganisationDeleteEmail({
|
||||
@@ -97,17 +78,4 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period.
|
||||
if (organisation.subscription) {
|
||||
const stripeSubscriptionId = organisation.subscription.planId;
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.cancel-organisation-subscription',
|
||||
payload: {
|
||||
stripeSubscriptionId,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
cfr21: z.literal(true).optional(),
|
||||
hipaa: z.literal(true).optional(),
|
||||
signingReminders: z.literal(true).optional(),
|
||||
cscQesSigning: z.literal(true).optional(),
|
||||
// Do NOT backport disableEmails.
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
}),
|
||||
@@ -38,7 +40,7 @@ export const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION = {
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./backport-subscription-claims.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
await handler.run({ payload: BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA.parse(payload), io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_ID,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
@@ -164,7 +163,7 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -186,7 +185,7 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
name: user.name || '',
|
||||
address: user.email,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -16,7 +15,9 @@ import { createElement } from 'react';
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
|
||||
import { buildEnvelopeEmailHeaders } from '../../../server-only/email/build-envelope-email-headers';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits';
|
||||
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
|
||||
@@ -51,6 +52,7 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
||||
data: {
|
||||
lastReminderSentAt: now,
|
||||
nextReminderAt: null,
|
||||
reminderCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,7 +102,17 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const {
|
||||
branding,
|
||||
emailLanguage,
|
||||
organisationType,
|
||||
senderEmail,
|
||||
replyToEmail,
|
||||
organisationId,
|
||||
claims,
|
||||
emailsDisabled,
|
||||
emailTransport,
|
||||
} = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -109,6 +121,13 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
// Don't send reminders if the owner is disabled (e.g. banned) or the organisation
|
||||
// has email sending disabled.
|
||||
if (envelope.user.disabled || emailsDisabled) {
|
||||
io.logger.info(`Envelope ${envelope.id} skipping reminder: owner disabled or organisation emails disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
const recipientActionVerb = i18n._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb).toLowerCase();
|
||||
@@ -138,70 +157,102 @@ export const run = async ({ payload, io }: { payload: TProcessSigningReminderJob
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
|
||||
|
||||
io.logger.info(
|
||||
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
|
||||
);
|
||||
|
||||
const template = createElement(DocumentReminderEmailTemplate, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: emailMessage,
|
||||
role: recipient.role,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: emailSubject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
// Meter reminder emails against the organisation email quota/stats. Reminders
|
||||
// are unsolicited (the recipient didn't opt in to them) and can recur, so they
|
||||
// must be bounded by the same org limits as other outbound emails.
|
||||
const isRateLimited = await assertOrganisationRatesAndLimits({
|
||||
organisationId,
|
||||
organisationClaim: claims,
|
||||
type: 'email',
|
||||
count: 1,
|
||||
})
|
||||
.then(() => false)
|
||||
.catch((_err) => {
|
||||
io.logger.warn({
|
||||
msg: 'Signing reminder dropped: org email limit exceeded',
|
||||
organisationId,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
|
||||
isResending: false,
|
||||
envelopeId: envelope.id,
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!isRateLimited) {
|
||||
io.logger.info(
|
||||
`Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
|
||||
);
|
||||
|
||||
const template = createElement(DocumentReminderEmailTemplate, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: emailMessage,
|
||||
role: recipient.role,
|
||||
reportUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
}),
|
||||
});
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: emailSubject,
|
||||
html,
|
||||
text,
|
||||
headers: buildEnvelopeEmailHeaders({
|
||||
userId: envelope.userId,
|
||||
envelopeId: envelope.id,
|
||||
teamId: envelope.teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
envelopeId: envelope.id,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Compute the next reminder time (repeat interval).
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
}
|
||||
|
||||
// reminderCount was incremented in the atomic claim above, so the value read
|
||||
// here includes the reminder we just sent and gates the next one.
|
||||
if (recipient.sentAt) {
|
||||
await updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt: recipient.sentAt,
|
||||
lastReminderSentAt: now,
|
||||
reminderCount: recipient.reminderCount,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { finalizeTspEnvelopeCompletion } from '@documenso/ee/server-only/signing/csc/finalize-tsp-completion';
|
||||
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
@@ -22,6 +23,7 @@ import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
|
||||
import { isTspEnvelope } from '../../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
|
||||
import { prefixedId } from '../../../universal/id';
|
||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||
@@ -164,6 +166,33 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
|
||||
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
if (isResealing) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Re-sealing TSP envelopes is not supported — recipient signatures cannot be regenerated externally.',
|
||||
});
|
||||
}
|
||||
|
||||
if (isRejected) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message:
|
||||
'TSP envelope rejection is not supported in V1 — rejection stamps would invalidate PAdES signatures.',
|
||||
});
|
||||
}
|
||||
|
||||
await finalizeTspEnvelopeCompletion({
|
||||
envelope,
|
||||
envelopeCompletedAuditLog,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return {
|
||||
envelopeId: envelope.id,
|
||||
envelopeStatus: envelope.status,
|
||||
isRejected,
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-fetch all PDF data so we can read dimensions and pass it
|
||||
// to decorateAndSignPdf without fetching again.
|
||||
const prefetchedItems = await Promise.all(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -79,7 +78,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
|
||||
email: recipient.email,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -108,7 +107,7 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
|
||||
|
||||
// Send email outside any transaction to avoid holding a connection
|
||||
// open during network I/O.
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, type Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
|
||||
@@ -9,6 +10,16 @@ export interface AdminFindDocumentsOptions {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
|
||||
|
||||
const emptyResponse = {
|
||||
data: [],
|
||||
count: 0,
|
||||
currentPage: 1,
|
||||
perPage: 10,
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: AdminFindDocumentsOptions) => {
|
||||
let termFilters: Prisma.EnvelopeWhereInput | undefined = !query
|
||||
? undefined
|
||||
@@ -19,7 +30,35 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
},
|
||||
};
|
||||
|
||||
if (query && query.startsWith('envelope_')) {
|
||||
if (query?.startsWith('user:')) {
|
||||
const parsedUserId = ZPositiveIntegerSchema.safeParse(query.slice('user:'.length));
|
||||
|
||||
if (parsedUserId.success) {
|
||||
termFilters = {
|
||||
userId: {
|
||||
equals: parsedUserId.data,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return emptyResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (query?.startsWith('team:')) {
|
||||
const parsedTeamId = ZPositiveIntegerSchema.safeParse(query.slice('team:'.length));
|
||||
|
||||
if (parsedTeamId.success) {
|
||||
termFilters = {
|
||||
teamId: {
|
||||
equals: parsedTeamId.data,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return emptyResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('envelope_')) {
|
||||
termFilters = {
|
||||
id: {
|
||||
equals: query,
|
||||
@@ -27,7 +66,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query.startsWith('document_')) {
|
||||
if (query && query?.startsWith('document_')) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: query,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -45,7 +44,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
|
||||
});
|
||||
}
|
||||
|
||||
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const { branding, settings, senderEmail, replyToEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -89,7 +88,7 @@ export const adminSuperDeleteDocument = async ({ envelopeId, requestMetadata }:
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const getDocumentStats = async () => {
|
||||
[ExtendedDocumentStatus.PENDING]: 0,
|
||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
||||
[ExtendedDocumentStatus.CANCELLED]: 0,
|
||||
[ExtendedDocumentStatus.ALL]: 0,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export type OrganisationSummary = {
|
||||
activeDocuments: number;
|
||||
completedDocuments: number;
|
||||
volumeThisPeriod: number;
|
||||
documentsThisPeriod: number;
|
||||
volumeAllTime: number;
|
||||
};
|
||||
|
||||
@@ -305,6 +306,10 @@ async function getOrganisationSummary(
|
||||
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)`
|
||||
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`
|
||||
).as('volumeThisPeriod'),
|
||||
(createdAtFrom
|
||||
? sql<number>`count(case when e."createdAt" >= ${createdAtFrom} then 1 end)`
|
||||
: sql<number>`count(e.id)`
|
||||
).as('documentsThisPeriod'),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -321,6 +326,7 @@ async function getOrganisationSummary(
|
||||
activeDocuments: Number(envelopeStats?.activeDocuments || 0),
|
||||
completedDocuments: Number(envelopeStats?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0),
|
||||
documentsThisPeriod: Number(envelopeStats?.documentsThisPeriod || 0),
|
||||
volumeAllTime: Number(envelopeStats?.volumeAllTime || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,9 +32,12 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const token = user.passwordResetTokens[0].token;
|
||||
const token = user.passwordResetTokens[0]?.token;
|
||||
if (!token) {
|
||||
throw new Error('Password reset token not found for user');
|
||||
}
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
|
||||
const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`;
|
||||
|
||||
const template = createElement(ForgotPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
|
||||
@@ -31,9 +31,13 @@ export const loadRecipientBrandingByTeamId = async ({
|
||||
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
let allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
|
||||
|
||||
if (!settings.brandingEnabled) {
|
||||
allowCustomBranding = false;
|
||||
}
|
||||
|
||||
if (!allowCustomBranding) {
|
||||
return {
|
||||
allowCustomBranding: false,
|
||||
|
||||
@@ -8,7 +8,10 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
|
||||
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
|
||||
|
||||
export type CreateDocumentMetaOptions = {
|
||||
userId: number;
|
||||
@@ -73,6 +76,22 @@ export const updateDocumentMeta = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await assertEnvelopeMutable(envelope);
|
||||
|
||||
if (signingOrder !== undefined) {
|
||||
assertCompatibleSigningOrder({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
signingOrder,
|
||||
});
|
||||
}
|
||||
|
||||
if (allowDictateNextSigner !== undefined) {
|
||||
assertCompatibleDictateNextSigner({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
allowDictateNextSigner,
|
||||
});
|
||||
}
|
||||
|
||||
const { documentMeta: originalDocumentMeta } = envelope;
|
||||
|
||||
// Validate the emailId belongs to the organisation.
|
||||
@@ -92,6 +111,8 @@ export const updateDocumentMeta = async ({
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const upsertedDocumentMeta = await tx.documentMeta.update({
|
||||
where: {
|
||||
id: envelope.documentMetaId,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isMemberManagerOrAbove } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CancelDocumentOptions = {
|
||||
id: EnvelopeIdOptions;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
reason?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
|
||||
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
|
||||
include: {
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserOwner = envelope.userId === userId;
|
||||
|
||||
const teamRole = await getMemberRoles({
|
||||
teamId: envelope.teamId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
.then((roles) => roles.teamRole)
|
||||
.catch(() => null);
|
||||
|
||||
const isUserTeamMember = teamRole !== null;
|
||||
|
||||
// Callers with no relationship to the document must not be able to determine
|
||||
// whether it exists, so respond as if it was not found.
|
||||
if (!isUserOwner && !isUserTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
|
||||
|
||||
// The document is visible to the caller, but cancelling requires elevated permissions.
|
||||
if (!isUserOwner && !isPrivilegedTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Not allowed',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Only pending documents can be cancelled',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.CANCELLED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
reason,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Send cancellation emails to recipients via the resilient background job.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger the webhook with the updated (cancelled) envelope payload.
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(
|
||||
mapEnvelopeToWebhookDocumentPayload({
|
||||
...envelope,
|
||||
status: updatedEnvelope.status,
|
||||
completedAt: updatedEnvelope.completedAt,
|
||||
}),
|
||||
),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return updatedEnvelope;
|
||||
};
|
||||
@@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@@ -389,7 +388,13 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
|
||||
if (pendingRecipients.length > 0) {
|
||||
await sendPendingEmail({ id, recipientId: recipient.id });
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.pending.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
import { createElement } from 'react';
|
||||
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
@@ -17,7 +12,6 @@ import { isDocumentCompleted } from '../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -126,7 +120,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
const { emailLanguage, emailsDisabled } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -187,50 +181,46 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
|
||||
|
||||
const isEnvelopeDeleteEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentDeleted;
|
||||
|
||||
if (!isEnvelopeDeleteEmailEnabled) {
|
||||
// Skip sending if the email is disabled for this document or the organisation
|
||||
// has email sending disabled entirely.
|
||||
if (!isEnvelopeDeleteEmailEnabled || emailsDisabled) {
|
||||
return deletedEnvelope;
|
||||
}
|
||||
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
// Enqueue cancellation emails as a background job. The envelope (and its
|
||||
// documentMeta) is hard-deleted above, so the job can't look it up later —
|
||||
// pass a self-contained payload with the recipients to notify.
|
||||
const recipientsToNotify = envelope.recipients
|
||||
.filter(
|
||||
(recipient) =>
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
)
|
||||
.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
}));
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
if (recipientsToNotify.length > 0) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.deleted.emails',
|
||||
payload: {
|
||||
teamId: envelope.teamId,
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document Cancelled`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}),
|
||||
);
|
||||
meta: envelope.documentMeta
|
||||
? {
|
||||
emailId: envelope.documentMeta.emailId,
|
||||
emailReplyTo: envelope.documentMeta.emailReplyTo,
|
||||
language: emailLanguage,
|
||||
}
|
||||
: null,
|
||||
recipients: recipientsToNotify,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return deletedEnvelope;
|
||||
};
|
||||
|
||||
@@ -248,7 +248,11 @@ export const findDocuments = async ({
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
|
||||
eb('Envelope.status', 'in', [
|
||||
sql.lit(DocumentStatus.COMPLETED),
|
||||
sql.lit(DocumentStatus.PENDING),
|
||||
sql.lit(DocumentStatus.CANCELLED),
|
||||
]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
]),
|
||||
@@ -319,6 +323,16 @@ export const findDocuments = async ({
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||
qb.where((eb) =>
|
||||
eb.and([
|
||||
@@ -466,6 +480,18 @@ export const findDocuments = async ({
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.CANCELLED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.EXPIRED, () =>
|
||||
qb.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
@@ -256,6 +256,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// CANCELLED: team-owned cancelled + team-email received cancelled docs
|
||||
const cancelledQuery = buildBaseQuery()
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
|
||||
.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
});
|
||||
|
||||
// EXPIRED: docs visible to the team/user with at least one expired, unsigned recipient.
|
||||
// Access control mirrors the EXPIRED branch in findDocuments so the count matches the listing.
|
||||
const expiredQuery = buildBaseQuery().where((eb) => {
|
||||
@@ -290,23 +304,25 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
|
||||
// ─── Execute all counts in parallel ──────────────────────────────────
|
||||
|
||||
const [draft, pending, completed, rejected, expired, inbox] = await Promise.all([
|
||||
const [draft, pending, completed, rejected, cancelled, expired, inbox] = await Promise.all([
|
||||
cappedCount(draftQuery),
|
||||
cappedCount(pendingQuery),
|
||||
cappedCount(completedQuery),
|
||||
cappedCount(rejectedQuery),
|
||||
cappedCount(cancelledQuery),
|
||||
cappedCount(expiredQuery),
|
||||
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
// `expired` is intentionally excluded from `all` — it overlaps PENDING.
|
||||
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
|
||||
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
|
||||
|
||||
const stats: Record<ExtendedDocumentStatus, number> = {
|
||||
[ExtendedDocumentStatus.DRAFT]: draft,
|
||||
[ExtendedDocumentStatus.PENDING]: pending,
|
||||
[ExtendedDocumentStatus.COMPLETED]: completed,
|
||||
[ExtendedDocumentStatus.REJECTED]: rejected,
|
||||
[ExtendedDocumentStatus.CANCELLED]: cancelled,
|
||||
[ExtendedDocumentStatus.EXPIRED]: expired,
|
||||
[ExtendedDocumentStatus.INBOX]: inbox,
|
||||
[ExtendedDocumentStatus.ALL]: all,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// This is closely related to `reject-document-with-token.ts` but is intentionally
|
||||
// kept as a separate method rather than merged into one. This file focuses on
|
||||
// rejection from an API/programmatic perspective (an authenticated API user acting
|
||||
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
|
||||
// from a recipient perspective (the recipient rejecting via their token).
|
||||
//
|
||||
// Code changes in one should probably be mirrored to the other, particularly in
|
||||
// relation to the jobs triggered after a rejection.
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { assertRecipientNotExpired } from '../../utils/recipients';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type RejectDocumentOnBehalfOfOptions = {
|
||||
/**
|
||||
* The ID of the envelope the recipient belongs to. Required so the caller
|
||||
* targets an explicit envelope/recipient combination rather than resolving the
|
||||
* envelope implicitly from the recipient ID.
|
||||
*/
|
||||
envelopeId: string;
|
||||
recipientId: number;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
reason: string;
|
||||
/**
|
||||
* The email of a team member to attribute the rejection to. Must be a member
|
||||
* of the team. When omitted the rejection is attributed to the API user that
|
||||
* owns the token (`userId`).
|
||||
*
|
||||
* This exists so external applications can elect which team member is acting
|
||||
* on behalf of the recipient, rather than always defaulting to the API user.
|
||||
*/
|
||||
actAsEmail?: string;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject a document on behalf of a recipient as an authenticated API user.
|
||||
*
|
||||
* This is used to programmatically record a rejection for cases where the
|
||||
* recipient declined to sign outside of the platform (e.g. before ever
|
||||
* reaching it). The rejection is flagged as `isExternal` in the audit log to
|
||||
* distinguish it from a rejection performed by the recipient directly.
|
||||
*
|
||||
* The action can optionally be attributed to a specific team member via
|
||||
* `actAsEmail`; otherwise it is attributed to the API user.
|
||||
*/
|
||||
export async function rejectDocumentOnBehalfOf({
|
||||
envelopeId,
|
||||
recipientId,
|
||||
userId,
|
||||
teamId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
requestMetadata,
|
||||
}: RejectDocumentOnBehalfOfOptions) {
|
||||
// Build the access-controlled envelope query. This enforces team membership
|
||||
// AND document visibility (and owner / team-email access), mirroring the
|
||||
// canonical envelope access checks used across the app.
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelope: envelopeWhereInput,
|
||||
},
|
||||
include: {
|
||||
envelope: true,
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = recipient?.envelope;
|
||||
|
||||
if (!recipient || !envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document or recipient not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document ${envelope.id} must be pending to reject`,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} has already actioned this document`,
|
||||
});
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// Resolve the user the rejection should be attributed to. When `actAsEmail`
|
||||
// is supplied it must resolve to a member of the team; otherwise the rejection
|
||||
// is attributed to the API user that owns the token.
|
||||
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
|
||||
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
|
||||
|
||||
// Update the recipient status to rejected and record an external rejection
|
||||
// audit log within the same transaction.
|
||||
const [updatedRecipient] = await prisma.$transaction([
|
||||
prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
signedAt: new Date(),
|
||||
signingStatus: SigningStatus.REJECTED,
|
||||
rejectionReason: reason,
|
||||
},
|
||||
}),
|
||||
prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
envelopeId: envelope.id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
||||
// Always attribute the audit log to a concrete user: the elected team
|
||||
// member when supplied, otherwise the API user that owns the token.
|
||||
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
|
||||
metadata: requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
reason,
|
||||
isExternal: true,
|
||||
// Only set when a member was explicitly elected via `actAsEmail`.
|
||||
onBehalfOfUserEmail: electedUser?.email,
|
||||
onBehalfOfUserName: electedUser?.name,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Trigger the seal document job to process the document asynchronously.
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
// Send email notifications to the rejecting recipient.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.rejected.emails',
|
||||
payload: {
|
||||
recipientId: recipient.id,
|
||||
documentId: legacyDocumentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Send cancellation emails to other recipients.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.cancelled.emails',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
cancellationReason: reason,
|
||||
requestMetadata: requestMetadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
|
||||
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
|
||||
* user). Throws when the email does not resolve to a member of the team.
|
||||
*/
|
||||
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
|
||||
if (!actAsEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const electedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: actAsEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (!electedUser) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'The user to act on behalf of must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
const isTeamMember = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
|
||||
});
|
||||
|
||||
if (!isTeamMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'The user to act on behalf of must be a member of the team',
|
||||
});
|
||||
}
|
||||
|
||||
return electedUser;
|
||||
};
|
||||
@@ -1,3 +1,11 @@
|
||||
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
|
||||
// kept as a separate method rather than merged into one. This file focuses on
|
||||
// rejection from a recipient perspective (the recipient rejecting via their token),
|
||||
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
|
||||
// perspective (an authenticated API user acting on behalf of a recipient).
|
||||
//
|
||||
// Code changes in one should probably be mirrored to the other, particularly in
|
||||
// relation to the jobs triggered after a rejection.
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
|
||||
@@ -1,8 +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, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
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';
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
EnvelopeType,
|
||||
OrganisationType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
@@ -27,9 +27,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 { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
|
||||
import { buildEnvelopeEmailHeaders } from '../email/build-envelope-email-headers';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder';
|
||||
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -74,6 +76,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
select: {
|
||||
teamEmail: true,
|
||||
name: true,
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: {
|
||||
select: {
|
||||
recipientCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -95,7 +106,19 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
// Refresh the expiresAt on each resent recipient.
|
||||
// A recipientCount of 0 means unlimited recipients are allowed. Block resending
|
||||
// when the document has more recipients than the organisation is allowed to send
|
||||
// to, mirroring the check in `sendDocument`. This prevents bypassing the limit by
|
||||
// adding recipients to an already-sent document and then resending.
|
||||
const maximumRecipientCount = envelope.team.organisation.organisationClaim.recipientCount;
|
||||
|
||||
if (maximumRecipientCount > 0 && envelope.recipients.length > maximumRecipientCount) {
|
||||
throw new AppError('RECIPIENT_LIMIT_EXCEEDED', {
|
||||
message: `You cannot send a document with more than ${maximumRecipientCount} recipients`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
|
||||
|
||||
const recipientsToRemind = envelope.recipients.filter(
|
||||
@@ -105,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
recipient.role !== RecipientRole.CC,
|
||||
);
|
||||
|
||||
// Extend the expiration deadline for recipients being resent.
|
||||
if (expiresAt && recipientsToRemind.length > 0) {
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
@@ -120,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
});
|
||||
}
|
||||
|
||||
// A manual resend restarts the reminder cycle from scratch, mirroring the
|
||||
// initial send, so a recipient that hit the threshold can be reminded again.
|
||||
const resentAt = new Date();
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map((recipient) =>
|
||||
updateRecipientNextReminder({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
sentAt: resentAt,
|
||||
lastReminderSentAt: null,
|
||||
resetReminderCount: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
|
||||
envelope.documentMeta,
|
||||
).recipientSigningRequest;
|
||||
@@ -128,15 +166,37 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } =
|
||||
await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
const {
|
||||
branding,
|
||||
emailLanguage,
|
||||
organisationType,
|
||||
senderEmail,
|
||||
replyToEmail,
|
||||
organisationId,
|
||||
claims,
|
||||
emailsDisabled,
|
||||
emailTransport,
|
||||
} = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
},
|
||||
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) => {
|
||||
@@ -180,6 +240,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||
const reportUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/report/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: envelope.title,
|
||||
@@ -195,6 +256,7 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
selfSigner,
|
||||
organisationType,
|
||||
teamName: envelope.team?.name,
|
||||
reportUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
@@ -209,18 +271,9 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
}),
|
||||
]);
|
||||
|
||||
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
|
||||
|
||||
if (!sendCheck.allowed) {
|
||||
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
|
||||
message: 'Organisation email send rate limit exceeded',
|
||||
userMessage: 'Email send rate limit reached. Please try again in a few minutes.',
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -232,6 +285,23 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
|
||||
: emailSubject,
|
||||
html,
|
||||
text,
|
||||
headers: buildEnvelopeEmailHeaders({
|
||||
userId: envelope.userId,
|
||||
envelopeId: envelope.id,
|
||||
teamId: envelope.teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
// Mark the recipient as sent if they were not already sent.
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -46,7 +45,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
|
||||
return;
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'team',
|
||||
@@ -76,7 +75,7 @@ export const sendDeleteEmail = async ({ envelopeId, reason }: SendDeleteEmailOpt
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name: name || '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
|
||||
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
ZRadioFieldMeta,
|
||||
ZTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
@@ -84,6 +86,19 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: {
|
||||
select: {
|
||||
recipientCount: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,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;
|
||||
|
||||
@@ -116,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);
|
||||
@@ -202,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,42 +0,0 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
recipientEmailRateLimit1d,
|
||||
recipientEmailRateLimit5m,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
|
||||
type AssertOrgEmailSendAllowedOptions = {
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
type Result = { allowed: true } | { allowed: false; reason: '5m' | '1d'; resetsAt: Date };
|
||||
|
||||
/**
|
||||
* TEMPORARY: rate-limit unsolicited recipient emails per organisation.
|
||||
*
|
||||
* Two layered windows: 100/5m and 1000/1d, both keyed to org id. Returns a
|
||||
* result object so callers can choose to silently drop (job path) or throw
|
||||
* (sync path).
|
||||
*
|
||||
* Remove this helper and all callers when the comprehensive abuse-prevention
|
||||
* design lands. See .agents/plans/sharp-gold-wave-email-abuse-prevention.md
|
||||
*/
|
||||
export const assertOrgEmailSendAllowed = async (options: AssertOrgEmailSendAllowedOptions): Promise<Result> => {
|
||||
// Self-hosted instances are not behind the SES cap.
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const ip = `org:${options.organisationId}`;
|
||||
|
||||
const fiveMinResult = await recipientEmailRateLimit5m.check({ ip });
|
||||
if (fiveMinResult.isLimited) {
|
||||
return { allowed: false, reason: '5m', resetsAt: fiveMinResult.reset };
|
||||
}
|
||||
|
||||
const dailyResult = await recipientEmailRateLimit1d.check({ ip });
|
||||
if (dailyResult.isLimited) {
|
||||
return { allowed: false, reason: '1d', resetsAt: dailyResult.reset };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export type BuildEnvelopeEmailHeadersOptions = {
|
||||
userId: number;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds opaque sender-attribution headers stamped onto outgoing user-triggered
|
||||
* envelope emails. These appear in AWS SES bounce/complaint notifications (when
|
||||
* "include original headers" is enabled) so an abusive send can be traced back
|
||||
* to the originating Documenso user via the admin panel.
|
||||
*
|
||||
* Only opaque IDs are included so recipients cannot see the sender's email
|
||||
* address or name in the delivered message source.
|
||||
*/
|
||||
export const buildEnvelopeEmailHeaders = ({
|
||||
userId,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: BuildEnvelopeEmailHeadersOptions): Record<string, string> => {
|
||||
return {
|
||||
'X-Documenso-Sender-User-Id': String(userId),
|
||||
'X-Documenso-Envelope-Id': envelopeId,
|
||||
'X-Documenso-Team-Id': String(teamId),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Config keys that hold secret values across all transport types.
|
||||
*
|
||||
* Secrets are never sent back to the client, so on update an empty incoming
|
||||
* value means "keep the existing secret". This list lets the update route know
|
||||
* which fields to preserve when left blank.
|
||||
*
|
||||
* Keep in sync with the fields marked `Secret` in the schemas below.
|
||||
*/
|
||||
export const EMAIL_TRANSPORT_SECRET_KEYS = ['password', 'apiKey'] as const;
|
||||
|
||||
export const ZSmtpAuthConfigSchema = z.object({
|
||||
type: z.literal('SMTP_AUTH'),
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().positive(),
|
||||
secure: z.boolean().default(false),
|
||||
ignoreTLS: z.boolean().default(false),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
|
||||
service: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZSmtpApiConfigSchema = z.object({
|
||||
type: z.literal('SMTP_API'),
|
||||
host: z.string().min(1),
|
||||
port: z.number().int().positive(),
|
||||
secure: z.boolean().default(false),
|
||||
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
|
||||
apiKeyUser: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZResendConfigSchema = z.object({
|
||||
type: z.literal('RESEND'),
|
||||
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
|
||||
});
|
||||
|
||||
export const ZMailChannelsConfigSchema = z.object({
|
||||
type: z.literal('MAILCHANNELS'),
|
||||
apiKey: z.string().min(1), // Secret — keep in sync with EMAIL_TRANSPORT_SECRET_KEYS.
|
||||
endpoint: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZEmailTransportConfigSchema = z.discriminatedUnion('type', [
|
||||
ZSmtpAuthConfigSchema,
|
||||
ZSmtpApiConfigSchema,
|
||||
ZResendConfigSchema,
|
||||
ZMailChannelsConfigSchema,
|
||||
]);
|
||||
|
||||
export type TEmailTransportConfig = z.infer<typeof ZEmailTransportConfigSchema>;
|
||||
|
||||
/**
|
||||
* Non-secret view of a transport config (secret fields removed).
|
||||
*
|
||||
* Safe to return to the client so the edit form can pre-fill the connection
|
||||
* settings without exposing secrets.
|
||||
*/
|
||||
export const ZEmailTransportPublicConfigSchema = z.discriminatedUnion('type', [
|
||||
ZSmtpAuthConfigSchema.omit({ password: true }),
|
||||
ZSmtpApiConfigSchema.omit({ apiKey: true }),
|
||||
ZResendConfigSchema.omit({ apiKey: true }),
|
||||
ZMailChannelsConfigSchema.omit({ apiKey: true }),
|
||||
]);
|
||||
|
||||
export type TEmailTransportPublicConfig = z.infer<typeof ZEmailTransportPublicConfigSchema>;
|
||||
|
||||
/**
|
||||
* Strips secret fields (see EMAIL_TRANSPORT_SECRET_KEYS) from a transport
|
||||
* config, returning only the non-secret connection settings.
|
||||
*/
|
||||
export const toPublicEmailTransportConfig = (config: TEmailTransportConfig): TEmailTransportPublicConfig => {
|
||||
const publicConfig: Record<string, unknown> = { ...config };
|
||||
|
||||
for (const key of EMAIL_TRANSPORT_SECRET_KEYS) {
|
||||
delete publicConfig[key];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return publicConfig as TEmailTransportPublicConfig;
|
||||
};
|
||||
|
||||
export const encryptEmailTransportConfig = (config: TEmailTransportConfig): string => {
|
||||
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing encryption key');
|
||||
}
|
||||
|
||||
return symmetricEncrypt({
|
||||
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||
data: JSON.stringify(config),
|
||||
});
|
||||
};
|
||||
|
||||
export const decryptEmailTransportConfig = (encrypted: string): TEmailTransportConfig => {
|
||||
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing encryption key');
|
||||
}
|
||||
|
||||
const decrypted = Buffer.from(
|
||||
symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, data: encrypted }),
|
||||
).toString('utf-8');
|
||||
|
||||
return ZEmailTransportConfigSchema.parse(JSON.parse(decrypted));
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import type { BrandingSettings } from '@documenso/email/providers/branding';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type {
|
||||
@@ -8,15 +9,19 @@ import type {
|
||||
OrganisationType,
|
||||
} from '@documenso/prisma/client';
|
||||
import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
organisationGlobalSettingsToBranding,
|
||||
teamGlobalSettingsToBranding,
|
||||
} from '../../utils/team-global-settings-to-branding';
|
||||
import { extractDerivedTeamSettings } from '../../utils/teams';
|
||||
import { resolveEmailTransport } from './resolve-email-transport';
|
||||
|
||||
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
|
||||
|
||||
@@ -66,8 +71,15 @@ export type EmailContextResponse = {
|
||||
branding: BrandingSettings;
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
claims: OrganisationClaim;
|
||||
/**
|
||||
* Whether the organisation is prevented from sending emails.
|
||||
*
|
||||
* When true, ALL emails sent on behalf of this organisation must be skipped.
|
||||
*/
|
||||
emailsDisabled: boolean;
|
||||
organisationId: string;
|
||||
organisationType: OrganisationType;
|
||||
emailTransport: Transporter;
|
||||
senderEmail: {
|
||||
name: string;
|
||||
address: string;
|
||||
@@ -79,7 +91,7 @@ export type EmailContextResponse = {
|
||||
export const getEmailContext = async (options: GetEmailContextOptions): Promise<EmailContextResponse> => {
|
||||
const { source, meta } = options;
|
||||
|
||||
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
|
||||
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage' | 'emailTransport'>;
|
||||
|
||||
if (source.type === 'organisation') {
|
||||
emailContext = await handleOrganisationEmailContext(source.organisationId);
|
||||
@@ -89,13 +101,44 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
|
||||
|
||||
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
|
||||
|
||||
const transportResolution = emailContext.claims.emailTransportId
|
||||
? await resolveEmailTransport(emailContext.claims.emailTransportId)
|
||||
: null;
|
||||
|
||||
// A configured transport that fails to resolve is an operational problem, not
|
||||
// "no transport". Surface it (alertable) before silently falling back to the
|
||||
// system mailer + Documenso sender, so the degraded organisation is findable.
|
||||
if (emailContext.claims.emailTransportId && !transportResolution) {
|
||||
logger.error({
|
||||
msg: 'Configured email transport could not be resolved; falling back to the system mailer',
|
||||
emailTransportId: emailContext.claims.emailTransportId,
|
||||
organisationId: emailContext.organisationId,
|
||||
});
|
||||
}
|
||||
|
||||
const resolvedTransportData = transportResolution
|
||||
? {
|
||||
name: transportResolution.row.fromName,
|
||||
address: transportResolution.row.fromAddress,
|
||||
transport: transportResolution.transporter,
|
||||
}
|
||||
: {
|
||||
name: DOCUMENSO_INTERNAL_EMAIL.name,
|
||||
address: DOCUMENSO_INTERNAL_EMAIL.address,
|
||||
transport: mailer,
|
||||
};
|
||||
|
||||
// Immediate return for internal emails.
|
||||
if (options.emailType === 'INTERNAL') {
|
||||
return {
|
||||
...emailContext,
|
||||
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
|
||||
emailTransport: resolvedTransportData.transport,
|
||||
senderEmail: {
|
||||
name: resolvedTransportData.name,
|
||||
address: resolvedTransportData.address,
|
||||
},
|
||||
replyToEmail: undefined,
|
||||
emailLanguage, // Not sure if we want to use this for internal emails.
|
||||
emailLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,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,
|
||||
};
|
||||
@@ -135,6 +191,11 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
organisationClaim: true,
|
||||
organisationGlobalSettings: true,
|
||||
emailDomains: {
|
||||
@@ -156,15 +217,24 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
|
||||
|
||||
const allowedEmails = getAllowedEmails(organisation);
|
||||
|
||||
const branding = organisationGlobalSettingsToBranding(
|
||||
organisation.organisationGlobalSettings,
|
||||
organisation.id,
|
||||
claims.flags.hidePoweredBy ?? false,
|
||||
);
|
||||
|
||||
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
|
||||
|
||||
if (!allowBrandedEmailColors) {
|
||||
branding.brandingColors = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
allowedEmails,
|
||||
branding: organisationGlobalSettingsToBranding(
|
||||
organisation.organisationGlobalSettings,
|
||||
organisation.id,
|
||||
claims.flags.hidePoweredBy ?? false,
|
||||
),
|
||||
branding,
|
||||
settings: organisation.organisationGlobalSettings,
|
||||
claims,
|
||||
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
|
||||
organisationId: organisation.id,
|
||||
organisationType: organisation.type,
|
||||
};
|
||||
@@ -179,6 +249,12 @@ const handleTeamEmailContext = async (teamId: number) => {
|
||||
teamGlobalSettings: true,
|
||||
organisation: {
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
organisationClaim: true,
|
||||
organisationGlobalSettings: true,
|
||||
emailDomains: {
|
||||
@@ -205,11 +281,20 @@ const handleTeamEmailContext = async (teamId: number) => {
|
||||
|
||||
const teamSettings = extractDerivedTeamSettings(organisation.organisationGlobalSettings, team.teamGlobalSettings);
|
||||
|
||||
const branding = teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false);
|
||||
|
||||
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
|
||||
|
||||
if (!allowBrandedEmailColors) {
|
||||
branding.brandingColors = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
allowedEmails,
|
||||
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
|
||||
branding,
|
||||
settings: teamSettings,
|
||||
claims,
|
||||
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
|
||||
organisationId: organisation.id,
|
||||
organisationType: organisation.type,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { buildTransport } from '@documenso/email/transports/build-transport';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { EmailTransport } from '@documenso/prisma/client';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { decryptEmailTransportConfig } from './email-transport-config';
|
||||
|
||||
export type ResolvedEmailTransport = {
|
||||
row: EmailTransport;
|
||||
transporter: Transporter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads an EmailTransport row, decrypts its config and builds a nodemailer
|
||||
* Transporter. Returns null when the id does not resolve or the stored config
|
||||
* cannot be decrypted/built (caller should fall back to the env mailer).
|
||||
*/
|
||||
export const resolveEmailTransport = async (emailTransportId: string): Promise<ResolvedEmailTransport | null> => {
|
||||
const row = await prisma.emailTransport.findUnique({
|
||||
where: { id: emailTransportId },
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
const transporter = buildTransport(config);
|
||||
|
||||
return { row, transporter };
|
||||
} catch (err) {
|
||||
// Todo: Logging
|
||||
logger.error({
|
||||
msg: 'Failed to decrypt or build the configured email transport',
|
||||
err,
|
||||
emailTransportId,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const createEmbeddingPresignToken = async ({
|
||||
}: CreateEmbeddingPresignTokenOptions) => {
|
||||
try {
|
||||
// Validate the API token
|
||||
const validatedToken = await getApiTokenByToken({ token: apiToken });
|
||||
const validatedToken = await getApiTokenByToken({ token: apiToken, bypassRateLimit: true });
|
||||
|
||||
const now = DateTime.now();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Envelope, Field, Recipient } from '@prisma/client';
|
||||
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
|
||||
import { findRecipientByPlaceholder } from '../pdf/helpers';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
@@ -96,6 +97,8 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
||||
let didFieldsChange = false;
|
||||
|
||||
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const updatedItem = await tx.envelopeItem.update({
|
||||
where: {
|
||||
id: envelopeItemId,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { DocumentStatus, type Envelope, type Prisma } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { isTspEnvelope } from '../../types/signature-level';
|
||||
|
||||
type EnvelopeMutableSnapshot = {
|
||||
signatureLevel: string;
|
||||
status: DocumentStatus;
|
||||
};
|
||||
|
||||
type EnvelopeIdRef = Pick<Envelope, 'id'>;
|
||||
|
||||
/**
|
||||
* Reject authoring mutations on an AES/QES envelope past DRAFT.
|
||||
*
|
||||
* The TSP mutation lock fires at distribution so the owner cannot replace the
|
||||
* PDF between a recipient completing service-scope OAuth (against PDF_v1) and
|
||||
* clicking Sign (now against PDF_v2). The SAD would authorise PDF_v2's digest
|
||||
* while the recipient viewed PDF_v1 — a WYSIWYS break.
|
||||
*
|
||||
* SES envelopes pass through unchanged. The existing per-route guards still
|
||||
* enforce COMPLETED/REJECTED rejection for them.
|
||||
*
|
||||
* Call this **twice** at every TSP-eligible authoring route:
|
||||
*
|
||||
* 1. Outside the transaction with the pre-fetched envelope snapshot —
|
||||
* `assertEnvelopeMutable(envelope)` — fast-fail without a DB round-trip.
|
||||
* 2. Inside the transaction with `tx` — `assertEnvelopeMutable(envelope, tx)`
|
||||
* — re-fetches under the transaction's snapshot, closing the TOCTOU
|
||||
* window against a concurrent `sendDocument` committing DRAFT → PENDING
|
||||
* between the snapshot read and the mutation.
|
||||
*
|
||||
* Throws:
|
||||
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
|
||||
* the TSP lock — SES routes happily allow PENDING).
|
||||
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` / `ENVELOPE_CANCELLED` for those
|
||||
* terminal states, to stay consistent with the existing envelope-state error
|
||||
* vocabulary.
|
||||
*/
|
||||
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
|
||||
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
|
||||
|
||||
export async function assertEnvelopeMutable(
|
||||
envelope: EnvelopeMutableSnapshot | EnvelopeIdRef,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
if (tx) {
|
||||
return await refetchAndAssert(tx, (envelope as EnvelopeIdRef).id);
|
||||
}
|
||||
|
||||
assertSnapshotMutable(envelope as EnvelopeMutableSnapshot);
|
||||
}
|
||||
|
||||
const refetchAndAssert = async (tx: Prisma.TransactionClient, envelopeId: string): Promise<void> => {
|
||||
const refetched = await tx.envelope.findFirstOrThrow({
|
||||
where: { id: envelopeId },
|
||||
select: { signatureLevel: true, status: true },
|
||||
});
|
||||
|
||||
assertSnapshotMutable(refetched);
|
||||
};
|
||||
|
||||
const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
|
||||
if (!isTspEnvelope(envelope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.status === DocumentStatus.DRAFT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorCode = match(envelope.status)
|
||||
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
|
||||
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
|
||||
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
|
||||
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
|
||||
.otherwise(() => AppErrorCode.INVALID_REQUEST);
|
||||
|
||||
throw new AppError(errorCode, {
|
||||
message: `Envelope is locked — AES/QES envelopes cannot be modified after leaving DRAFT (current status: ${envelope.status}).`,
|
||||
});
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||
import type { TFieldAndMeta } from '../../types/field-meta';
|
||||
import type { TSignatureLevel } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
@@ -36,6 +37,9 @@ import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -88,6 +92,7 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
delegatedDocumentOwner?: string;
|
||||
signatureLevel?: TSignatureLevel;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
@@ -136,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: {
|
||||
@@ -155,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({
|
||||
@@ -183,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
|
||||
@@ -243,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;
|
||||
@@ -299,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)
|
||||
@@ -319,6 +360,7 @@ export const createEnvelope = async ({
|
||||
internalVersion,
|
||||
type,
|
||||
title,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
envelopeItems: {
|
||||
|
||||
@@ -4,11 +4,14 @@ import pMap from 'p-map';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export interface DuplicateEnvelopeOptions {
|
||||
@@ -25,7 +28,7 @@ export interface DuplicateEnvelopeOptions {
|
||||
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
|
||||
const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: null,
|
||||
userId,
|
||||
@@ -39,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
title: true,
|
||||
userId: true,
|
||||
internalVersion: true,
|
||||
signatureLevel: true,
|
||||
templateType: true,
|
||||
publicTitle: true,
|
||||
publicDescription: true,
|
||||
@@ -83,6 +87,15 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
|
||||
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
|
||||
|
||||
// Enforce the organisation document-creation limit before creating the duplicate.
|
||||
if (targetType === EnvelopeType.DOCUMENT) {
|
||||
await assertOrganisationRatesAndLimits({
|
||||
organisationId: team.organisationId,
|
||||
type: 'document',
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
|
||||
targetType === EnvelopeType.DOCUMENT
|
||||
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
||||
@@ -106,12 +119,21 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
? 'PRIVATE'
|
||||
: (envelope.templateType ?? undefined);
|
||||
|
||||
// The source level is a free-form TEXT column — parse defensively before
|
||||
// handing to the resolver. Coerce (not strict) because instance mode may have
|
||||
// changed since the source envelope was created.
|
||||
const duplicatedSignatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(envelope.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const duplicatedEnvelope = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
type: targetType,
|
||||
internalVersion: envelope.internalVersion,
|
||||
signatureLevel: duplicatedSignatureLevel,
|
||||
userId,
|
||||
teamId,
|
||||
title: envelope.title + ' (copy)',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
authOptions: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
signatureLevel: true,
|
||||
}).extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
|
||||
@@ -15,7 +15,10 @@ import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../uti
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
|
||||
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
|
||||
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { assertEnvelopeMutable } from './assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from './get-envelope-by-id';
|
||||
|
||||
export type UpdateEnvelopeOptions = {
|
||||
@@ -76,6 +79,22 @@ export const updateEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (meta.signingOrder !== undefined) {
|
||||
assertCompatibleSigningOrder({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
signingOrder: meta.signingOrder,
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.allowDictateNextSigner !== undefined) {
|
||||
assertCompatibleDictateNextSigner({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the template fields for document type envelopes',
|
||||
@@ -297,6 +316,8 @@ export const updateEnvelope = async ({
|
||||
// }
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const result = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
|
||||
|
||||
@@ -93,6 +94,8 @@ export const createEnvelopeFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -242,6 +245,8 @@ export const createEnvelopeFields = async ({
|
||||
});
|
||||
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const newlyCreatedFields = await tx.field.createManyAndReturn({
|
||||
data: validatedFields.map((field) => ({
|
||||
type: field.type,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteDocumentFieldOptions {
|
||||
@@ -59,6 +60,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -81,6 +84,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const deletedField = await tx.field.delete({
|
||||
where: {
|
||||
id: fieldId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateEnvelopeFieldsOptions {
|
||||
@@ -60,6 +61,8 @@ export const updateEnvelopeFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -115,6 +118,8 @@ export const updateEnvelopeFields = async ({
|
||||
});
|
||||
|
||||
const updatedFields = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
|
||||
const updatedField = await tx.field.update({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { LicenseFlag, TCachedLicense } from '../../types/license';
|
||||
import { env } from '../../utils/env';
|
||||
import { LicenseClient } from './license-client';
|
||||
|
||||
type AssertLicensedForOptions = {
|
||||
/**
|
||||
* Override the AppError code thrown when the assertion fails.
|
||||
*
|
||||
* Defaults to `AppErrorCode.FORBIDDEN`. Callers that need a more specific
|
||||
* surface — for example the CSC transport throwing `CSC_UNLICENSED` at
|
||||
* transport-create time — pass their own code here.
|
||||
*/
|
||||
errorCode?: string;
|
||||
|
||||
/**
|
||||
* Override the AppError message thrown when the assertion fails.
|
||||
*/
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the configured Documenso licence grants `flag`. Reads the
|
||||
* {@link LicenseClient} cache; never re-pings the licence server.
|
||||
*
|
||||
* - No `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` → throws. No licensing intent.
|
||||
* - Key set, claim unverifiable (no client, null cache, read throws,
|
||||
* `license: null`) → passes. Mirrors how org-claim gates keep running on
|
||||
* last known state when the licence server is unreachable; paying
|
||||
* operators shouldn't be locked out by transient infra.
|
||||
* - Key set, claim loaded and denies the flag (bad standing or flag falsy)
|
||||
* → throws.
|
||||
*/
|
||||
export const assertLicensedFor = async (flag: LicenseFlag, options?: AssertLicensedForOptions): Promise<void> => {
|
||||
const denied = (): AppError =>
|
||||
new AppError(options?.errorCode ?? AppErrorCode.FORBIDDEN, {
|
||||
message: options?.message ?? `License does not include the "${flag}" feature.`,
|
||||
});
|
||||
|
||||
// No licence key configured = no licensing intent. Fail closed unconditionally
|
||||
// so unlicensed instances cannot reach gated features simply because the
|
||||
// licence cache is empty.
|
||||
if (!env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY')) {
|
||||
throw denied();
|
||||
}
|
||||
|
||||
let cached: TCachedLicense | null = null;
|
||||
|
||||
const licenseClient = LicenseClient.getInstance();
|
||||
|
||||
if (licenseClient) {
|
||||
cached = await licenseClient?.getCachedLicense().catch(() => null);
|
||||
}
|
||||
|
||||
// Licence key is configured but we have no positively-verified claim to
|
||||
// check. Fail-open — see block comment for the full set of conditions and
|
||||
// rationale.
|
||||
if (!cached?.license) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inGoodStanding = cached.derivedStatus === 'ACTIVE' || cached.derivedStatus === 'PAST_DUE';
|
||||
|
||||
const flagGranted = Boolean(cached.license.flags[flag]);
|
||||
|
||||
if (!inGoodStanding || !flagGranted) {
|
||||
throw denied();
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
assertMemberCountWithinCap,
|
||||
syncMemberCountWithStripeSeatPlan,
|
||||
} from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
@@ -187,7 +186,7 @@ export const sendOrganisationMemberInviteEmail = async ({
|
||||
organisationName: organisation.name,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = await getEmailContext({
|
||||
const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
@@ -195,6 +194,12 @@ export const sendOrganisationMemberInviteEmail = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// Member invites can be sent to anyone, so block them when the organisation has email
|
||||
// sending disabled.
|
||||
if (emailsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
@@ -209,7 +214,7 @@ export const sendOrganisationMemberInviteEmail = async ({
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
await emailTransport.sendMail({
|
||||
to: email,
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationMemberRole, OrganisationType, Prisma } from '@prisma/client';
|
||||
import { OrganisationMemberRole, OrganisationType, Prisma, type SubscriptionClaim } from '@prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { InternalClaim } from '../../types/subscription';
|
||||
import { INTERNAL_CLAIM_ID, internalClaims } from '../../types/subscription';
|
||||
import { INTERNAL_CLAIM_ID } from '../../types/subscription';
|
||||
import { generateDatabaseId, prefixedId } from '../../universal/id';
|
||||
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
|
||||
import { createTeam } from '../team/create-team';
|
||||
@@ -17,7 +17,7 @@ type CreateOrganisationOptions = {
|
||||
type: OrganisationType;
|
||||
url?: string;
|
||||
customerId?: string;
|
||||
claim: InternalClaim;
|
||||
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
|
||||
};
|
||||
|
||||
export const createOrganisation = async ({ name, url, type, userId, customerId, claim }: CreateOrganisationOptions) => {
|
||||
@@ -151,12 +151,14 @@ export const createPersonalOrganisation = async ({
|
||||
inheritMembers = true,
|
||||
type = OrganisationType.PERSONAL,
|
||||
}: CreatePersonalOrganisationOptions) => {
|
||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||
|
||||
const organisation = await createOrganisation({
|
||||
name: 'Personal Organisation',
|
||||
userId,
|
||||
url: orgUrl,
|
||||
type,
|
||||
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
|
||||
claim: freeSubscriptionClaim,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -184,16 +186,27 @@ export const createPersonalOrganisation = async ({
|
||||
return organisation;
|
||||
};
|
||||
|
||||
export const createOrganisationClaimUpsertData = (subscriptionClaim: InternalClaim) => {
|
||||
export const createOrganisationClaimUpsertData = (
|
||||
subscriptionClaim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>,
|
||||
) => {
|
||||
// Done like this to ensure type errors are thrown if items are added.
|
||||
const data: Omit<Prisma.SubscriptionClaimCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> = {
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
};
|
||||
const data: Omit<Prisma.SubscriptionClaimUncheckedCreateInput, 'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'> =
|
||||
{
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
envelopeItemCount: subscriptionClaim.envelopeItemCount,
|
||||
recipientCount: subscriptionClaim.recipientCount,
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
documentRateLimits: subscriptionClaim.documentRateLimits ?? [],
|
||||
documentQuota: subscriptionClaim.documentQuota,
|
||||
emailRateLimits: subscriptionClaim.emailRateLimits ?? [],
|
||||
emailQuota: subscriptionClaim.emailQuota,
|
||||
apiRateLimits: subscriptionClaim.apiRateLimits ?? [],
|
||||
apiQuota: subscriptionClaim.apiQuota,
|
||||
emailTransportId: subscriptionClaim.emailTransportId ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
...data,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import type { EmailContextResponse } from '../email/get-email-context';
|
||||
|
||||
@@ -12,7 +13,7 @@ export type SendOrganisationDeleteEmailOptions = {
|
||||
email: string;
|
||||
organisationName: string;
|
||||
deletedByAdmin?: boolean;
|
||||
emailContext: EmailContextResponse;
|
||||
emailContext: Omit<EmailContextResponse, 'emailTransport'>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,7 +31,7 @@ export const sendOrganisationDeleteEmail = async ({
|
||||
deletedByAdmin,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = emailContext;
|
||||
const { branding, emailLanguage } = emailContext;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
@@ -39,9 +40,13 @@ export const sendOrganisationDeleteEmail = async ({
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// This is sent through the global Documenso mailer (the org's transport is
|
||||
// intentionally not used during deletion), so use the Documenso sender to keep
|
||||
// the From-address aligned with the sending infrastructure (SPF/DKIM). Note the
|
||||
// org's `senderEmail` on `emailContext` could be a custom transport address.
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: senderEmail,
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
|
||||
html,
|
||||
text,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../constants/organisations';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
|
||||
|
||||
export type DeleteOrganisationOptions = {
|
||||
organisation: {
|
||||
id: string;
|
||||
teams: { id: number }[];
|
||||
subscription: { planId: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully tears down an organisation:
|
||||
*
|
||||
* 1. Orphans every team's envelopes (so foreign key constraints don't block the delete).
|
||||
* 2. Removes the organisation's account rows and the organisation itself in a transaction.
|
||||
* 3. Schedules the Stripe subscription to be cancelled at the end of the billing period
|
||||
* (when one exists). The job runs asynchronously so a Stripe outage doesn't block the
|
||||
* delete, and is retried by the job runner if Stripe is temporarily unavailable.
|
||||
*
|
||||
* Authorization must be handled by the caller. This is the shared implementation used by
|
||||
* the organisation delete route, the admin delete-organisation job, and account deletion.
|
||||
*/
|
||||
export const deleteOrganisation = async ({ organisation }: DeleteOrganisationOptions) => {
|
||||
// Orphan all envelopes to get rid of foreign key constraints.
|
||||
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (organisation.subscription) {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.cancel-organisation-subscription',
|
||||
payload: {
|
||||
stripeSubscriptionId: organisation.subscription.planId,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { renderSVG } from 'uqr';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { APP_I18N_OPTIONS } from '../../constants/i18n';
|
||||
import { getSignatureFontFamily } from '../../constants/pdf';
|
||||
import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
|
||||
import { svgToPng } from '../../utils/images/svg-to-png';
|
||||
@@ -302,7 +303,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
|
||||
x: 2,
|
||||
text: recipient.signatureField?.signature?.typedSignature,
|
||||
padding: 4,
|
||||
fontFamily: 'Caveat',
|
||||
fontFamily: getSignatureFontFamily(recipient.signatureField?.signature?.typedSignature),
|
||||
fontSize: 16,
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
|
||||
@@ -2,8 +2,20 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { hashString } from '../auth/hash';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
|
||||
export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
type GetApiTokenByTokenOptions = {
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* Defaults to false.
|
||||
*
|
||||
* Will assert that the API request limit is not exceeded.
|
||||
*/
|
||||
bypassRateLimit?: boolean;
|
||||
};
|
||||
|
||||
export const getApiTokenByToken = async ({ token, bypassRateLimit = false }: GetApiTokenByTokenOptions) => {
|
||||
const hashedToken = hashString(token);
|
||||
|
||||
const apiToken = await prisma.apiToken.findFirst({
|
||||
@@ -15,6 +27,7 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -45,6 +58,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (apiToken.user?.disabled || apiToken.team.organisation.owner.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is disabled',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (apiToken.expires && apiToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
||||
message: 'Expired token',
|
||||
@@ -52,6 +72,15 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!bypassRateLimit) {
|
||||
await assertOrganisationRatesAndLimits({
|
||||
organisationId: apiToken.team.organisationId,
|
||||
organisationClaim: apiToken.team.organisation.organisationClaim,
|
||||
type: 'api',
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle a silly choice from many moons ago
|
||||
if (apiToken.team && !apiToken.user) {
|
||||
apiToken.user = apiToken.team.organisation.owner;
|
||||
|
||||
@@ -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({
|
||||
@@ -97,17 +111,3 @@ export const fileUploadRateLimit = createRateLimit({
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
// ---- Recipient email send (TEMPORARY: per-org abuse-prevention stopgap) ----
|
||||
|
||||
export const recipientEmailRateLimit5m = createRateLimit({
|
||||
action: 'email.send.recipient.5m',
|
||||
max: 100,
|
||||
window: '5m',
|
||||
});
|
||||
|
||||
export const recipientEmailRateLimit1d = createRateLimit({
|
||||
action: 'email.send.recipient.1d',
|
||||
max: 1500,
|
||||
window: '1d',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type LimitCounter = 'api' | 'document' | 'email';
|
||||
|
||||
export type RateLimitEntry = {
|
||||
window: `${number}${'s' | 'm' | 'h' | 'd'}`;
|
||||
max: number;
|
||||
};
|
||||
@@ -10,7 +10,9 @@ import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface CreateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -63,6 +65,8 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -80,12 +84,21 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipientsToCreate) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
const authOptions = createRecipientAuthOptions({
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { EnvelopeType, SendStatus } from '@prisma/client';
|
||||
import { createElement } from 'react';
|
||||
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteEnvelopeRecipientOptions {
|
||||
@@ -71,6 +65,8 @@ export const deleteEnvelopeRecipient = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -108,6 +104,8 @@ export const deleteEnvelopeRecipient = async ({
|
||||
});
|
||||
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
@@ -137,44 +135,21 @@ export const deleteEnvelopeRecipient = async ({
|
||||
// Send email to deleted recipient.
|
||||
if (
|
||||
recipientToDelete.sendStatus === SendStatus.SENT &&
|
||||
recipientToDelete.role !== RecipientRole.CC &&
|
||||
isRecipientRemovedEmailEnabled &&
|
||||
envelope.type === EnvelopeType.DOCUMENT &&
|
||||
isRecipientEmailValidForSending(recipientToDelete)
|
||||
) {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||
documentName: envelope.title,
|
||||
inviterName: envelope.team?.name || user.name || undefined,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId: envelope.teamId,
|
||||
// Enqueue the "removed from document" email as a background job so a
|
||||
// transient mail outage doesn't fail the request and the send is retried.
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.removed.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
recipientEmail: recipientToDelete.email,
|
||||
recipientName: recipientToDelete.name,
|
||||
inviterName: envelope.team?.name || user.name || undefined,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipientToDelete.email,
|
||||
name: recipientToDelete.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
@@ -8,21 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { createElement } from 'react';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -79,19 +74,12 @@ export const setDocumentRecipients = async ({
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
|
||||
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
|
||||
emailType: 'RECIPIENT',
|
||||
source: {
|
||||
type: 'team',
|
||||
teamId,
|
||||
},
|
||||
meta: envelope.documentMeta,
|
||||
});
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some(
|
||||
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
|
||||
);
|
||||
@@ -103,6 +91,13 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
@@ -137,6 +132,8 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
linkedRecipients.map(async (recipient) => {
|
||||
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
|
||||
@@ -275,46 +272,29 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
|
||||
|
||||
// Send emails to deleted recipients who have emails.
|
||||
await Promise.all(
|
||||
removedRecipients.map(async (recipient) => {
|
||||
if (
|
||||
recipient.sendStatus !== SendStatus.SENT ||
|
||||
recipient.role === RecipientRole.CC ||
|
||||
!isRecipientRemovedEmailEnabled ||
|
||||
!isRecipientEmailValidForSending(recipient)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isRecipientRemovedEmailEnabled) {
|
||||
await Promise.all(
|
||||
removedRecipients.map(async (recipient) => {
|
||||
if (
|
||||
recipient.sendStatus !== SendStatus.SENT ||
|
||||
recipient.role === RecipientRole.CC ||
|
||||
!isRecipientEmailValidForSending(recipient)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(RecipientRemovedFromDocumentTemplate, {
|
||||
documentName: envelope.title,
|
||||
inviterName: user.name || undefined,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`You have been removed from a document`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.removed.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
inviterName: user.name || undefined,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export type SetTemplateRecipientsOptions = {
|
||||
userId: number;
|
||||
@@ -60,6 +61,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => {
|
||||
// Force replace any changes to the name or email of the direct recipient.
|
||||
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
|
||||
|
||||
@@ -12,7 +12,9 @@ import { extractLegacyIds } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface UpdateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -67,6 +69,8 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -84,6 +88,17 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.role === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
|
||||
|
||||
@@ -106,6 +121,8 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
|
||||
const updatedRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
|
||||
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
|
||||
|
||||
@@ -6,22 +6,20 @@ import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constant
|
||||
/**
|
||||
* Compute and store `nextReminderAt` for a single recipient.
|
||||
*
|
||||
* Call this after:
|
||||
* - Sending the signing email (sentAt is set)
|
||||
* - Sending a reminder (lastReminderSentAt is updated)
|
||||
*
|
||||
* If `reminderSettings` is provided it's used directly, avoiding a query.
|
||||
* Otherwise it's read from the envelope's documentMeta (already resolved
|
||||
* from the org/team cascade at envelope creation time).
|
||||
* Pass `resetReminderCount: true` to restart the reminder cycle (e.g. on a
|
||||
* manual resend): the count is zeroed and the schedule recomputed as if the
|
||||
* request was freshly sent at `sentAt`.
|
||||
*/
|
||||
export const updateRecipientNextReminder = async (options: {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderCount?: number;
|
||||
resetReminderCount?: boolean;
|
||||
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
|
||||
}) => {
|
||||
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
|
||||
const { recipientId, envelopeId, sentAt, lastReminderSentAt, reminderCount = 0, resetReminderCount } = options;
|
||||
|
||||
let settings = options.reminderSettings;
|
||||
|
||||
@@ -40,11 +38,15 @@ export const updateRecipientNextReminder = async (options: {
|
||||
config: settings,
|
||||
sentAt,
|
||||
lastReminderSentAt,
|
||||
reminderCount: resetReminderCount ? 0 : reminderCount,
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipientId },
|
||||
data: { nextReminderAt },
|
||||
data: {
|
||||
nextReminderAt,
|
||||
...(resetReminderCount ? { reminderCount: 0 } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,7 +84,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
// Don't reschedule reminders for recipients whose deadline has passed.
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
},
|
||||
select: { id: true, sentAt: true, lastReminderSentAt: true },
|
||||
select: { id: true, sentAt: true, lastReminderSentAt: true, reminderCount: true },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
@@ -95,6 +97,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
|
||||
config: settings,
|
||||
sentAt: recipient.sentAt,
|
||||
lastReminderSentAt: recipient.lastReminderSentAt,
|
||||
reminderCount: recipient.reminderCount,
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
|
||||
@@ -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,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 { 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';
|
||||
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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user