From 19c2f7b4a14740706941386d788ce5921d04c3ad Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 27 Apr 2026 10:51:14 +1000 Subject: [PATCH] docs: add signing reminders guide (#2716) --- .../docs/users/documents/advanced/index.mdx | 5 + .../docs/users/documents/advanced/meta.json | 3 +- .../documents/advanced/signing-reminders.mdx | 195 ++++++++++++++++++ .../organisations/preferences/document.mdx | 1 + packages/lib/constants/envelope-reminder.ts | 36 +++- .../internal/backport-subscription-claims.ts | 1 + .../process-signing-reminder.handler.ts | 5 +- .../send-signing-reminders-sweep.handler.ts | 5 + .../update-recipient-next-reminder.ts | 4 + packages/lib/types/subscription.ts | 11 + 10 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx diff --git a/apps/docs/content/docs/users/documents/advanced/index.mdx b/apps/docs/content/docs/users/documents/advanced/index.mdx index 6b4572242..8ff904d66 100644 --- a/apps/docs/content/docs/users/documents/advanced/index.mdx +++ b/apps/docs/content/docs/users/documents/advanced/index.mdx @@ -29,4 +29,9 @@ description: Advanced document features including PDF placeholders, AI detection description="Set a signing deadline so document links expire after a configurable period." href="/docs/users/documents/advanced/recipient-expiration" /> + diff --git a/apps/docs/content/docs/users/documents/advanced/meta.json b/apps/docs/content/docs/users/documents/advanced/meta.json index 88dda8485..f56ea634c 100644 --- a/apps/docs/content/docs/users/documents/advanced/meta.json +++ b/apps/docs/content/docs/users/documents/advanced/meta.json @@ -6,6 +6,7 @@ "ai-detection", "default-recipients", "document-visibility", - "recipient-expiration" + "recipient-expiration", + "signing-reminders" ] } diff --git a/apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx b/apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx new file mode 100644 index 000000000..1f674526e --- /dev/null +++ b/apps/docs/content/docs/users/documents/advanced/signing-reminders.mdx @@ -0,0 +1,195 @@ +--- +title: Signing Reminders +description: Automatically email recipients who have not yet signed on a configurable schedule. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; +import { Step, Steps } from 'fumadocs-ui/components/steps'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +## Overview + +Signing reminders automatically email recipients who have not completed their signing. You configure when the first reminder goes out and how often it repeats — Documenso handles the rest until the recipient signs or the document leaves a pending state. + +This is useful when: + +- You want recipients nudged without having to track them down manually +- A deadline is approaching and you want to escalate gradually +- You send a high volume of documents and cannot chase each one + +Reminders are tracked **per recipient**, not per document. Each unsigned recipient is on their own schedule based on when they were emailed. + +This is different from the **Resend** action covered in [Send Documents](/docs/users/documents/send), which is a one-off manual nudge. Reminders are scheduled and recurring. + +## Default Behaviour + +Every **newly created** organisation starts with reminders **enabled**: + +- First reminder sent **5 days** after the recipient is emailed +- Repeats **every 2 days** until the recipient signs + +You can change this default at the organisation or team level, or override it per document. + + + Organisations created **before this feature shipped** have reminders left blank (no default) so + recipients of in-flight or future documents are not unexpectedly sent reminders. To enable + reminders for an existing organisation, configure **Default Signing Reminders** under + **Organisation Settings > Preferences > Document**. + + +## Settings Cascade + +Reminder settings follow a three-level cascade: **Organisation → Team → Document**. Each level can override the one above it, or inherit from it. + + + + +Sets the default for all teams in the organisation. Options are **Enabled** (set when the first reminder fires and how often it repeats) or **No reminders**. + +To configure, navigate to **Organisation Settings > Preferences > Document** and find **Default Signing Reminders**. + + + + +Overrides the organisation default for documents created within this team. Options are **Enabled**, **No reminders**, or **Inherit from organisation**. + +New teams default to **Inherit from organisation**. + +To configure, navigate to **Team Settings > Preferences > Document** and find **Default Signing Reminders**. + + + + +Overrides the team or organisation default for a single document. Options are **Enabled**, **No reminders**, or **Inherit from organisation**. + +If you do not change reminders when editing a document, the team or organisation default applies. + + + + +## Set Reminders for a Document + +{/* prettier-ignore */} + + + +### Open the document settings + +In the document editor, open the **Settings** dialog and go to the **Reminders** tab. + + + + +### Choose a mode + +- **Enabled** — Documenso sends reminders on the schedule you configure below +- **No reminders** — no automatic reminders for this document +- **Inherit from organisation** — use the team or organisation default + + + + +### Configure the schedule + +When **Enabled**, set: + +- **Send first reminder after** — a number and unit (days, weeks, or months) measured from when the recipient is first emailed +- **Then repeat every** — either **Custom interval** (a number and unit) or **Don't repeat** (only one reminder is ever sent) + + + + +### Send the document + +The first reminder is scheduled when the recipient receives the initial signing email. Subsequent reminders are scheduled from the time the previous reminder was sent. + + + + + + Editing reminder settings on a document that is already pending recalculates the next reminder for + every unsigned recipient immediately. + + +## Set a Default Reminder Schedule + +{/* prettier-ignore */} + + + +### Navigate to document preferences + +Go to **Organisation Settings > Preferences > Document** (or **Team Settings > Preferences > Document** for team-level overrides). + + + + +### Configure the default + +Find **Default Signing Reminders** and choose: + +- **Enabled** — set the first-reminder delay and repeat interval +- **No reminders** — disable reminders by default +- **Inherit from organisation** (team level only) — use whatever the organisation has configured + + + + +### Save + +Click **Save** to apply. New documents created after this change use the updated default. Documents already in flight are unaffected unless you edit their reminder settings directly. + + + + +## What Happens When a Reminder Fires + +When a recipient's next reminder time arrives: + +1. Documenso sends the reminder email — same template as the original signing request, but the subject and preview are prefixed with **"Reminder:"**. +2. An audit log entry is created for the recipient (an `EMAIL_SENT` entry of type `REMINDER`). +3. The `document.reminder.sent` webhook fires. See [webhook events](/docs/developers/webhooks/events). +4. The next reminder is scheduled based on **Then repeat every**, or no further reminder is scheduled if you chose **Don't repeat**. + +Reminders stop automatically when the recipient signs, declines, the recipient's signing deadline passes, or the document leaves the pending state (completed, rejected, or deleted). + +## When Reminders Are Skipped + +A configured reminder will not be sent in the following cases: + +- The recipient is a **CC** — CCs are notified once and never reminded +- The recipient's **signing deadline has passed** — see [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration). Resending the document refreshes the deadline and resumes reminders. +- The document uses **manual link distribution** — Documenso never emails recipients for these documents +- The envelope's email settings have **signing request emails disabled** +- The recipient has not yet been emailed (for example, an unreached step in a sequential workflow) + + + Reminders stop automatically **30 days after the recipient was first emailed**, regardless of the + repeat interval. This hard cap prevents runaway reminder chains for recipients who never sign and + have no expiration set. If you need a different stop condition, set a shorter repeat interval, + use **Don't repeat**, or configure [recipient expiration](/docs/users/documents/advanced/recipient-expiration). + + + + Reminders are dispatched by a background sweep that runs every 15 minutes, so the actual send time + may be up to ~15 minutes after the scheduled time. + + +## Reminder Options Reference + +| Setting | Options | Notes | +| --------------------------- | ---------------------------------------- | -------------------------------------------------------------------- | +| **Mode** | Enabled / No reminders / Inherit | Inherit is only available at the team and document levels | +| **Send first reminder after** | 1+ days, weeks, or months | Measured from when the recipient receives the initial signing email. Reminders past 30 days from that moment are skipped. | +| **Then repeat every** | Custom interval (1+ days/weeks/months) or Don't repeat | Custom interval keeps reminding until the recipient signs or the 30-day cap is reached | + +The organisation default out of the box is **first reminder after 5 days, repeating every 2 days**, which falls well inside the 30-day cap. + +--- + +## See Also + +- [Send Documents](/docs/users/documents/send) - Send documents and trigger a one-off resend +- [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration) - Set a hard signing deadline +- [Document Preferences](/docs/users/organisations/preferences/document) - Configure default document settings +- [Webhook Events](/docs/developers/webhooks/events) - Subscribe to `document.reminder.sent` diff --git a/apps/docs/content/docs/users/organisations/preferences/document.mdx b/apps/docs/content/docs/users/organisations/preferences/document.mdx index 4d6bab54b..1f4e82c08 100644 --- a/apps/docs/content/docs/users/organisations/preferences/document.mdx +++ b/apps/docs/content/docs/users/organisations/preferences/document.mdx @@ -33,6 +33,7 @@ To access the preferences, navigate to either the organisation or teams settings | **Include the Audit Logs** | Whether the audit logs are embedded in the document when downloaded. The audit logs are always available separately from the logs page. | | **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. | | **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). | +| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). | | **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. | | **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. | diff --git a/packages/lib/constants/envelope-reminder.ts b/packages/lib/constants/envelope-reminder.ts index 8f2672d66..da045de55 100644 --- a/packages/lib/constants/envelope-reminder.ts +++ b/packages/lib/constants/envelope-reminder.ts @@ -31,6 +31,14 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = { repeatEvery: { unit: 'day', amount: 2 }, }; +/** + * Hard upper bound on the window in which automated reminders may be sent, + * measured from the moment the signing request was first sent to the + * recipient. Prevents runaway reminder chains for recipients with no + * expiration set who never sign. + */ +export const MAX_REMINDER_WINDOW_DAYS = 30; + const UNIT_TO_LUXON_KEY: Record = { day: 'days', @@ -49,6 +57,9 @@ 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. + * * `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. @@ -64,6 +75,12 @@ export const resolveNextReminderAt = (options: { return null; } + const maxReminderAt = new Date( + sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis(), + ); + + let candidate: Date; + // If we haven't sent the first reminder yet, use sendAfter. if (!lastReminderSentAt) { if ('disabled' in config.sendAfter) { @@ -72,15 +89,22 @@ export const resolveNextReminderAt = (options: { const delay = getEnvelopeReminderDuration(config.sendAfter); - return new Date(sentAt.getTime() + delay.toMillis()); + candidate = new Date(sentAt.getTime() + delay.toMillis()); + } else { + // For subsequent reminders, use repeatEvery. + if ('disabled' in config.repeatEvery) { + return null; + } + + const interval = getEnvelopeReminderDuration(config.repeatEvery); + + candidate = new Date(lastReminderSentAt.getTime() + interval.toMillis()); } - // For subsequent reminders, use repeatEvery. - if ('disabled' in config.repeatEvery) { + // Stop if the candidate is past the hard cap measured from sentAt. + if (candidate.getTime() > maxReminderAt.getTime()) { return null; } - const interval = getEnvelopeReminderDuration(config.repeatEvery); - - return new Date(lastReminderSentAt.getTime() + interval.toMillis()); + return candidate; }; diff --git a/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts b/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts index dcb83456e..e2c54fcda 100644 --- a/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts +++ b/packages/lib/jobs/definitions/internal/backport-subscription-claims.ts @@ -19,6 +19,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({ embedSigningWhiteLabel: z.literal(true).optional(), cfr21: z.literal(true).optional(), hipaa: z.literal(true).optional(), + signingReminders: z.literal(true).optional(), // Todo: Envelopes - Do we need to check? // authenticationPortal & emailDomains missing here. }), diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts index 94bffad9d..eea5b69a4 100644 --- a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -44,13 +44,16 @@ export const run = async ({ const now = new Date(); // Atomically claim this reminder by setting lastReminderSentAt and clearing - // nextReminderAt so no other sweep picks it up. + // nextReminderAt so no other sweep picks it up. The expiration filter + // guards against races where the expiration sweep hasn't yet flagged + // a recipient whose deadline has already passed. const updatedCount = await prisma.recipient.updateMany({ where: { id: recipientId, signingStatus: SigningStatus.NOT_SIGNED, sendStatus: SendStatus.SENT, role: { not: RecipientRole.CC }, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], envelope: { status: DocumentStatus.PENDING, deletedAt: null, 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 index e28aa0139..13a956935 100644 --- a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts +++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts @@ -20,6 +20,11 @@ export const run = async ({ signingStatus: SigningStatus.NOT_SIGNED, sendStatus: SendStatus.SENT, role: { not: RecipientRole.CC }, + // Skip recipients whose signing deadline has passed. `expiresAt` + // is the source of truth — the expiration sweep asynchronously + // sets `expirationNotifiedAt`, so filtering on `expiresAt` also + // covers the window before the expiration sweep runs. + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], envelope: { status: DocumentStatus.PENDING, deletedAt: null, diff --git a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts index 5530580bb..fe7cbee23 100644 --- a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts +++ b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts @@ -80,6 +80,8 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => { ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings) : null; + const now = new Date(); + const recipients = await prisma.recipient.findMany({ where: { envelopeId, @@ -87,6 +89,8 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => { sendStatus: SendStatus.SENT, sentAt: { not: null }, role: { not: RecipientRole.CC }, + // Don't reschedule reminders for recipients whose deadline has passed. + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }, select: { id: true, sentAt: true, lastReminderSentAt: true }, }); diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index 724a6c747..d12b906e9 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({ authenticationPortal: z.boolean().optional(), allowLegacyEnvelopes: z.boolean().optional(), + + signingReminders: z.boolean().optional(), }); export type TClaimFlags = z.infer; @@ -101,6 +103,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< key: 'allowLegacyEnvelopes', label: 'Allow Legacy Envelopes', }, + signingReminders: { + key: 'signingReminders', + label: 'Signing reminders', + }, }; export enum INTERNAL_CLAIM_ID { @@ -137,6 +143,7 @@ export const internalClaims: InternalClaims = { locked: true, flags: { unlimitedDocuments: true, + signingReminders: true, }, }, [INTERNAL_CLAIM_ID.TEAM]: { @@ -150,6 +157,7 @@ export const internalClaims: InternalClaims = { unlimitedDocuments: true, allowCustomBranding: true, embedSigning: true, + signingReminders: true, }, }, [INTERNAL_CLAIM_ID.PLATFORM]: { @@ -168,6 +176,7 @@ export const internalClaims: InternalClaims = { embedAuthoringWhiteLabel: true, embedSigning: false, embedSigningWhiteLabel: true, + signingReminders: true, }, }, [INTERNAL_CLAIM_ID.ENTERPRISE]: { @@ -188,6 +197,7 @@ export const internalClaims: InternalClaims = { embedSigningWhiteLabel: true, cfr21: true, authenticationPortal: true, + signingReminders: true, }, }, [INTERNAL_CLAIM_ID.EARLY_ADOPTER]: { @@ -203,6 +213,7 @@ export const internalClaims: InternalClaims = { hidePoweredBy: true, embedSigning: true, embedSigningWhiteLabel: true, + signingReminders: true, }, }, } as const;