From 4935f387bfa2163b8d95443cd85172dfa84bdddd Mon Sep 17 00:00:00 2001
From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com>
Date: Tue, 14 Apr 2026 11:01:53 +0000
Subject: [PATCH 1/7] feat: signing reminders (#1749)
---
README.md | 3 +
.../dialogs/document-resend-dialog.tsx | 9 +-
.../dialogs/envelope-redistribute-dialog.tsx | 5 +-
.../envelope-save-as-template-dialog.tsx | 9 +-
.../dialogs/template-direct-link-dialog.tsx | 5 +-
.../dialogs/template-use-dialog.tsx | 5 +-
.../embed/authoring/configure-fields-view.tsx | 7 +-
.../forms/document-preferences-form.tsx | 38 +++
.../general/avatar-with-recipient.tsx | 4 +-
.../document/document-page-view-dropdown.tsx | 22 +-
.../document-recipient-link-copy-dialog.tsx | 10 +-
.../envelope-editor-settings-dialog.tsx | 58 +++-
.../envelope-recipient-selector.tsx | 29 +-
.../general/stack-avatars-with-tooltip.tsx | 15 +-
.../app/components/general/stack-avatars.tsx | 9 +-
.../template-page-view-recipients.tsx | 4 +-
.../documents-table-action-dropdown.tsx | 22 +-
.../templates-table-action-dropdown.tsx | 3 +-
.../o.$orgUrl.settings.document.tsx | 2 +
.../t.$teamUrl+/settings.document.tsx | 2 +
.../envelope-settings.spec.ts | 92 ++++++
.../envelope-expiration-settings.spec.ts | 21 +-
.../template-document-reminder.tsx | 77 +++++
.../email/templates/document-reminder.tsx | 91 ++++++
.../client-only/hooks/use-editor-fields.ts | 4 +-
.../hooks/use-editor-recipients.ts | 4 +-
packages/lib/client-only/recipient-type.ts | 6 +-
packages/lib/constants/document-audit-logs.ts | 3 +
packages/lib/constants/envelope-reminder.ts | 86 ++++++
packages/lib/jobs/client.ts | 4 +
.../emails/send-signing-email.handler.ts | 44 +--
.../process-signing-reminder.handler.ts | 222 +++++++++++++++
.../internal/process-signing-reminder.ts | 31 ++
.../send-signing-reminders-sweep.handler.ts | 49 ++++
.../internal/send-signing-reminders-sweep.ts | 30 ++
.../document/complete-document-with-token.ts | 1 +
.../server-only/document/viewed-document.ts | 5 +
.../server-only/envelope/update-envelope.ts | 6 +
.../update-recipient-next-reminder.ts | 112 ++++++++
.../create-document-from-direct-template.ts | 1 +
packages/lib/types/document-audit-logs.ts | 1 +
packages/lib/types/document-meta.ts | 2 +
packages/lib/types/document.ts | 1 +
packages/lib/types/envelope-editor.ts | 4 +
packages/lib/types/recipient.ts | 7 +
packages/lib/utils/document.ts | 3 +
packages/lib/utils/embed-config.ts | 3 +
packages/lib/utils/envelope.ts | 2 +-
packages/lib/utils/organisations.ts | 3 +
packages/lib/utils/recipients.ts | 20 +-
packages/lib/utils/teams.ts | 2 +
.../migration.sql | 16 ++
packages/prisma/schema.prisma | 16 +-
.../server/document-router/update-document.ts | 4 +-
.../update-organisation-settings.ts | 2 +
.../update-organisation-settings.types.ts | 2 +
.../team-router/update-team-settings.ts | 2 +
.../team-router/update-team-settings.types.ts | 2 +
.../document/document-read-only-fields.tsx | 7 +-
.../document/expiration-period-picker.tsx | 5 +-
.../document/reminder-settings-picker.tsx | 266 ++++++++++++++++++
.../primitives/document-flow/add-fields.tsx | 9 +-
.../primitives/document-flow/add-settings.tsx | 4 +-
.../primitives/document-flow/add-signers.tsx | 7 +-
.../primitives/document-flow/add-subject.tsx | 5 +-
packages/ui/primitives/recipient-selector.tsx | 14 +-
.../template-flow/add-template-fields.tsx | 11 +-
.../add-template-placeholder-recipients.tsx | 7 +-
.../template-flow/add-template-settings.tsx | 5 +-
69 files changed, 1426 insertions(+), 156 deletions(-)
create mode 100644 packages/email/template-components/template-document-reminder.tsx
create mode 100644 packages/email/templates/document-reminder.tsx
create mode 100644 packages/lib/constants/envelope-reminder.ts
create mode 100644 packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
create mode 100644 packages/lib/jobs/definitions/internal/process-signing-reminder.ts
create mode 100644 packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts
create mode 100644 packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts
create mode 100644 packages/lib/server-only/recipient/update-recipient-next-reminder.ts
create mode 100644 packages/prisma/migrations/20260401000000_add_reminder_settings/migration.sql
create mode 100644 packages/ui/components/document/reminder-settings-picker.tsx
diff --git a/README.md b/README.md
index 9021ee7e6..b0fc9990f 100644
--- a/README.md
+++ b/README.md
@@ -182,6 +182,9 @@ git clone https://github.com//documenso
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Create your own signing certificate.
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
+- Optional: Configure job provider for document reminders.
+ - The default local job provider does not support scheduled jobs required for document reminders.
+ - To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
### Run in Gitpod
diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx
index d8c0a73ee..57e5235cc 100644
--- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx
+++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx
@@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
+import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick & {
user: Pick;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
team: Pick | null;
};
- recipients: Recipient[];
+ recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
@@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
}
+ primaryText={{recipient.email}
}
secondaryText={
-
+
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
}
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
index 27c633806..5592d04b9 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
@@ -13,7 +13,7 @@ import {
TemplateType,
} from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
-import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
+import { BellRingIcon, InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
@@ -26,6 +26,7 @@ import {
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -69,6 +70,7 @@ import {
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
+import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
@@ -145,10 +147,11 @@ export const ZAddSettingsFormSchema = z.object({
message: msg`At least one signature type must be enabled`.id,
}),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
+ reminderSettings: ZEnvelopeReminderSettings.nullish(),
}),
});
-type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
+type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
const tabs = [
{
@@ -157,6 +160,12 @@ const tabs = [
icon: SettingsIcon,
description: msg`Configure document settings and options before sending.`,
},
+ {
+ id: 'reminders',
+ title: msg`Reminders`,
+ icon: BellRingIcon,
+ description: msg`Configure signing reminder settings for the document.`,
+ },
{
id: 'email',
title: msg`Email`,
@@ -222,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
+ reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
},
};
};
@@ -270,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
subject,
emailReplyTo,
envelopeExpirationPeriod,
+ reminderSettings,
} = data.meta;
const parsedGlobalAccessAuth = z
@@ -300,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
+ reminderSettings,
},
});
@@ -381,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
return null;
}
+ if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
+ return null;
+ }
+
return (
))
+ .with(
+ { activeTab: 'reminders', settings: { allowConfigureReminders: true } },
+ () => (
+ (
+
+
+ Signing Reminders
+
+
+
+
+
+
+
+ Configure when and how often reminder emails are sent to
+ recipients who have not yet completed signing. Uses the team
+ default when set to inherit.
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ />
+ ),
+ )
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
index 5c39a945b..8367d71e9 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
@@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
@@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
export interface EnvelopeRecipientSelectorProps {
className?: string;
- selectedRecipient: Recipient | null;
- onSelectedRecipientChange: (recipient: Recipient) => void;
- recipients: Recipient[];
+ selectedRecipient: TEnvelopeRecipientLite | null;
+ onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
+ recipients: TEnvelopeRecipientLite[];
fields: Field[];
align?: 'center' | 'end' | 'start';
}
@@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
- (recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
+ (recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
interface EnvelopeRecipientSelectorCommandProps {
className?: string;
- selectedRecipient: Recipient | null;
- onSelectedRecipientChange: (recipient: Recipient) => void;
- recipients: Recipient[];
+ selectedRecipient: TEnvelopeRecipientLite | null;
+ onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
+ recipients: TEnvelopeRecipientLite[];
fields: Field[];
placeholder?: string;
}
@@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
- const recipientsByRole: Record = {
+ const recipientsByRole: Record = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
- ] as [RecipientRole, Recipient[]],
+ ] as [RecipientRole, TEnvelopeRecipientLite[]],
);
}, [recipientsByRole]);
@@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
);
const getRecipientLabel = useCallback(
- (recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
+ (recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -247,7 +248,11 @@ export const EnvelopeRecipientSelectorCommand = ({
);
};
-const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
+const extractRecipientLabel = (
+ recipient: TEnvelopeRecipientLite,
+ recipients: TEnvelopeRecipientLite[],
+ i18n: I18n,
+) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
diff --git a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
index ca3abea1b..2f165deb7 100644
--- a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
+++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
@@ -2,10 +2,11 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import { type DocumentStatus, type Recipient } from '@prisma/client';
+import type { DocumentStatus } from '@prisma/client';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { PopoverHover } from '@documenso/ui/primitives/popover';
@@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
@@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
Completed
- {completedRecipients.map((recipient: Recipient) => (
+ {completedRecipients.map((recipient) => (
Rejected
- {rejectedRecipients.map((recipient: Recipient) => (
+ {rejectedRecipients.map((recipient) => (
Waiting
- {waitingRecipients.map((recipient: Recipient) => (
+ {waitingRecipients.map((recipient) => (
Opened
- {openedRecipients.map((recipient: Recipient) => (
+ {openedRecipients.map((recipient) => (
Uncompleted
- {uncompletedRecipients.map((recipient: Recipient) => (
+ {uncompletedRecipients.map((recipient) => (
{
+export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
+ const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
- return itemsToRender.map((recipient: Recipient, index: number) => {
+ return itemsToRender.map((recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
diff --git a/apps/remix/app/components/general/template/template-page-view-recipients.tsx b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
index e5ac2d5a9..6419cf40e 100644
--- a/apps/remix/app/components/general/template/template-page-view-recipients.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
@@ -1,17 +1,17 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Recipient } from '@prisma/client';
import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
- recipients: Recipient[];
+ recipients: TRecipientLite[];
envelopeId: string;
templateRootPath: string;
readOnly?: boolean;
diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
index b3bea4060..509be3880 100644
--- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
@@ -62,6 +62,7 @@ export const DocumentsTableActionDropdown = ({
const trpcUtils = trpcReact.useUtils();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
+ const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = findRecipientByEmail({
recipients: row.recipients,
@@ -175,17 +176,10 @@ export const DocumentsTableActionDropdown = ({
}
/>
- e.preventDefault()}>
-
-
- Save as Template
-
-
- }
- />
+ setSaveAsTemplateDialogOpen(true)}>
+
+ Save as Template
+
{onMoveDocument && canManageDocument && (
e.preventDefault()}>
@@ -250,6 +244,12 @@ export const DocumentsTableActionDropdown = ({
/>
+
+
| null;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
};
templateRootPath: string;
teamId: number;
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
index 6adaaa96a..8c231776a 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
@@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
+ reminderSettings,
} = data;
if (
@@ -93,6 +94,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
+ reminderSettings: reminderSettings ?? undefined,
},
});
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx
index a99b83909..0bfa25ebb 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx
@@ -55,6 +55,7 @@ export default function TeamsSettingsPage() {
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
+ reminderSettings,
} = data;
await updateTeamSettings({
@@ -70,6 +71,7 @@ export default function TeamsSettingsPage() {
defaultRecipients,
aiFeaturesEnabled,
envelopeExpirationPeriod,
+ reminderSettings,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts
index a5a6f8aad..03bcc79ae 100644
--- a/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts
@@ -31,6 +31,12 @@ const TEST_SETTINGS_VALUES = {
expirationMode: 'Custom duration',
expirationAmount: 5,
expirationUnit: 'Weeks',
+ reminderMode: 'Enabled',
+ reminderSendAfterAmount: 3,
+ reminderSendAfterUnit: 'Days',
+ reminderRepeatMode: 'Custom interval',
+ reminderRepeatAmount: 7,
+ reminderRepeatUnit: 'Days',
accessAuth: 'Require account',
actionAuth: 'Require password',
visibility: 'Managers and above',
@@ -42,6 +48,10 @@ const DB_EXPECTED_VALUES = {
timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE,
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
+ reminderSettings: {
+ sendAfter: { unit: 'day', amount: 3 },
+ repeatEvery: { unit: 'day', amount: 7 },
+ },
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'],
@@ -130,6 +140,66 @@ const runSettingsFlow = async (
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
await clickSettingsDialogHeader(root);
+ // Configure reminder settings.
+ await root.getByRole('button', { name: 'Reminders' }).click();
+
+ await root.locator('[data-testid="reminder-mode-select"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderMode }).click();
+ await clickSettingsDialogHeader(root);
+
+ await root.locator('[data-testid="reminder-send-after-amount"]').clear();
+ await root
+ .locator('[data-testid="reminder-send-after-amount"]')
+ .fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
+
+ await root.locator('[data-testid="reminder-send-after-unit"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
+ await clickSettingsDialogHeader(root);
+
+ await root.locator('[data-testid="reminder-repeat-mode-select"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
+ await clickSettingsDialogHeader(root);
+
+ await root.locator('[data-testid="reminder-repeat-amount"]').clear();
+ await root
+ .locator('[data-testid="reminder-repeat-amount"]')
+ .fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
+
+ await root.locator('[data-testid="reminder-repeat-unit"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
+ await clickSettingsDialogHeader(root);
+
+ const spinbuttons = root.getByRole('spinbutton');
+ await spinbuttons.first().clear();
+ await spinbuttons.first().fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
+
+ const sendAfterUnitTrigger = root
+ .locator('button[role="combobox"]')
+ .filter({ hasText: /Days|Weeks|Months/ })
+ .first();
+ await sendAfterUnitTrigger.click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
+ await clickSettingsDialogHeader(root);
+
+ const repeatModeSelect = root
+ .locator('button[role="combobox"]')
+ .filter({ hasText: /Custom interval|Don't repeat/ })
+ .first();
+ await repeatModeSelect.click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
+ await clickSettingsDialogHeader(root);
+
+ await spinbuttons.last().clear();
+ await spinbuttons.last().fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
+
+ const repeatUnitTrigger = root
+ .locator('button[role="combobox"]')
+ .filter({ hasText: /Days|Weeks|Months/ })
+ .last();
+ await repeatUnitTrigger.click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
+ await clickSettingsDialogHeader(root);
+
await root.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click();
@@ -200,6 +270,27 @@ const runSettingsFlow = async (
.first(),
).toBeVisible();
+ // Verify reminder settings persisted in UI.
+ await root.getByRole('button', { name: 'Reminders' }).click();
+ await expect(root.locator('[data-testid="reminder-mode-select"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderMode,
+ );
+ await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
+ String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
+ );
+ await expect(root.locator('[data-testid="reminder-send-after-unit"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderSendAfterUnit,
+ );
+ await expect(root.locator('[data-testid="reminder-repeat-mode-select"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderRepeatMode,
+ );
+ await expect(root.locator('[data-testid="reminder-repeat-amount"]')).toHaveValue(
+ String(TEST_SETTINGS_VALUES.reminderRepeatAmount),
+ );
+ await expect(root.locator('[data-testid="reminder-repeat-unit"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderRepeatUnit,
+ );
+
await root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
@@ -285,6 +376,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
DB_EXPECTED_VALUES.envelopeExpirationPeriod,
);
+ expect(envelope.documentMeta.reminderSettings).toEqual(DB_EXPECTED_VALUES.reminderSettings);
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
diff --git a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts
index 7710a3ddb..39b6f8900 100644
--- a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts
+++ b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts
@@ -25,7 +25,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2.
- const amountInput = page.getByRole('spinbutton');
+ const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('2');
@@ -33,9 +33,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
// In the duration mode, there's a mode select and a unit select.
// The unit select is inside the duration row, after the number input.
// Let's find the select trigger that contains the unit text.
- const unitTrigger = page
- .locator('button[role="combobox"]')
- .filter({ hasText: /Months|Days|Weeks|Years/ });
+ const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
@@ -65,9 +63,7 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
- const modeTrigger = page
- .locator('button[role="combobox"]')
- .filter({ hasText: 'Custom duration' });
+ const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
@@ -118,11 +114,8 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
- // Scope to the "Default Envelope Expiration" form field section.
- const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
-
// The expiration picker mode select should show "Inherit from organisation" by default.
- const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
+ const modeTrigger = page.getByTestId('envelope-expiration-mode');
await expect(modeTrigger).toBeVisible();
// Switch to custom duration.
@@ -130,13 +123,11 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await page.getByRole('option', { name: 'Custom duration' }).click();
// Set to 5 days.
- const amountInput = expirationSection.getByRole('spinbutton');
+ const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('5');
- const unitTrigger = expirationSection
- .locator('button[role="combobox"]')
- .filter({ hasText: /Months|Days|Weeks|Years/ });
+ const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
diff --git a/packages/email/template-components/template-document-reminder.tsx b/packages/email/template-components/template-document-reminder.tsx
new file mode 100644
index 000000000..542272182
--- /dev/null
+++ b/packages/email/template-components/template-document-reminder.tsx
@@ -0,0 +1,77 @@
+import { useLingui } from '@lingui/react';
+import { Trans } from '@lingui/react/macro';
+import { RecipientRole } from '@prisma/client';
+import { match } from 'ts-pattern';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+
+import { Button, Section, Text } from '../components';
+import { TemplateDocumentImage } from './template-document-image';
+
+export interface TemplateDocumentReminderProps {
+ recipientName: string;
+ documentName: string;
+ signDocumentLink: string;
+ assetBaseUrl: string;
+ role: RecipientRole;
+}
+
+export const TemplateDocumentReminder = ({
+ recipientName,
+ documentName,
+ signDocumentLink,
+ assetBaseUrl,
+ role,
+}: TemplateDocumentReminderProps) => {
+ const { _ } = useLingui();
+
+ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
+
+ return (
+ <>
+
+
+
+
+
+ Reminder: Please {_(actionVerb).toLowerCase()} your document
+
"{documentName}"
+
+
+
+
+ Hi {recipientName},
+
+
+
+ {match(role)
+ .with(RecipientRole.SIGNER, () => Continue by signing the document.)
+ .with(RecipientRole.VIEWER, () => Continue by viewing the document.)
+ .with(RecipientRole.APPROVER, () => Continue by approving the document.)
+ .with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () => (
+ Continue by assisting with the document.
+ ))
+ .exhaustive()}
+
+
+
+
+
+
+ >
+ );
+};
+
+export default TemplateDocumentReminder;
diff --git a/packages/email/templates/document-reminder.tsx b/packages/email/templates/document-reminder.tsx
new file mode 100644
index 000000000..c12c8f327
--- /dev/null
+++ b/packages/email/templates/document-reminder.tsx
@@ -0,0 +1,91 @@
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { RecipientRole } from '@prisma/client';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+
+import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
+import { useBranding } from '../providers/branding';
+import { TemplateCustomMessageBody } from '../template-components/template-custom-message-body';
+import { TemplateDocumentReminder } from '../template-components/template-document-reminder';
+import { TemplateFooter } from '../template-components/template-footer';
+
+export type DocumentReminderEmailTemplateProps = {
+ recipientName: string;
+ documentName: string;
+ signDocumentLink: string;
+ assetBaseUrl?: string;
+ customBody?: string;
+ role: RecipientRole;
+};
+
+export const DocumentReminderEmailTemplate = ({
+ recipientName = 'John Doe',
+ documentName = 'Open Source Pledge.pdf',
+ signDocumentLink = 'https://documenso.com',
+ assetBaseUrl = 'http://localhost:3002',
+ customBody,
+ role = RecipientRole.SIGNER,
+}: DocumentReminderEmailTemplateProps) => {
+ const { _ } = useLingui();
+ const branding = useBranding();
+
+ const action = _(RECIPIENT_ROLES_DESCRIPTION[role].actionVerb).toLowerCase();
+
+ const previewText = msg`Reminder to ${action} ${documentName}`;
+
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+ {_(previewText)}
+
+
+
+
+
+ {branding.brandingEnabled && branding.brandingLogo ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {customBody && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DocumentReminderEmailTemplate;
diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts
index 6edb951c5..c326f6332 100644
--- a/packages/lib/client-only/hooks/use-editor-fields.ts
+++ b/packages/lib/client-only/hooks/use-editor-fields.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
- selectedRecipient: Recipient | null;
+ selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts
index dfea75acc..a7fbadbd7 100644
--- a/packages/lib/client-only/hooks/use-editor-recipients.ts
+++ b/packages/lib/client-only/hooks/use-editor-recipients.ts
@@ -1,7 +1,7 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
-import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
+import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -39,7 +39,7 @@ type EditorRecipientsProps = {
};
type ResetFormOptions = {
- recipients?: Recipient[];
+ recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts
index 1701fc1be..0476264ff 100644
--- a/packages/lib/client-only/recipient-type.ts
+++ b/packages/lib/client-only/recipient-type.ts
@@ -7,6 +7,8 @@ import {
SigningStatus,
} from '@prisma/client';
+type RecipientForType = Pick;
+
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
@@ -16,7 +18,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
- recipient: Recipient,
+ recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -45,7 +47,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
-export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
+export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts
index 9b91d2cb9..3ec716e0a 100644
--- a/packages/lib/constants/document-audit-logs.ts
+++ b/packages/lib/constants/document-audit-logs.ts
@@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
+ [DOCUMENT_EMAIL_TYPE.REMINDER]: {
+ description: 'Signing Reminder',
+ },
} satisfies Record;
diff --git a/packages/lib/constants/envelope-reminder.ts b/packages/lib/constants/envelope-reminder.ts
new file mode 100644
index 000000000..8f2672d66
--- /dev/null
+++ b/packages/lib/constants/envelope-reminder.ts
@@ -0,0 +1,86 @@
+import type { DurationLikeObject } from 'luxon';
+import { Duration } from 'luxon';
+import { z } from 'zod';
+
+export const ZEnvelopeReminderDurationPeriod = z.object({
+ unit: z.enum(['day', 'week', 'month']),
+ amount: z.number().int().min(1),
+});
+
+export const ZEnvelopeReminderDisabledPeriod = z.object({
+ disabled: z.literal(true),
+});
+
+export const ZEnvelopeReminderPeriod = z.union([
+ ZEnvelopeReminderDurationPeriod,
+ ZEnvelopeReminderDisabledPeriod,
+]);
+
+export type TEnvelopeReminderPeriod = z.infer;
+export type TEnvelopeReminderDurationPeriod = z.infer;
+
+export const ZEnvelopeReminderSettings = z.object({
+ sendAfter: ZEnvelopeReminderPeriod,
+ repeatEvery: ZEnvelopeReminderPeriod,
+});
+
+export type TEnvelopeReminderSettings = z.infer;
+
+export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
+ sendAfter: { unit: 'day', amount: 5 },
+ repeatEvery: { unit: 'day', amount: 2 },
+};
+
+const UNIT_TO_LUXON_KEY: Record =
+ {
+ day: 'days',
+ week: 'weeks',
+ month: 'months',
+ };
+
+export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
+ return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
+};
+
+/**
+ * Resolve the next reminder timestamp from the config and the last reminder sent time.
+ *
+ * - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
+ * - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
+ * - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
+ *
+ * `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.
+ */
+export const resolveNextReminderAt = (options: {
+ config: TEnvelopeReminderSettings | null;
+ sentAt: Date;
+ lastReminderSentAt: Date | null;
+}): Date | null => {
+ const { config, sentAt, lastReminderSentAt } = options;
+
+ if (!config) {
+ return null;
+ }
+
+ // If we haven't sent the first reminder yet, use sendAfter.
+ if (!lastReminderSentAt) {
+ if ('disabled' in config.sendAfter) {
+ return null;
+ }
+
+ const delay = getEnvelopeReminderDuration(config.sendAfter);
+
+ return new Date(sentAt.getTime() + delay.toMillis());
+ }
+
+ // For subsequent reminders, use repeatEvery.
+ if ('disabled' in config.repeatEvery) {
+ return null;
+ }
+
+ const interval = getEnvelopeReminderDuration(config.repeatEvery);
+
+ return new Date(lastReminderSentAt.getTime() + interval.toMillis());
+};
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index b9f5c6368..d0b2c4f98 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -16,8 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
+import { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
+import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
@@ -43,6 +45,8 @@ export const jobsClient = new JobClient([
EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
+ SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
+ PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
] as const);
diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
index 17f099afa..f02e36673 100644
--- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
+++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
@@ -22,6 +22,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
+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';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@@ -206,6 +207,8 @@ export const run = async ({
});
}
+ const sentAt = new Date();
+
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
@@ -213,26 +216,33 @@ export const run = async ({
},
data: {
sendStatus: SendStatus.SENT,
+ sentAt,
},
});
});
- await io.runTask('store-audit-log', async () => {
- await prisma.documentAuditLog.create({
- data: createDocumentAuditLogData({
- type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
- envelopeId: envelope.id,
- user,
- requestMetadata,
- data: {
- emailType: recipientEmailType,
- recipientId: recipient.id,
- recipientName: recipient.name,
- recipientEmail: recipient.email,
- recipientRole: recipient.role,
- isResending: false,
- },
- }),
- });
+ // Compute the first reminder time based on the envelope's effective settings.
+ await updateRecipientNextReminder({
+ recipientId: recipient.id,
+ envelopeId: envelope.id,
+ sentAt,
+ lastReminderSentAt: null,
+ });
+
+ await prisma.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
+ envelopeId: envelope.id,
+ user,
+ requestMetadata,
+ data: {
+ emailType: recipientEmailType,
+ recipientId: recipient.id,
+ recipientName: recipient.name,
+ recipientEmail: recipient.email,
+ recipientRole: recipient.role,
+ isResending: false,
+ },
+ }),
});
};
diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
new file mode 100644
index 000000000..94bffad9d
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
@@ -0,0 +1,222 @@
+import { createElement } from 'react';
+
+import { msg } from '@lingui/core/macro';
+import {
+ DocumentDistributionMethod,
+ DocumentStatus,
+ OrganisationType,
+ RecipientRole,
+ SendStatus,
+ SigningStatus,
+ WebhookTriggerEvents,
+} from '@prisma/client';
+
+import { mailer } from '@documenso/email/mailer';
+import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
+import { prisma } from '@documenso/prisma';
+
+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 { getEmailContext } from '../../../server-only/email/get-email-context';
+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';
+import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
+import {
+ ZWebhookDocumentSchema,
+ mapEnvelopeToWebhookDocumentPayload,
+} from '../../../types/webhook-payload';
+import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
+import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
+import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
+
+export const run = async ({
+ payload,
+ io,
+}: {
+ payload: TProcessSigningReminderJobDefinition;
+ io: JobRunIO;
+}) => {
+ const { recipientId } = payload;
+ const now = new Date();
+
+ // Atomically claim this reminder by setting lastReminderSentAt and clearing
+ // nextReminderAt so no other sweep picks it up.
+ const updatedCount = await prisma.recipient.updateMany({
+ where: {
+ id: recipientId,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ sendStatus: SendStatus.SENT,
+ role: { not: RecipientRole.CC },
+ envelope: {
+ status: DocumentStatus.PENDING,
+ deletedAt: null,
+ },
+ },
+ data: {
+ lastReminderSentAt: now,
+ nextReminderAt: null,
+ },
+ });
+
+ if (updatedCount.count === 0) {
+ io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
+ return;
+ }
+
+ const recipient = await prisma.recipient.findFirst({
+ where: { id: recipientId },
+ include: {
+ envelope: {
+ include: {
+ documentMeta: true,
+ user: true,
+ recipients: true,
+ team: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!recipient) {
+ io.logger.warn(`Recipient ${recipientId} not found`);
+ return;
+ }
+
+ const { envelope } = recipient;
+
+ if (!envelope.documentMeta) {
+ io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
+ return;
+ }
+
+ // Skip if distribution method is NONE (manual link sharing, no emails).
+ if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
+ io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
+ return;
+ }
+
+ if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
+ io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
+ return;
+ }
+
+ const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
+ await getEmailContext({
+ emailType: 'RECIPIENT',
+ source: {
+ type: 'team',
+ teamId: envelope.teamId,
+ },
+ meta: envelope.documentMeta,
+ });
+
+ const i18n = await getI18nInstance(emailLanguage);
+
+ const recipientActionVerb = i18n
+ ._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
+ .toLowerCase();
+
+ let emailSubject = i18n._(
+ msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`,
+ );
+
+ if (organisationType === OrganisationType.ORGANISATION) {
+ emailSubject = i18n._(
+ msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
+ );
+ }
+
+ const customEmailTemplate = {
+ 'signer.name': recipient.name,
+ 'signer.email': recipient.email,
+ 'document.name': envelope.title,
+ };
+
+ if (envelope.documentMeta.subject) {
+ emailSubject = renderCustomEmailTemplate(
+ i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
+ customEmailTemplate,
+ );
+ }
+
+ const emailMessage = envelope.documentMeta.message
+ ? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
+ : undefined;
+
+ const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
+ const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${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,
+ recipientId: recipient.id,
+ recipientRole: recipient.role,
+ emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
+ isResending: false,
+ },
+ }),
+ });
+
+ await triggerWebhook({
+ event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
+ data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
+ userId: envelope.userId,
+ teamId: envelope.teamId,
+ });
+
+ // Compute the next reminder time (repeat interval).
+ if (recipient.sentAt) {
+ await updateRecipientNextReminder({
+ recipientId: recipient.id,
+ envelopeId: envelope.id,
+ sentAt: recipient.sentAt,
+ lastReminderSentAt: now,
+ });
+ }
+};
diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.ts
new file mode 100644
index 000000000..aacc720a2
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+import { type JobDefinition } from '../../client/_internal/job';
+
+const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
+
+const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
+ recipientId: z.number(),
+});
+
+export type TProcessSigningReminderJobDefinition = z.infer<
+ typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA
+>;
+
+export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
+ id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
+ name: 'Process Signing Reminder',
+ version: '1.0.0',
+ trigger: {
+ name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
+ schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./process-signing-reminder.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
+ TProcessSigningReminderJobDefinition
+>;
diff --git a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts
new file mode 100644
index 000000000..e28aa0139
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts
@@ -0,0 +1,49 @@
+import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
+
+import { prisma } from '@documenso/prisma';
+
+import { jobs } from '../../client';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
+
+export const run = async ({
+ io,
+}: {
+ payload: TSendSigningRemindersSweepJobDefinition;
+ io: JobRunIO;
+}) => {
+ const now = new Date();
+
+ const recipients = await prisma.recipient.findMany({
+ where: {
+ nextReminderAt: { lte: now },
+ signingStatus: SigningStatus.NOT_SIGNED,
+ sendStatus: SendStatus.SENT,
+ role: { not: RecipientRole.CC },
+ envelope: {
+ status: DocumentStatus.PENDING,
+ deletedAt: null,
+ },
+ },
+ select: { id: true },
+ take: 1000,
+ });
+
+ if (recipients.length === 0) {
+ io.logger.info('No recipients need signing reminders');
+ return;
+ }
+
+ io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
+
+ await Promise.allSettled(
+ recipients.map(async (recipient) => {
+ await jobs.triggerJob({
+ name: 'internal.process-signing-reminder',
+ payload: {
+ recipientId: recipient.id,
+ },
+ });
+ }),
+ );
+};
diff --git a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts
new file mode 100644
index 000000000..1c12ad19f
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+
+import { type JobDefinition } from '../../client/_internal/job';
+
+const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
+
+const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
+
+export type TSendSigningRemindersSweepJobDefinition = z.infer<
+ typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
+>;
+
+export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
+ id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
+ name: 'Send Signing Reminders Sweep',
+ version: '1.0.0',
+ trigger: {
+ name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
+ schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
+ cron: '*/15 * * * *', // Every 15 minutes.
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./send-signing-reminders-sweep.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
+ TSendSigningRemindersSweepJobDefinition
+>;
diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts
index e8f78a673..a9030ff4f 100644
--- a/packages/lib/server-only/document/complete-document-with-token.ts
+++ b/packages/lib/server-only/document/complete-document-with-token.ts
@@ -442,6 +442,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
+ sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts
index 5bdd1828b..23aa6d710 100644
--- a/packages/lib/server-only/document/viewed-document.ts
+++ b/packages/lib/server-only/document/viewed-document.ts
@@ -69,6 +69,8 @@ export const viewedDocument = async ({
// This handles cases where distribution is done manually
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED,
+ // Only set sentAt if not already set (email may have been sent before they opened).
+ ...(!recipient.sentAt ? { sentAt: new Date() } : {}),
},
});
@@ -92,6 +94,9 @@ export const viewedDocument = async ({
});
});
+ // Don't schedule reminders for manually distributed documents —
+ // there's no email pathway to send them through.
+
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
diff --git a/packages/lib/server-only/envelope/update-envelope.ts b/packages/lib/server-only/envelope/update-envelope.ts
index 0abbd19c9..b4e0a8d45 100644
--- a/packages/lib/server-only/envelope/update-envelope.ts
+++ b/packages/lib/server-only/envelope/update-envelope.ts
@@ -18,6 +18,7 @@ import {
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
+import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
@@ -354,6 +355,11 @@ export const updateEnvelope = async ({
return result;
});
+ // Recompute reminders for active recipients when reminder settings change.
+ if (meta && 'reminderSettings' in meta) {
+ await recomputeNextReminderForEnvelope(envelope.id);
+ }
+
if (envelope.type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
diff --git a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts
new file mode 100644
index 000000000..5530580bb
--- /dev/null
+++ b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts
@@ -0,0 +1,112 @@
+import {
+ DocumentDistributionMethod,
+ RecipientRole,
+ SendStatus,
+ SigningStatus,
+} from '@prisma/client';
+
+import { prisma } from '@documenso/prisma';
+
+import {
+ ZEnvelopeReminderSettings,
+ resolveNextReminderAt,
+} from '../../constants/envelope-reminder';
+
+/**
+ * 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).
+ */
+export const updateRecipientNextReminder = async (options: {
+ recipientId: number;
+ envelopeId: string;
+ sentAt: Date;
+ lastReminderSentAt: Date | null;
+ reminderSettings?: ReturnType | null;
+}) => {
+ const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
+
+ let settings = options.reminderSettings;
+
+ if (settings === undefined) {
+ const envelope = await prisma.envelope.findFirst({
+ where: { id: envelopeId },
+ select: { documentMeta: { select: { reminderSettings: true } } },
+ });
+
+ settings = envelope?.documentMeta?.reminderSettings
+ ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
+ : null;
+ }
+
+ const nextReminderAt = resolveNextReminderAt({
+ config: settings,
+ sentAt,
+ lastReminderSentAt,
+ });
+
+ await prisma.recipient.update({
+ where: { id: recipientId },
+ data: { nextReminderAt },
+ });
+};
+
+/**
+ * Recompute `nextReminderAt` for all active (unsigned, sent) recipients
+ * of a given envelope. Call when document-level reminder settings change.
+ */
+export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
+ const envelope = await prisma.envelope.findFirst({
+ where: { id: envelopeId },
+ select: {
+ documentMeta: {
+ select: { reminderSettings: true, distributionMethod: true },
+ },
+ },
+ });
+
+ // No reminders for manually distributed documents.
+ const isEmailDistribution =
+ envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE;
+
+ const settings =
+ isEmailDistribution && envelope?.documentMeta?.reminderSettings
+ ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
+ : null;
+
+ const recipients = await prisma.recipient.findMany({
+ where: {
+ envelopeId,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ sendStatus: SendStatus.SENT,
+ sentAt: { not: null },
+ role: { not: RecipientRole.CC },
+ },
+ select: { id: true, sentAt: true, lastReminderSentAt: true },
+ });
+
+ await Promise.all(
+ recipients.map(async (recipient) => {
+ if (!recipient.sentAt) {
+ return;
+ }
+
+ const nextReminderAt = resolveNextReminderAt({
+ config: settings,
+ sentAt: recipient.sentAt,
+ lastReminderSentAt: recipient.lastReminderSentAt,
+ });
+
+ await prisma.recipient.update({
+ where: { id: recipient.id },
+ data: { nextReminderAt },
+ });
+ }),
+ );
+};
diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts
index 59b1ec910..0cb2cd2e9 100644
--- a/packages/lib/server-only/template/create-document-from-direct-template.ts
+++ b/packages/lib/server-only/template/create-document-from-direct-template.ts
@@ -705,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
+ sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts
index 5a1f21a83..eafa1723f 100644
--- a/packages/lib/types/document-audit-logs.ts
+++ b/packages/lib/types/document-audit-logs.ts
@@ -63,6 +63,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'ASSISTING_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
+ 'REMINDER',
]);
export const ZDocumentMetaDiffTypeSchema = z.enum([
diff --git a/packages/lib/types/document-meta.ts b/packages/lib/types/document-meta.ts
index f3ad49def..759a6fdc8 100644
--- a/packages/lib/types/document-meta.ts
+++ b/packages/lib/types/document-meta.ts
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -131,6 +132,7 @@ export const ZDocumentMetaCreateSchema = z.object({
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
+ reminderSettings: ZEnvelopeReminderSettings.nullish(),
});
export type TDocumentMetaCreate = z.infer;
diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts
index 125cae519..56bc75568 100644
--- a/packages/lib/types/document.ts
+++ b/packages/lib/types/document.ts
@@ -72,6 +72,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
+ reminderSettings: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts
index 02cfa4abd..a8b8787e5 100644
--- a/packages/lib/types/envelope-editor.ts
+++ b/packages/lib/types/envelope-editor.ts
@@ -43,6 +43,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowConfigureRedirectUrl: z.boolean(),
allowConfigureDistribution: z.boolean(),
allowConfigureExpirationPeriod: z.boolean(),
+ allowConfigureReminders: z.boolean(),
allowConfigureEmailSender: z.boolean(),
allowConfigureEmailReplyTo: z.boolean(),
})
@@ -122,6 +123,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
+ allowConfigureReminders: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
},
@@ -180,6 +182,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
+ allowConfigureReminders: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
},
@@ -271,6 +274,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
+ reminderSettings: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
diff --git a/packages/lib/types/recipient.ts b/packages/lib/types/recipient.ts
index 93ce73f02..a15233761 100644
--- a/packages/lib/types/recipient.ts
+++ b/packages/lib/types/recipient.ts
@@ -118,6 +118,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
templateId: true,
});
+export type TRecipientSchema = z.infer;
+export type TRecipientLite = z.infer;
+export type TRecipientMany = z.infer;
+export type TEnvelopeRecipientSchema = z.infer;
+export type TEnvelopeRecipientLite = z.infer;
+export type TEnvelopeRecipientMany = z.infer;
+
export const ZRecipientEmailSchema = z.union([
z.literal(''),
zEmail('Invalid email').trim().toLowerCase().max(254),
diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts
index 58753a6c8..eaf63d268 100644
--- a/packages/lib/utils/document.ts
+++ b/packages/lib/utils/document.ts
@@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = (
// Envelope expiration.
envelopeExpirationPeriod:
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
+
+ // Reminder settings.
+ reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
} satisfies Omit;
};
diff --git a/packages/lib/utils/embed-config.ts b/packages/lib/utils/embed-config.ts
index d3f44b72e..5456d471e 100644
--- a/packages/lib/utils/embed-config.ts
+++ b/packages/lib/utils/embed-config.ts
@@ -67,6 +67,9 @@ export const buildEmbeddedFeatures = (
allowConfigureExpirationPeriod:
features.settings?.allowConfigureExpirationPeriod ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
+ allowConfigureReminders:
+ features.settings?.allowConfigureReminders ??
+ DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
allowConfigureEmailSender:
features.settings?.allowConfigureEmailSender ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
diff --git a/packages/lib/utils/envelope.ts b/packages/lib/utils/envelope.ts
index 090e77f6f..0f0aba1c3 100644
--- a/packages/lib/utils/envelope.ts
+++ b/packages/lib/utils/envelope.ts
@@ -252,7 +252,7 @@ export type EnvelopeItemPermissions = {
export const getEnvelopeItemPermissions = (
envelope: Pick,
- recipients: Recipient[],
+ recipients: Pick[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
if (
diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts
index b38e9a697..e4eb814f3 100644
--- a/packages/lib/utils/organisations.ts
+++ b/packages/lib/utils/organisations.ts
@@ -9,6 +9,7 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/orga
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
+import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
@@ -142,6 +143,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
+ reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
+
aiFeaturesEnabled: false,
};
};
diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts
index a8a4c574f..ce7314f42 100644
--- a/packages/lib/utils/recipients.ts
+++ b/packages/lib/utils/recipients.ts
@@ -1,10 +1,11 @@
import type { Envelope } from '@prisma/client';
-import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
+import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
+import type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod';
@@ -20,7 +21,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con
*
* Currently only SIGNERs are validated - they must have at least one signature field.
*/
-export const getRecipientsWithMissingFields = >(
+export const getRecipientsWithMissingFields = >(
recipients: T[],
fields: Pick[],
): T[] => {
@@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
/**
* Whether a recipient can be modified by the document owner.
*/
-export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
+export const canRecipientBeModified = (
+ recipient: TRecipientLite,
+ fields: Pick[],
+) => {
if (!recipient) {
return false;
}
@@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
* - They are not a Viewer or CCer
* - They can be modified (canRecipientBeModified)
*/
-export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => {
+export const canRecipientFieldsBeModified = (
+ recipient: TRecipientLite,
+ fields: Pick[],
+) => {
if (!canRecipientBeModified(recipient, fields)) {
return false;
}
@@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
};
export const mapRecipientToLegacyRecipient = (
- recipient: Recipient,
+ recipient: TRecipientLite,
envelope: Pick,
) => {
const legacyId = extractLegacyIds(envelope);
@@ -92,6 +99,7 @@ export const mapRecipientToLegacyRecipient = (
};
};
+
export const findRecipientByEmail = ({
recipients,
userEmail,
@@ -102,7 +110,7 @@ export const findRecipientByEmail = ({
teamEmail?: string | null;
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
-export const isRecipientEmailValidForSending = (recipient: Pick) => {
+export const isRecipientEmailValidForSending = (recipient: Pick) => {
return zEmail().safeParse(recipient.email).success;
};
diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts
index 02fc562c1..ba71da07a 100644
--- a/packages/lib/utils/teams.ts
+++ b/packages/lib/utils/teams.ts
@@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit {
diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts
index 7dfa9b033..570187cf8 100644
--- a/packages/trpc/server/organisation-router/update-organisation-settings.ts
+++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts
@@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
defaultRecipients,
delegateDocumentOwnership,
envelopeExpirationPeriod,
+ reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -151,6 +152,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
+ reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts
index bc211a93c..daf93d821 100644
--- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts
+++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -28,6 +29,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
+ reminderSettings: ZEnvelopeReminderSettings.optional(),
// Branding related settings.
brandingEnabled: z.boolean().optional(),
diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts
index f764bc95d..b44db128a 100644
--- a/packages/trpc/server/team-router/update-team-settings.ts
+++ b/packages/trpc/server/team-router/update-team-settings.ts
@@ -41,6 +41,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
+ reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -157,6 +158,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
delegateDocumentOwnership,
envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
+ reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts
index 0ba4bebed..e8f24e611 100644
--- a/packages/trpc/server/team-router/update-team-settings.types.ts
+++ b/packages/trpc/server/team-router/update-team-settings.types.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -31,6 +32,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
+ reminderSettings: ZEnvelopeReminderSettings.nullish(),
// Branding related settings.
brandingEnabled: z.boolean().nullish(),
diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx
index 6fb855a2b..818d3ea25 100644
--- a/packages/ui/components/document/document-read-only-fields.tsx
+++ b/packages/ui/components/document/document-read-only-fields.tsx
@@ -2,12 +2,13 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { DocumentMeta, Field, Recipient } from '@prisma/client';
+import type { DocumentMeta, Field } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
@@ -34,7 +35,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
};
export type DocumentField = Field & {
- recipient: Pick;
+ recipient: Pick;
};
export type DocumentReadOnlyFieldsProps = {
@@ -68,7 +69,7 @@ export type DocumentReadOnlyFieldsProps = {
export const mapFieldsWithRecipients = (
fields: Field[],
- recipients: Recipient[],
+ recipients: TRecipientLite[],
): DocumentField[] => {
return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
diff --git a/packages/ui/components/document/expiration-period-picker.tsx b/packages/ui/components/document/expiration-period-picker.tsx
index ee1c39263..1d2347560 100644
--- a/packages/ui/components/document/expiration-period-picker.tsx
+++ b/packages/ui/components/document/expiration-period-picker.tsx
@@ -90,7 +90,7 @@ export const ExpirationPeriodPicker = ({
return (