Compare commits

..

3 Commits

Author SHA1 Message Date
Lucas Smith 844af17ec2 feat: move email sending into background jobs for retry support
Each direct mailer.sendMail() call is replaced by a dedicated background
job so that email delivery failures can be retried independently.

New jobs: send-pending-email, send-completed-email, send-forgot-password-email,
send-document-super-delete-email, send-recipient-removed-email,
send-document-deleted-emails, send-2fa-token-email, send-resend-document-email,
send-direct-template-created-email.

Existing handlers (send-document-cancelled-emails, send-organisation-member-*,
send-recipient-signed-email) have io.runTask wrappers removed since they
interfere with the job scheduler. Job triggers are dispatched after
transactions commit to avoid race conditions with uncommitted data.
2026-02-20 23:25:37 +11:00
Lucas Smith 653ab3678a feat: better ratelimiting (#2520)
Replace hono-rate-limiter with a Prisma/PostgreSQL bucketed counter
approach that works correctly across multiple instances without sticky
sessions.

- Add RateLimit model with composite PK (key, action, bucket) and atomic
upsert
- Create rate limit factory with window parsing, bucket computation, and
fail-open
- Define auth-tier and API-tier rate limit instances
- Add Hono middleware, rateLimitResponse helper, and tRPC
assertRateLimit helper
- Wire rate limit headers through AppError constructor (was declared but
never assigned)
- Apply rate limits to auth routes (email-password, passkey), tRPC
routes
  (2FA email, link org account), API routes, and file upload endpoints
- Add cleanup cron job for expired rate limit rows (batched delete every
15 min)
- Remove hono-rate-limiter dependency
2026-02-20 12:23:02 +11:00
Lucas Smith 006b1d0a57 feat: per-recipient envelope expiration (#2519) 2026-02-20 11:36:20 +11:00
110 changed files with 5000 additions and 593 deletions
@@ -0,0 +1,519 @@
---
date: 2026-02-10
title: Envelope Expiration
---
## Summary
Envelopes (documents sent for signing) should automatically expire after a configurable period, preventing recipients from completing stale documents. Expiration is tracked **per-recipient** — when a recipient's signing window lapses, the document owner is notified and can resend (extending the deadline) or cancel. The document itself stays PENDING so other recipients can continue signing.
**Settings cascade**: Organisation → Team → Document (each level can override the prior).
**Default**: 1 month from when the envelope is sent (transitions to PENDING).
---
## 1. Database Schema Changes
### 1.1 Expiration period data shape
Store expiration as a structured JSON object rather than an enum or raw milliseconds. This avoids the enum treadmill (adding `FOUR_MONTHS` later requires a migration) while keeping values validated and meaningful.
**Zod schema** (defined in `packages/lib/constants/envelope-expiration.ts`):
```typescript
export const ZEnvelopeExpirationPeriod = z.union([
z.object({ unit: z.enum(['day', 'week', 'month', 'year']), amount: z.number().int().min(1) }),
z.object({ disabled: z.literal(true) }),
]);
export type TEnvelopeExpirationPeriod = z.infer<typeof ZEnvelopeExpirationPeriod>;
```
Semantics:
- `null` on `DocumentMeta` / `TeamGlobalSettings` = inherit from parent
- `{ disabled: true }` = explicitly never expires
- `{ unit: 'month', amount: 1 }` = expires in 1 month
No Prisma enum is needed — the period is stored as `Json?` on the relevant models (see sections 1.3 and 1.4).
### 1.2 Add expiration fields to `Recipient`
```prisma
model Recipient {
// ... existing fields
expiresAt DateTime?
expirationNotifiedAt DateTime? // null = not yet notified; set when owner notification sent
@@index([expiresAt])
}
```
`expiresAt` is a computed timestamp set when the envelope transitions to PENDING (at send time). It is calculated from the effective expiration period. Storing the concrete timestamp rather than a relative duration means:
- Sweep queries are simple (`WHERE expiresAt <= NOW() AND expirationNotifiedAt IS NULL`)
- No need to re-resolve the settings cascade at query time
- The sender can see the exact deadline in the UI
- The index on `expiresAt` ensures the expiration sweep query is efficient
`expirationNotifiedAt` tracks whether the owner has already been notified about this recipient's expiration, making the notification job idempotent.
### 1.3 Add expiration period to settings models
**OrganisationGlobalSettings** (JSON, application-level default):
```prisma
model OrganisationGlobalSettings {
// ... existing fields
envelopeExpirationPeriod Json?
}
```
Prisma `@default` doesn't work for `Json` columns, so the application-level default (`{ unit: 'month', amount: 1 }`) is applied in `extractDerivedTeamSettings` / `extractDerivedDocumentMeta` when the value is null. The migration should backfill existing rows with `{ "unit": "month", "amount": 1 }`.
**TeamGlobalSettings** (nullable, null = inherit from org):
```prisma
model TeamGlobalSettings {
// ... existing fields
envelopeExpirationPeriod Json?
}
```
### 1.4 Add expiration period to DocumentMeta
This allows per-document override during the document editing flow:
```prisma
model DocumentMeta {
// ... existing fields
envelopeExpirationPeriod Json?
}
```
When null on DocumentMeta, the resolved team/org setting is used at send time. Validated at write time using `ZEnvelopeExpirationPeriod.nullable()`.
**Important**: `envelopeExpirationPeriod` on `DocumentMeta` is a user-facing preference that may be set during the draft editing flow. It does NOT determine the final expiration — that is resolved at send time (see section 2.3). The value stored here is just the user's selection in the document editor.
---
## 2. Expiration Period Resolution
### 2.1 Duration mapping
Add to `packages/lib/constants/envelope-expiration.ts` alongside the Zod schema:
```typescript
import { Duration } from 'luxon';
const UNIT_TO_LUXON_KEY: Record<TEnvelopeExpirationPeriod['unit'], string> = {
day: 'days',
week: 'weeks',
month: 'months',
year: 'years',
};
export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationPeriod = {
unit: 'month',
amount: 1,
};
export const getEnvelopeExpirationDuration = (period: TEnvelopeExpirationPeriod): Duration => {
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
};
```
### 2.2 Settings cascade integration
`extractDerivedTeamSettings()` in `packages/lib/utils/teams.ts` needs **no code changes** — it iterates `Object.keys(derivedSettings)` and overrides with non-null team values at runtime. The new `envelopeExpirationPeriod` field on both `OrganisationGlobalSettings` and `TeamGlobalSettings` will be automatically picked up.
Update `extractDerivedDocumentMeta()` in `packages/lib/utils/document.ts` to include the new field:
```typescript
envelopeExpirationPeriod: meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod,
```
### 2.3 Compute `expiresAt` at send time
The expiration period is **locked at send time** — when the envelope transitions to PENDING. The concrete `expiresAt` timestamp is computed for each recipient when the document is actually sent.
In `packages/lib/server-only/document/send-document.ts`:
```typescript
// Resolve effective period: document meta -> team/org settings -> default
const rawPeriod =
envelope.documentMeta?.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod;
const expiresAt = resolveExpiresAt(rawPeriod);
// Inside the $transaction, for each recipient:
await tx.recipient.updateMany({
where: { envelopeId: envelope.id },
data: { expiresAt },
});
```
### 2.4 Compute `expiresAt` in the direct template flow
`create-document-from-direct-template.ts` creates envelopes directly as PENDING and then calls `sendDocument` afterward. Since `sendDocument` handles setting `expiresAt` on recipients, the direct template flow doesn't need to set it directly — `sendDocument` handles it.
---
## 3. Cron Job Infrastructure (New)
The current job system is purely event-triggered. Inngest natively supports cron-triggered functions, but the local provider (used in dev and by self-hosters who don't want a third-party dependency) has no scheduling capability. This section adds cron support to the local provider to maintain feature parity.
### 3.1 Extend `JobDefinition` with cron support
Add an optional `cron` field to the trigger type in `packages/lib/jobs/client/_internal/job.ts`:
```typescript
export type JobDefinition<Name extends string = string, Schema = any> = {
id: string;
name: string;
version: string;
enabled?: boolean;
optimizeParallelism?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;
/** Cron expression (e.g. "* * * * *"). When set, the job runs on a schedule. */
cron?: string;
};
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
};
```
### 3.2 Inngest provider: wire up native cron
In `packages/lib/jobs/client/inngest.ts`, when defining a function, check for `cron`:
```typescript
defineJob(job) {
if (job.trigger.cron) {
this._functions.push(
this._client.createFunction(
{ id: job.id, name: job.name },
{ cron: job.trigger.cron },
async ({ step, logger }) => {
const io = convertInngestIoToJobRunIo(step, logger, this);
await job.handler({ payload: {} as any, io });
},
),
);
} else {
// Existing event-triggered logic (unchanged)
}
}
```
### 3.3 Local provider: poller + deterministic `BackgroundJob` IDs
Use the existing `BackgroundJob` table for multi-instance dedupe instead of advisory locks. This approach keeps implementation Prisma-only (no raw SQL), works for single-instance and multi-instance deployments, and preserves existing retry/visibility behavior.
**On `defineJob()`**: If the job has a `cron` field, register an in-process scheduler entry and start a lightweight poller (every 30s with jitter).
**Each poll tick**:
1. Evaluate whether the cron schedule has one or more due run slots since the last tick (use a real cron parser, e.g. `cron-parser`)
2. For each due slot, build a deterministic run ID from job ID + scheduled slot time
3. Create a `BackgroundJob` row with that deterministic ID using Prisma
4. If insert succeeds → enqueue via the existing local job pipeline
5. If insert fails with Prisma `P2002` (unique violation) → another node already enqueued that run, skip
### 3.4 Summary of changes to the job system
| File | Change |
| ------------------------------------------- | ---------------------------------------------------------------- |
| `packages/lib/jobs/client/_internal/job.ts` | Add optional `cron` field to `trigger` type |
| `packages/lib/jobs/client/local.ts` | Add cron poller + deterministic `BackgroundJob.id` dedupe |
| `packages/lib/jobs/client/inngest.ts` | Wire up `{ cron: ... }` in `createFunction` for cron jobs |
| `packages/lib/jobs/client/_internal/*` | Add cron helper utilities (`getDueCronSlots`, run ID generation) |
---
## 4. Expiration Processing
### 4.1 Two-job architecture
Expiration uses two jobs: a **sweep dispatcher** that runs on a cron schedule and finds expired recipients, and an **individual notification job** that handles the audit log, owner notification email, and webhook for a single recipient. This separation means:
- The sweep is lightweight and fast (just a query + N job triggers)
- Each recipient's expiration notification is independently retryable
- The individual jobs are idempotent — they check `expirationNotifiedAt IS NULL` before processing
### 4.2 Sweep job: `EXPIRE_RECIPIENTS_SWEEP_JOB`
A cron-triggered job that runs every minute to find and dispatch notifications for expired recipients.
**Definition:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts`
**Handler:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts`
```typescript
const expiredRecipients = await prisma.recipient.findMany({
where: {
expiresAt: { lte: new Date() },
expirationNotifiedAt: null,
signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] },
envelope: { status: DocumentStatus.PENDING },
},
select: { id: true },
take: 100,
});
for (const recipient of expiredRecipients) {
await jobs.triggerJob({
name: 'internal.notify-recipient-expired',
payload: { recipientId: recipient.id },
});
}
```
### 4.3 Individual notification job: `NOTIFY_RECIPIENT_EXPIRED_JOB`
An event-triggered job that handles a single recipient's expiration.
**Definition:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts`
**Handler:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts`
The handler:
1. Fetches the recipient (with guard: `expirationNotifiedAt IS NULL` + not signed/rejected)
2. Sets `recipient.expirationNotifiedAt = now()` (idempotency)
3. Creates audit log entry with `DOCUMENT_RECIPIENT_EXPIRED` type
4. Sends email notification to the **document owner** (inline — no separate email job)
5. The document stays PENDING — the owner decides whether to resend or cancel
### 4.4 Register in job client
Add `EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION` and `NOTIFY_RECIPIENT_EXPIRED_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`.
### 4.5 Email template: Recipient Expired
Target the **document owner**:
- Subject: `Signing window expired for "{recipientName}" on "{documentTitle}"`
- Body: "The signing window for {recipientName} ({recipientEmail}) on document {title} has expired. You can resend the document to extend their deadline or cancel the document."
- Include a "View Document" link to the document page in the app
Template files:
- `packages/email/templates/recipient-expired.tsx` — wrapper
- `packages/email/template-components/template-recipient-expired.tsx` — body
### 4.6 Recipient signing guard
In the signing flow, check `recipient.expiresAt` before allowing any signing action. Note that the document stays PENDING even after recipient expiration, so the existing `status !== PENDING` guard does not block expired recipients — an explicit expiration check is required:
```typescript
if (recipient.expiresAt && recipient.expiresAt <= new Date()) {
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
message: 'Recipient signing window has expired',
});
}
```
**Files to update:**
- `packages/lib/server-only/document/complete-document-with-token.ts`
- `packages/lib/server-only/field/sign-field-with-token.ts`
- `packages/lib/server-only/field/remove-signed-field-with-token.ts`
- `packages/lib/server-only/document/reject-document-with-token.ts`
---
## 5. UI Design
### 5.1 Expiration Period Selector Component
Use a number input + unit selector combo. This gives organisations full flexibility to configure any duration without needing schema changes for new options.
**Layout**: A horizontal group with:
- A number `<Input>` (min 1, integer)
- A `<Select>` for the unit (`day`, `week`, `month`, `year`)
- A "Never expires" toggle/checkbox that disables the duration inputs and sets the value to `{ disabled: true }`
At the team level, include an "Inherit from organisation" option that clears the value to `null`.
**Validation**: Use `ZEnvelopeExpirationPeriod` for form validation.
### 5.2 Organisation Settings → Document Preferences
Add a "Default Envelope Expiration" field to the `DocumentPreferencesForm` component. At the org level, there is no "Inherit" option — it must have a concrete value (default: `{ unit: 'month', amount: 1 }`).
### 5.3 Team Settings → Document Preferences
Same field as org, but with the additional "Inherit from organisation" option (stored as `null`).
### 5.4 Document Editor → Settings Step
Add the expiration selector to `packages/ui/primitives/document-flow/add-settings.tsx` inside the "Advanced Options" accordion.
Label: **"Expiration"**
Description: _"How long recipients have to complete this document after it is sent."_
### 5.5 Recipient Signing Page — Expired State
When a recipient visits a signing link for an expired recipient:
- Redirect to `/sign/{token}/expired`
- Show a clear, non-alarming message: "Your signing window has expired. Please contact the sender for a new invitation."
- Do not show the signing form or fields
- The `isExpired` flag in `get-envelope-for-recipient-signing.ts` is derived from `recipient.expiresAt`
### 5.6 Embed Signing — Expired State
Embed signing routes handle recipient expiration by throwing `embed-recipient-expired`:
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — both V1 and V2 loaders check expiration
- The embed error boundary renders an `EmbedRecipientExpired` component
- Direct templates (`direct.$token.tsx`) create fresh recipients so `isExpired` is always `false`
---
## 6. API / TRPC Changes
### 6.1 Update settings mutation schemas
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod` (non-nullable at org level)
- `packages/trpc/server/team-router/update-team-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` (null = inherit from org)
### 6.2 Update document mutation schemas
- `packages/lib/types/document-meta.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` to the meta schema
- `packages/trpc/server/document-router/create-document.types.ts` — include in meta
- `packages/trpc/server/document-router/update-document.types.ts` — include in meta
- `packages/trpc/server/document-router/distribute-document.types.ts` — include in meta
### 6.3 Expose `expiresAt` in recipient responses
Ensure `expiresAt` and `expirationNotifiedAt` are returned when fetching recipients/documents so the UI can display expiration status.
### 6.4 Webhook / API schema updates
- Recipient schema includes `expiresAt` and `expirationNotifiedAt` fields (replacing the old `expired` field)
- Update `packages/api/v1/schema.ts`, webhook payload types, zapier integration, and sample data generators
---
## 7. Edge Cases & Considerations
### 7.1 Already-sent documents
The migration should NOT retroactively expire existing recipients. `expiresAt` will be null for all existing recipients, meaning they never expire (backward-compatible).
### 7.2 Re-sending / redistributing
When `redistribute` is called on a PENDING document, `expiresAt` should be refreshed on all eligible recipients. Redistributing signals active intent, so the clock should restart.
**Implementation**: `resendDocument` refreshes `recipient.expiresAt` for all recipients that haven't signed/rejected yet.
### 7.3 Multi-recipient partial expiration
If some recipients have signed and others expire, the document stays PENDING. This is the key advantage over document-level expiration — the owner can resend to extend the expired recipients' deadlines without affecting those who've already signed.
### 7.4 Partial completion
Partial signatures are preserved. The document is not sealed/completed until all required recipients have signed (or the owner cancels).
### 7.5 Timezone handling
`expiresAt` is stored as UTC. Display in the sender's configured timezone.
### 7.6 Race condition: signing at expiration time
The signing guard checks `recipient.expiresAt` in application code before the signing operation. The notification job's guard (`expirationNotifiedAt IS NULL` + `signingStatus NOT IN (SIGNED, REJECTED)`) prevents double-notifications. If a recipient signs just before expiration, the sweep's `signingStatus` filter skips them.
### 7.7 Direct template flow
`create-document-from-direct-template.ts` creates envelopes directly as PENDING then calls `sendDocument`. Since `sendDocument` sets `recipient.expiresAt`, no special handling is needed in the direct template flow.
---
## 8. Migration Plan
1. Add Prisma schema changes (`expiresAt` + `expirationNotifiedAt` on Recipient, `Json?` fields on settings models, index)
2. Generate and run migration
3. Backfill: set `envelopeExpirationPeriod` to `{ "unit": "month", "amount": 1 }` on all existing `OrganisationGlobalSettings` rows
4. No backfill on `Recipient.expiresAt` — existing recipients keep null (never expire)
5. Deploy backend changes (jobs, guards, email template)
6. Deploy frontend changes (settings UI, document editor, signing page, embeds)
---
## 9. Files to Create or Modify
### New Files
- `packages/lib/constants/envelope-expiration.ts``ZEnvelopeExpirationPeriod` schema, types, `DEFAULT_ENVELOPE_EXPIRATION_PERIOD`, `getEnvelopeExpirationDuration()`, `resolveExpiresAt()` helper
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts` — cron sweep job definition
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts` — cron sweep handler
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts` — individual notification job definition
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts` — notification handler (includes inline email sending)
- `packages/email/templates/recipient-expired.tsx` — email template wrapper
- `packages/email/template-components/template-recipient-expired.tsx` — email template body
- `apps/remix/app/components/embed/embed-recipient-expired.tsx` — embed expired component
### Modified Files
**Job system (cron infrastructure):**
- `packages/lib/jobs/client/_internal/job.ts` — add optional `cron` field to `trigger` type
- `packages/lib/jobs/client/local.ts` — add cron poller + deterministic `BackgroundJob.id` dedupe
- `packages/lib/jobs/client/inngest.ts` — wire up `{ cron: ... }` in `createFunction`
- `packages/lib/jobs/client/_internal/*` — add cron helper utilities (slot calc + run ID)
- `packages/lib/jobs/client.ts` — register new jobs
**Schema & data layer:**
- `packages/prisma/schema.prisma` — model changes + index
- `packages/lib/utils/document.ts``extractDerivedDocumentMeta` (add `envelopeExpirationPeriod`)
- `packages/lib/server-only/document/send-document.ts` — resolve settings + compute and set `recipient.expiresAt`
- `packages/lib/server-only/template/create-document-from-direct-template.ts` — no changes (sendDocument handles it)
- `packages/lib/server-only/document/resend-document.ts` — refresh `recipient.expiresAt` on redistribute
- `packages/lib/server-only/document/complete-document-with-token.ts` — recipient expiration guard
- `packages/lib/server-only/field/sign-field-with-token.ts` — recipient expiration guard
- `packages/lib/server-only/field/remove-signed-field-with-token.ts` — recipient expiration guard
- `packages/lib/server-only/document/reject-document-with-token.ts` — recipient expiration guard
**Error handling:**
- `packages/lib/errors/app-error.ts` — add `RECIPIENT_EXPIRED` error code
**Audit logs:**
- `packages/lib/types/document-audit-logs.ts` — add `DOCUMENT_RECIPIENT_EXPIRED` type with `recipientEmail`/`recipientName` data fields
- `packages/lib/utils/document-audit-logs.ts` — add human-readable rendering for `DOCUMENT_RECIPIENT_EXPIRED`
**Signing page:**
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` — derive `isExpired` from `recipient.expiresAt`
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx` — keep redirect to expired page using `isExpired`
**Embeds:**
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — check recipient expiration in V1/V2 loaders
- `apps/remix/app/routes/embed+/_v0+/_layout.tsx` — handle `embed-recipient-expired` in error boundary
**Webhook / API:**
- `packages/lib/types/recipient.ts` — add `expiresAt`/`expirationNotifiedAt` to recipient type
- `packages/lib/types/webhook-payload.ts` — add `expiresAt`/`expirationNotifiedAt` to webhook recipient
- `packages/lib/server-only/webhooks/trigger/generate-sample-data.ts` — update sample data
- `packages/lib/server-only/webhooks/zapier/list-documents.ts` — update zapier recipient shape
- `packages/api/v1/schema.ts` — add `expiresAt` to API recipient schema
**TRPC / settings:**
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts`
- `packages/trpc/server/team-router/update-team-settings.types.ts`
- `packages/lib/types/document-meta.ts`
**UI:**
- `apps/remix/app/components/forms/document-preferences-form.tsx` — add expiration period picker
- `packages/ui/primitives/document-flow/add-settings.tsx` — add expiration field
- `packages/ui/primitives/document-flow/add-settings.types.ts` — add to schema
@@ -0,0 +1,551 @@
---
date: 2026-02-19
title: Database Rate Limiting
---
## Summary
Replace the in-memory `hono-rate-limiter` with a database-backed rate limiting system using Prisma and PostgreSQL. The current in-memory approach is ineffective in multi-instance deployments since there are no sticky sessions. The new system uses **bucketed counters** (one row per key/action/time-bucket with atomic increment) to efficiently handle both high-throughput API rate limiting and granular auth/email rate limiting.
### Design Decisions
- **Bucketed counters** over row-per-request: high-throughput consumers would create thousands of rows per minute; bucketed counters reduce this to one row per key per time bucket
- **Fixed time windows**: simpler than sliding windows, the 2x burst-at-boundary scenario is acceptable for rate limiting purposes
- **Dual-key rate limiting**: per-identifier (`max`) and per-IP (`globalMax`) checked independently via separate rows with a `key` prefix (`id:` / `ip:`)
- **Accept slight over-count**: the upsert is atomic (increment + return count in one operation) but concurrent requests near the limit may both see a count just under the threshold before either commits, allowing a slight overshoot
- **Fail-open on errors**: if the rate limit DB query fails, allow the request through rather than blocking legitimate users
- **Prisma upsert** with `{ increment: 1 }` for atomic counter updates, returns the updated row so count check is a single operation
- **Application cron job** for cleanup of expired bucket rows
### Rate Limit Check Flow
```
check({ ip, identifier }) ->
1. Upsert IP row (ip:{ip} / action / bucket) with count + 1, RETURNING count
-> if globalMax is set and count >= globalMax, return { isLimited: true }
2. Upsert identifier row (id:{identifier} / action / bucket) with count + 1, RETURNING count
-> if count >= max, return { isLimited: true }
3. Neither limited -> return { isLimited: false }
```
Each upsert atomically increments and returns the new count in a single operation. Both counters always increment on every check — there's no conditional logic to skip one based on the other. This keeps the implementation simple and avoids read-then-write race conditions. If only IP is provided (API rate limiting), only step 1 runs.
---
## 1. Database Schema
### 1.1 Prisma model
Add to `packages/prisma/schema.prisma` after the `Counter` model:
```prisma
model RateLimit {
key String
action String
bucket DateTime
count Int @default(1)
createdAt DateTime @default(now())
@@id([key, action, bucket])
@@index([createdAt])
}
```
- **Composite primary key** `(key, action, bucket)` serves as both the unique constraint for upserts and the lookup index
- **`key`** is prefixed: `ip:1.2.3.4` or `id:user@example.com`
- **`action`** is the rate limit action name: `auth.forgot-password`, `api.v1`, etc.
- **`bucket`** is the start of the time window, truncated to the window size (e.g., `2026-02-19T10:05:00Z` for a 5-minute bucket)
- **`createdAt` index** is for the cleanup job to efficiently delete old rows
- **`count`** starts at 1 (set by the create side of the upsert)
### 1.2 Migration
Generate with `npx prisma migrate dev --name add-rate-limits`.
---
## 2. Rate Limit Library
### 2.1 Core module
Create `packages/lib/server-only/rate-limit/rate-limit.ts`:
```typescript
type WindowUnit = 's' | 'm' | 'h' | 'd';
type WindowStr = `${number}${WindowUnit}`;
type RateLimitConfig = {
action: string;
max: number;
globalMax?: number;
window: WindowStr;
};
type CheckParams = {
ip: string;
identifier?: string;
};
export const rateLimit = (config: RateLimitConfig) => {
return {
async check(params: CheckParams): Promise<{
isLimited: boolean;
remaining: number;
limit: number;
reset: Date;
}> { ... }
};
};
```
### 2.2 Window parsing and bucket computation
```typescript
const parseWindow = (window: WindowStr): number => {
const value = parseInt(window.slice(0, -1), 10);
const unit = window.slice(-1) as WindowUnit;
const multipliers: Record<WindowUnit, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * multipliers[unit];
};
const getBucket = (windowMs: number): Date => {
const now = Date.now();
return new Date(now - (now % windowMs));
};
```
### 2.3 Check implementation
The `check()` method:
1. Compute the current bucket from the window
2. Compute `reset` as `bucket + windowMs` (the start of the next window)
3. If `globalMax` is set, upsert the IP row and check count
4. If `identifier` is provided, upsert the identifier row and check count
5. Wrap in try/catch — **fail-open** on any database error (log the error, return `{ isLimited: false }`)
Each upsert uses Prisma's `upsert` with `{ increment: 1 }`:
```typescript
const result = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
},
},
create: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
if (config.globalMax && result.count >= config.globalMax) {
return { isLimited: true, remaining: 0, limit: config.globalMax };
}
```
### 2.4 Rate limit definitions
Create `packages/lib/server-only/rate-limit/rate-limits.ts` with all rate limit instances:
```typescript
// ---- Auth (Tier 1 - Critical, sends emails) ----
export const signupRateLimit = rateLimit({
action: 'auth.signup',
max: 5,
globalMax: 10,
window: '1h',
});
export const forgotPasswordRateLimit = rateLimit({
action: 'auth.forgot-password',
max: 3,
globalMax: 20,
window: '1h',
});
export const resendVerifyEmailRateLimit = rateLimit({
action: 'auth.resend-verify-email',
max: 3,
globalMax: 20,
window: '1h',
});
export const request2FAEmailRateLimit = rateLimit({
action: 'auth.request-2fa-email',
max: 5,
globalMax: 20,
window: '15m',
});
// ---- Auth (Tier 2 - Unauthenticated) ----
export const loginRateLimit = rateLimit({
action: 'auth.login',
max: 10,
globalMax: 50,
window: '15m',
});
export const resetPasswordRateLimit = rateLimit({
action: 'auth.reset-password',
max: 5,
globalMax: 20,
window: '1h',
});
export const verifyEmailRateLimit = rateLimit({
action: 'auth.verify-email',
max: 5,
globalMax: 20,
window: '15m',
});
export const passkeyRateLimit = rateLimit({
action: 'auth.passkey',
max: 10,
globalMax: 50,
window: '15m',
});
export const oauthRateLimit = rateLimit({
action: 'auth.oauth',
max: 10,
globalMax: 50,
window: '15m',
});
export const linkOrgAccountRateLimit = rateLimit({
action: 'auth.link-org-account',
max: 5,
globalMax: 20,
window: '1h',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = rateLimit({
action: 'api.v1',
max: 100,
window: '1m',
});
export const apiV2RateLimit = rateLimit({
action: 'api.v2',
max: 100,
window: '1m',
});
export const apiTrpcRateLimit = rateLimit({
action: 'api.trpc',
max: 100,
window: '1m',
});
export const aiRateLimit = rateLimit({
action: 'api.ai',
max: 3,
window: '1m',
});
export const fileUploadRateLimit = rateLimit({
action: 'api.file-upload',
max: 20,
window: '1m',
});
```
Exact limits are initial values — tune based on observed traffic patterns. These should be easy to adjust.
---
## 3. Integration Points
### 3.1 Hono middleware for API routes
Create a reusable Hono middleware factory in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` that wraps the `rateLimit` checker into Hono middleware:
```typescript
import { type MiddlewareHandler } from 'hono';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
export const createRateLimitMiddleware = (
limiter: ReturnType<typeof rateLimit>,
options?: { identifierFn?: (c: Context) => string | undefined },
): MiddlewareHandler => {
return async (c, next) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const identifier = options?.identifierFn?.(c);
const result = await limiter.check({ ip, identifier });
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
if (result.isLimited) {
c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
await next();
};
};
```
### 3.2 Replace existing Hono rate limiters
In `apps/remix/server/router.ts`:
- Remove `hono-rate-limiter` import and both `rateLimiter()` instances
- Replace with `createRateLimitMiddleware()` calls using the defined rate limits
- API routes use IP-only limiting (no identifier)
- AI route uses IP-only limiting with the stricter 3/min limit
```typescript
// Before
import { rateLimiter } from 'hono-rate-limiter';
const rateLimitMiddleware = rateLimiter({ ... });
// After
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { apiV1RateLimit, apiV2RateLimit, aiRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit);
const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit);
const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit);
```
### 3.3 Response helpers for inline checks
For auth routes (Hono handlers) and tRPC routes where rate limiting is applied inline rather than via middleware, provide helpers that handle the response formatting and headers consistently.
**Hono helper** — returns a 429 `Response` with headers if limited, or `null` if allowed:
```typescript
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
if (result.isLimited) {
c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
return null;
};
```
Usage in auth routes:
```typescript
const result = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: input.email,
});
const limited = rateLimitResponse(c, result);
if (limited) return limited;
```
**tRPC helper** — throws a `TRPCError` with rate limit headers if limited:
```typescript
export const assertRateLimit = (result: RateLimitCheckResult): void => {
if (result.isLimited) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
});
}
};
```
Usage in tRPC routes:
```typescript
const result = await request2FAEmailRateLimit.check({
ip: ctx.requestMetadata.ipAddress ?? 'unknown',
identifier: input.recipientId,
});
assertRateLimit(result);
```
Both helpers live in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` alongside the Hono middleware.
### 3.4 Auth endpoint rate limiting
In `packages/auth/server/routes/email-password.ts`, add rate limit checks at the start of each handler using the `rateLimitResponse` helper.
Apply to each endpoint per the tier list:
| Endpoint | Rate Limit |
| --------------------------- | ----------------------------------------------------- |
| `POST /signup` | `signupRateLimit` with `identifier: email` |
| `POST /authorize` (login) | `loginRateLimit` with `identifier: email` |
| `POST /forgot-password` | `forgotPasswordRateLimit` with `identifier: email` |
| `POST /resend-verify-email` | `resendVerifyEmailRateLimit` with `identifier: email` |
| `POST /verify-email` | `verifyEmailRateLimit` with `identifier: token` |
| `POST /reset-password` | `resetPasswordRateLimit` with `identifier: token` |
| `POST /passkey/authorize` | `passkeyRateLimit` (IP only, no identifier) |
| `POST /oauth/authorize/*` | `oauthRateLimit` (IP only) |
### 3.4 tRPC unauthenticated route rate limiting
For unauthenticated tRPC routes that send emails, add rate limit checks at the start of the route handler:
| Route | Rate Limit | Identifier |
| ---------------------------------------------------------- | ------------------------------------ | ---------------------- |
| `document.accessAuth.request2FAEmail` | `request2FAEmailRateLimit` | `recipientId` or token |
| `enterprise.organisation.authenticationPortal.linkAccount` | `linkOrgAccountRateLimit` | email |
| `template.createDocumentFromDirectTemplate` | Dedicated direct template rate limit | IP only |
Access `requestMetadata` from the tRPC context (`ctx.requestMetadata.ipAddress`).
### 3.5 tRPC and file routes — general API rate limiting
Add rate limit middleware for currently unprotected routes:
- `/api/trpc/*` — apply `apiTrpcRateLimit` middleware
- `/api/files/*` — apply `fileUploadRateLimit` middleware
---
## 4. Cleanup Job
### 4.1 Job definition
Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts`:
```typescript
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
id: 'internal.cleanup-rate-limits',
name: 'Cleanup Rate Limits',
version: '1.0.0',
trigger: {
name: 'internal.cleanup-rate-limits',
schema: z.object({}),
cron: '*/15 * * * *', // Every 15 minutes
},
handler: async ({ payload, io }) => {
const handler = await import('./cleanup-rate-limits.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<...>;
```
### 4.2 Job handler
Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts`:
- Delete all `RateLimit` rows where `createdAt` is older than 24 hours (covers all possible windows with margin)
- Use batched deletes to avoid long-running transactions
- Batch in chunks of 10,000 rows
```typescript
export const run = async () => {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
let deleted = 0;
do {
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
deleted = await prisma.$executeRaw`
DELETE FROM "RateLimit"
WHERE "createdAt" < ${cutoff}
AND ctid IN (
SELECT ctid FROM "RateLimit"
WHERE "createdAt" < ${cutoff}
LIMIT 10000
)
`;
} while (deleted > 0);
};
```
### 4.3 Register in job client
Add `CLEANUP_RATE_LIMITS_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`.
---
## 5. Remove hono-rate-limiter Dependency
After the migration is complete:
- Remove `hono-rate-limiter` from `apps/remix/package.json`
- Run `npm install` to clean up
---
## 6. Files to Create or Modify
### New Files
| File | Purpose |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `packages/lib/server-only/rate-limit/rate-limit.ts` | Core rate limit factory (`rateLimit()`) with window parsing, bucket computation, Prisma upsert, fail-open |
| `packages/lib/server-only/rate-limit/rate-limits.ts` | All rate limit instances (auth, API, AI, file upload) |
| `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` | Hono middleware factory, `rateLimitResponse` helper for Hono handlers, `assertRateLimit` helper for tRPC routes |
| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts` | Cleanup cron job definition |
| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts` | Cleanup handler (batched deletes) |
### Modified Files
| File | Change |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `packages/prisma/schema.prisma` | Add `RateLimit` model |
| `apps/remix/server/router.ts` | Replace `hono-rate-limiter` with DB-backed middleware, add rate limits for `/api/trpc/*` and `/api/files/*` |
| `apps/remix/package.json` | Remove `hono-rate-limiter` dependency |
| `packages/auth/server/routes/email-password.ts` | Add rate limit checks to signup, login, forgot-password, resend-verify-email, verify-email, reset-password |
| `packages/auth/server/routes/passkey.ts` | Add rate limit check to passkey authorize |
| `packages/auth/server/routes/oauth.ts` | Add rate limit check to OAuth authorize endpoints |
| `packages/trpc/server/document-router/access-auth-request-2fa-email.ts` | Add rate limit check (sends email, unauthenticated) |
| `packages/trpc/server/enterprise-router/link-organisation-account.ts` | Add rate limit check (sends email, unauthenticated) |
| `packages/lib/jobs/client.ts` | Register cleanup-rate-limits job definition |
---
## 7. Considerations
### 7.1 Fail-open
All rate limit checks must be wrapped in try/catch. On any DB error, log the error and allow the request through. Rate limiting should never block legitimate traffic due to infrastructure issues.
### 7.2 Performance
- Each API request adds 1 upsert query (~1ms)
- Auth requests add 2 upsert queries (~2ms total)
- The composite primary key ensures all lookups and upserts are index-only operations
- No `COUNT(*)` queries — the count is stored directly in the row
### 7.3 Monitoring
Log rate limit hits at `warn` level with the action, key type (IP/identifier), and count. This provides visibility into traffic patterns and helps tune limits.
### 7.4 Testing
The rate limit module should be mockable in tests. Consider exporting the bucket computation and window parsing as standalone functions for unit testing. Integration tests can verify the upsert + count logic against a test database.
### 7.5 Future improvements
- **Redis backend**: if DB pressure from rate limiting becomes measurable, swap the Prisma upsert for Redis `INCR` + `EXPIRE` with no API changes
- **System-wide circuit breaker**: add a `systemMax` config option that counts all requests for an action regardless of key
+2
View File
@@ -175,6 +175,8 @@ GOOGLE_VERTEX_API_KEY=""
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# OPTIONAL: Set to "true" to disable all rate limiting. Only use for E2E tests.
DANGEROUS_BYPASS_RATE_LIMITS=
# [[LOGGER]]
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
+1
View File
@@ -41,6 +41,7 @@ jobs:
env:
# Needed since we use next start which will set the NODE_ENV to production
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: './example/cert.p12'
DANGEROUS_BYPASS_RATE_LIMITS: 'true'
- uses: actions/upload-artifact@v4
if: always()
@@ -115,7 +115,9 @@ export const ConfigureFieldsView = ({
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
expired: null, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
export const EmbedRecipientExpired = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent && !hasPostedMessage) {
window.parent.postMessage(
{
action: 'recipient-expired',
data: null,
},
'*',
);
}
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
return (
<div className="embed--RecipientExpired relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-center text-2xl font-bold text-foreground">
<Trans>Signing Window Expired</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-sm text-muted-foreground">
<Trans>
Your signing window for this document has expired. Please contact the sender for a new
invitation.
</Trans>
</p>
<p className="mt-4 text-sm text-muted-foreground">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};
@@ -11,6 +11,10 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -27,6 +31,7 @@ import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -70,6 +75,7 @@ export type TDocumentPreferencesFormSchema = {
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
};
type SettingsSubset = Pick<
@@ -87,6 +93,7 @@ type SettingsSubset = Pick<
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
>;
export type DocumentPreferencesFormProps = {
@@ -126,6 +133,7 @@ export const DocumentPreferencesForm = ({
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -146,6 +154,7 @@ export const DocumentPreferencesForm = ({
: null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -669,6 +678,35 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="envelopeExpirationPeriod"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Envelope Expiration</Trans>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls how long recipients have to complete signing before the document
expires. After expiration, recipients can no longer sign the document.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
@@ -9,19 +9,21 @@ import {
AlertTriangle,
CheckIcon,
Clock,
Clock8Icon,
MailIcon,
MailOpenIcon,
PenIcon,
PlusIcon,
UserIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { formatSigningLink, isRecipientExpired } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -44,7 +46,7 @@ export const DocumentPageViewRecipients = ({
envelope,
documentRootPath,
}: DocumentPageViewRecipientsProps) => {
const { _ } = useLingui();
const { _, i18n } = useLingui();
const { toast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
@@ -66,9 +68,9 @@ export const DocumentPageViewRecipients = ({
}, [searchParams, setSearchParams]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recipients</Trans>
</h1>
@@ -87,7 +89,7 @@ export const DocumentPageViewRecipients = ({
)}
</div>
<ul className="text-muted-foreground divide-y border-t">
<ul className="divide-y border-t text-muted-foreground">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
@@ -98,9 +100,9 @@ export const DocumentPageViewRecipients = ({
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
@@ -154,12 +156,41 @@ export const DocumentPageViewRecipients = ({
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
isRecipientExpired(recipient) && (
<Badge variant="destructive">
<Clock8Icon className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
!isRecipientExpired(recipient) &&
(recipient.expiresAt ? (
<PopoverHover
trigger={
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
}
>
<p className="text-xs text-muted-foreground">
<Trans>
Expires{' '}
{recipient.expiresAt
? i18n.date(recipient.expiresAt, DateTime.DATETIME_MED)
: 'N/A'}
</Trans>
</p>
</PopoverHover>
) : (
<Badge variant="secondary">
<Clock className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</Badge>
)}
))}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && (
@@ -175,7 +206,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Reason for rejection: </Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
{recipient.rejectionReason}
</p>
</PopoverHover>
@@ -183,7 +214,8 @@ export const DocumentPageViewRecipients = ({
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
recipient.role !== RecipientRole.CC &&
!isRecipientExpired(recipient) && (
<TooltipProvider>
<Tooltip open={shouldHighlightCopyButtons && i === 0}>
<TooltipTrigger asChild>
@@ -22,6 +22,7 @@ import {
DOCUMENT_DISTRIBUTION_METHODS,
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -62,6 +63,7 @@ import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
@@ -135,6 +137,7 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
}),
});
@@ -207,6 +210,7 @@ export const EnvelopeEditorSettingsDialog = ({
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
},
};
};
@@ -245,6 +249,7 @@ export const EnvelopeEditorSettingsDialog = ({
message,
subject,
emailReplyTo,
envelopeExpirationPeriod,
} = data.meta;
const parsedGlobalAccessAuth = z
@@ -273,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
},
});
@@ -373,7 +379,7 @@ export const EnvelopeEditorSettingsDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 pt-6"
className="flex h-[45rem] max-h-[calc(100vh-14rem)] w-full flex-col space-y-6 overflow-y-auto px-6 py-6"
disabled={form.formState.isSubmitting}
key={activeTab}
>
@@ -636,6 +642,40 @@ export const EnvelopeEditorSettingsDialog = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
))
.with('email', () => (
@@ -60,6 +60,7 @@ export default function OrganisationSettingsDocumentPage() {
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
} = data;
if (
@@ -90,6 +91,7 @@ export default function OrganisationSettingsDocumentPage() {
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
},
});
@@ -53,6 +53,7 @@ export default function TeamsSettingsPage() {
defaultRecipients,
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
} = data;
await updateTeamSettings({
@@ -67,6 +68,7 @@ export default function TeamsSettingsPage() {
includeAuditLog,
defaultRecipients,
aiFeaturesEnabled,
envelopeExpirationPeriod,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -25,6 +25,7 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -140,6 +141,10 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
throw redirect(`/sign/${token}/rejected`);
}
if (isRecipientExpired(recipient)) {
throw redirect(`/sign/${token}/expired`);
}
if (
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
@@ -201,7 +206,8 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
return envelopeForSigning;
}
const { envelope, recipient, isCompleted, isRejected, isRecipientsTurn } = envelopeForSigning;
const { envelope, recipient, isCompleted, isRejected, isExpired, isRecipientsTurn } =
envelopeForSigning;
if (!isRecipientsTurn) {
throw redirect(`/sign/${token}/waiting`);
@@ -233,12 +239,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
if (isRejected) {
throw redirect(`/sign/${token}/rejected`);
}
@@ -247,6 +247,16 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
throw redirect(envelope.documentMeta.redirectUrl || `/sign/${token}/complete`);
}
if (isExpired) {
throw redirect(`/sign/${token}/expired`);
}
await viewedDocument({
token,
requestMetadata,
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
return {
isDocumentAccessValid: true,
envelopeForSigning,
@@ -0,0 +1,114 @@
import { Trans } from '@lingui/react/macro';
import { TimerOffIcon } from 'lucide-react';
import { Link } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/expired';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
throw new Response('Not Found', { status: 404 });
}
const title = document.title;
const recipient = await getRecipientByToken({ token }).catch(() => null);
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
const recipientEmail = recipient.email;
if (isDocumentAccessValid) {
return {
isDocumentAccessValid: true,
recipientEmail,
title,
};
}
return {
isDocumentAccessValid: false,
recipientEmail,
};
}
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientEmail} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge
variant="neutral"
size="default"
title={title}
className="mb-6 rounded-xl border bg-transparent"
>
{truncateTitle(title ?? '')}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<TimerOffIcon className="h-10 w-10 text-orange-500" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Deadline Expired</Trans>
</h2>
</div>
<p className="mt-6 max-w-[60ch] text-center text-sm text-muted-foreground">
<Trans>
The signing deadline for this document has passed. Please contact the document owner if
you need a new copy to sign.
</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
</div>
);
}
@@ -13,6 +13,7 @@ import { EmbedDocumentCompleted } from '~/components/embed/embed-document-comple
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
import { EmbedPaywall } from '~/components/embed/embed-paywall';
import { EmbedRecipientExpired } from '~/components/embed/embed-recipient-expired';
import type { Route } from './+types/_layout';
@@ -79,6 +80,10 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
return <EmbedDocumentWaitingForTurn />;
}
if (error.status === 403 && error.data.type === 'embed-recipient-expired') {
return <EmbedRecipientExpired />;
}
// !: Not used at the moment, may be removed in the future.
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
return <EmbedDocumentRejected />;
@@ -19,6 +19,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
@@ -78,6 +79,17 @@ async function handleV1Loader({ params, request }: Route.LoaderArgs) {
);
}
if (isRecipientExpired(recipient)) {
throw data(
{
type: 'embed-recipient-expired',
},
{
status: 403,
},
);
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@@ -190,7 +202,7 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
);
}
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
const { envelope, recipient, isRecipientsTurn, isExpired } = envelopeForSigning;
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
@@ -208,6 +220,17 @@ async function handleV2Loader({ params, request }: Route.LoaderArgs) {
);
}
if (isExpired) {
throw data(
{
type: 'embed-recipient-expired',
},
{
status: 403,
},
);
}
if (!isRecipientsTurn) {
throw data(
{
-1
View File
@@ -46,7 +46,6 @@
"content-disposition": "^1.0.1",
"framer-motion": "^12.23.24",
"hono": "4.11.4",
"hono-rate-limiter": "^0.4.2",
"hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2",
"isbot": "^5.1.32",
+36 -47
View File
@@ -1,20 +1,25 @@
import { Hono } from 'hono';
import { rateLimiter } from 'hono-rate-limiter';
import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id';
import type { RequestIdVariables } from 'hono/request-id';
import { requestId } from 'hono/request-id';
import type { Logger } from 'pino';
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
aiRateLimit,
apiTrpcRateLimit,
apiV1RateLimit,
apiV2RateLimit,
fileUploadRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { migrateDeletedAccountServiceAccount } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { migrateLegacyServiceAccount } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { env } from '@documenso/lib/utils/env';
import { logger } from '@documenso/lib/utils/logger';
import { openApiDocument } from '@documenso/trpc/server/open-api';
@@ -37,38 +42,13 @@ export interface HonoEnv {
const app = new Hono<HonoEnv>();
/**
* Rate limiting for v1 and v2 API routes only.
* - 100 requests per minute per IP address
* Database-backed rate limiting for API routes.
*/
const rateLimitMiddleware = rateLimiter({
windowMs: 60 * 1000, // 1 minute
limit: 100, // 100 requests per window
keyGenerator: (c) => {
try {
return getIpAddress(c.req.raw);
} catch (error) {
return 'unknown';
}
},
message: {
error: 'Too many requests, please try again later.',
},
});
const aiRateLimitMiddleware = rateLimiter({
windowMs: 60 * 1000, // 1 minute
limit: 3, // 3 requests per window
keyGenerator: (c) => {
try {
return getIpAddress(c.req.raw);
} catch (error) {
return 'unknown';
}
},
message: {
error: 'Too many requests, please try again later.',
},
});
const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit);
const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit);
const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit);
const trpcRateLimitMiddleware = createRateLimitMiddleware(apiTrpcRateLimit);
const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit);
/**
* Attach session and context to requests.
@@ -86,6 +66,7 @@ app.use(async (c, next) => {
const honoLogger = logger.child({
requestId: c.var.requestId,
requestPath: c.req.path,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
});
@@ -95,14 +76,20 @@ app.use(async (c, next) => {
await next();
});
// Apply rate limit to /api/v1/*
app.use('/api/v1/*', rateLimitMiddleware);
app.use('/api/v2/*', rateLimitMiddleware);
// Apply cors and rate limits to API routes.
app.use(`/api/v1/*`, cors());
app.use('/api/v1/*', apiV1RateLimitMiddleware);
app.use(`/api/v2/*`, cors());
app.use('/api/v2/*', apiV2RateLimitMiddleware);
app.use(`/api/v2-beta/*`, cors());
app.use('/api/v2-beta/*', apiV2RateLimitMiddleware);
// Auth server.
app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
@@ -110,28 +97,26 @@ app.use('/api/ai/*', aiRateLimitMiddleware);
app.route('/api/ai', aiRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', trpcRateLimitMiddleware);
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_URL}/*`, cors());
app.get(`/api/v2/openapi.json`, (c) => c.json(openApiDocument));
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
app.route(`${API_V2_URL}`, downloadRoute);
app.use(`${API_V2_URL}/*`, async (c) =>
app.route(`/api/v2`, downloadRoute);
app.use(`/api/v2/*`, async (c) =>
openApiTrpcServerHandler(c, {
isBeta: false,
}),
);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, cors());
app.get(`/api/v2-beta/openapi.json`, (c) => c.json(openApiDocument));
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
app.route(`${API_V2_BETA_URL}`, downloadRoute);
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
app.route(`/api/v2-beta`, downloadRoute);
app.use(`/api/v2-beta/*`, async (c) =>
openApiTrpcServerHandler(c, {
isBeta: true,
}),
@@ -146,6 +131,10 @@ if (env('NODE_ENV') !== 'development') {
// Start license client to verify license on startup.
void LicenseClient.start();
// Start cron scheduler for background jobs (e.g. envelope expiration sweep).
// No-op for Inngest provider which handles cron externally.
jobsClient.startCron();
void migrateDeletedAccountServiceAccount();
void migrateLegacyServiceAccount();
+13 -10
View File
@@ -19,6 +19,7 @@
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
@@ -142,7 +143,6 @@
"content-disposition": "^1.0.1",
"framer-motion": "^12.23.24",
"hono": "4.11.4",
"hono-rate-limiter": "^0.4.2",
"hono-react-router-adapter": "^0.6.5",
"input-otp": "^1.4.2",
"isbot": "^5.1.32",
@@ -20192,6 +20192,18 @@
"typescript": ">=5"
}
},
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
"license": "MIT",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -25081,15 +25093,6 @@
"node": ">=16.9.0"
}
},
"node_modules/hono-rate-limiter": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/hono-rate-limiter/-/hono-rate-limiter-0.4.2.tgz",
"integrity": "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw==",
"license": "MIT",
"peerDependencies": {
"hono": "^4.1.1"
}
},
"node_modules/hono-react-router-adapter": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/hono-react-router-adapter/-/hono-react-router-adapter-0.6.5.tgz",
+1
View File
@@ -90,6 +90,7 @@
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"ai": "^5.0.104",
"cron-parser": "^5.5.0",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
+4 -3
View File
@@ -437,8 +437,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().nullish(),
token: z.string(),
// !: Not used for now
// expired: z.string(),
expiresAt: z.date().nullish(),
expirationNotifiedAt: z.date().nullish(),
signedAt: z.date().nullable(),
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
@@ -576,7 +576,8 @@ export const ZRecipientSchema = z.object({
token: z.string(),
signingOrder: z.number().nullish(),
documentDeletedAt: z.date().nullish(),
expired: z.date().nullish(),
expiresAt: z.date().nullish(),
expirationNotifiedAt: z.date().nullish(),
signedAt: z.date().nullish(),
authOptions: z.unknown(),
role: z.nativeEnum(RecipientRole),
@@ -171,6 +171,7 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: { type: 'signature' },
},
{
type: FieldType.SIGNATURE,
@@ -180,6 +181,7 @@ test.describe('API V2 Envelopes', () => {
positionY: 0,
width: 0,
height: 0,
fieldMeta: { type: 'signature' },
},
],
},
@@ -205,6 +207,7 @@ test.describe('API V2 Envelopes', () => {
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
},
},
@@ -0,0 +1,291 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: sending document sets expiresAt on recipients', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-expiration-send',
expiresIn: null,
});
const createPayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: '[TEST] Expiration Send Test',
recipients: [
{
email: 'signer-expiry@test.documenso.com',
name: 'Signer Expiry',
role: RecipientRole.SIGNER,
fields: [
{
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature' },
},
],
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createPayload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
// Check that recipients now have expiresAt set.
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
expect(recipients.length).toBe(1);
expect(recipients[0].expiresAt).not.toBeNull();
// The default expiration period is 3 months. Verify it's roughly correct.
const expiresAt = recipients[0].expiresAt!;
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
// 3 months is roughly 89-92 days. Allow a generous range.
expect(diffDays).toBeGreaterThan(80);
expect(diffDays).toBeLessThan(100);
});
test('[ENVELOPE_EXPIRATION]: sending document with custom org expiration period', async ({
request,
}) => {
const { user, organisation, team } = await seedUser();
// Set org expiration to 7 days.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { unit: 'day', amount: 7 } },
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-expiration-custom',
expiresIn: null,
});
const createPayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: '[TEST] Custom Expiration Send Test',
recipients: [
{
email: 'signer-custom@test.documenso.com',
name: 'Signer Custom',
role: RecipientRole.SIGNER,
fields: [
{
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature' },
},
],
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createPayload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
expect(recipients.length).toBe(1);
expect(recipients[0].expiresAt).not.toBeNull();
// 7 days expiration.
const expiresAt = recipients[0].expiresAt!;
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
expect(diffDays).toBeGreaterThan(6);
expect(diffDays).toBeLessThan(8);
});
test('[ENVELOPE_EXPIRATION]: sending document with expiration disabled', async ({ request }) => {
const { user, organisation, team } = await seedUser();
// Disable expiration at org level.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { disabled: true } },
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-expiration-disabled',
expiresIn: null,
});
const createPayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: '[TEST] Disabled Expiration Send Test',
recipients: [
{
email: 'signer-disabled@test.documenso.com',
name: 'Signer Disabled',
role: RecipientRole.SIGNER,
fields: [
{
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10,
width: 10,
height: 5,
fieldMeta: { type: 'signature' },
},
],
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createPayload));
formData.append('files', new File([examplePdf], 'example.pdf', { type: 'application/pdf' }));
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const { id: envelopeId }: TCreateEnvelopeResponse = await createRes.json();
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: { envelopeId } satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
});
expect(recipients.length).toBe(1);
expect(recipients[0].expiresAt).toBeNull();
});
test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) => {
const { user, team } = await seedUser();
const document = await seedPendingDocument(user, team.id, ['resend-target@test.documenso.com']);
const recipient = document.recipients[0];
// Set an initial expiresAt that's 1 day from now.
const initialExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: initialExpiresAt },
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
// Open the document action menu and click Resend.
const actionBtn = page.getByTestId('document-table-action-btn').first();
await expect(actionBtn).toBeAttached();
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Resend' }).click();
// Select the recipient and send.
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
timeout: 10_000,
});
// Verify expiresAt was refreshed.
await expect(async () => {
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.expiresAt).not.toBeNull();
expect(updatedRecipient.expiresAt!.getTime()).toBeGreaterThan(initialExpiresAt.getTime());
}).toPass({ timeout: 10_000 });
});
@@ -0,0 +1,150 @@
import { expect, test } from '@playwright/test';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level', async ({
page,
}) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Wait for the form to load.
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2.
const amountInput = page.getByRole('spinbutton');
await amountInput.clear();
await amountInput.fill('2');
// Find all triggers, the unit picker is the one showing Months/Days/etc.
// 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/ });
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(orgSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
});
test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/document`,
});
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' });
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
const orgSettings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(orgSettings.envelopeExpirationPeriod).toEqual({ disabled: true });
});
test('[ENVELOPE_EXPIRATION]: team inherits expiration from organisation', async () => {
const { organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
// Set org expiration to 2 weeks directly.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
});
// Verify team settings inherit the org setting.
const teamSettings = await getTeamSettings({ teamId: team.id });
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'week', amount: 2 });
});
test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
// Set org expiration to 2 weeks.
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { envelopeExpirationPeriod: { unit: 'week', amount: 2 } },
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/document`,
});
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();
await expect(modeTrigger).toBeVisible();
// Switch to custom duration.
await modeTrigger.click();
await page.getByRole('option', { name: 'Custom duration' }).click();
// Set to 5 days.
const amountInput = expirationSection.getByRole('spinbutton');
await amountInput.clear();
await amountInput.fill('5');
const unitTrigger = expirationSection
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ });
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify team setting is overridden.
const teamSettings = await getTeamSettings({ teamId: team.id });
expect(teamSettings.envelopeExpirationPeriod).toEqual({ unit: 'day', amount: 5 });
});
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test.describe.configure({ mode: 'parallel' });
test('[ENVELOPE_EXPIRATION]: expired recipient is redirected to expired page', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['expired-recipient@test.documenso.com'],
teamId: team.id,
});
const recipient = recipients[0];
// Set expiresAt to the past so the recipient is expired.
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() - 60_000) },
});
await page.goto(`/sign/${recipient.token}`);
await page.waitForURL(`/sign/${recipient.token}/expired`);
await expect(page.getByText('Signing Deadline Expired')).toBeVisible();
await expect(page.getByText('The signing deadline for this document has passed')).toBeVisible();
});
test('[ENVELOPE_EXPIRATION]: non-expired recipient can access signing page', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['active-recipient@test.documenso.com'],
teamId: team.id,
});
const recipient = recipients[0];
// Set expiresAt to 1 hour in the future.
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
});
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
test('[ENVELOPE_EXPIRATION]: recipient with null expiresAt can sign normally', async ({ page }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['null-expiry@test.documenso.com'],
teamId: team.id,
});
const recipient = recipients[0];
// Verify expiresAt is null (default from seed).
const dbRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(dbRecipient.expiresAt).toBeNull();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
test('[ENVELOPE_EXPIRATION]: expired recipient cannot complete signing', async ({ page }) => {
const { user, team } = await seedUser();
// Use only a SIGNATURE field to simplify the signing flow.
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [user],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
const recipient = recipients[0];
await apiSignin({
page,
email: user.email,
redirectPath: `/sign/${recipient.token}`,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Now expire the recipient while they're on the signing page.
await prisma.recipient.update({
where: { id: recipient.id },
data: { expiresAt: new Date(Date.now() - 1_000) },
});
// Set up signature.
await signSignaturePad(page);
// Click the signature field to attempt to insert it.
// The server will reject because the recipient is now expired.
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
if (signatureField) {
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
}
// The server should reject the signing attempt because the recipient has expired.
// Verify the field was NOT inserted (stays data-inserted="false").
if (signatureField) {
await expect(async () => {
const field = await prisma.field.findUniqueOrThrow({
where: { id: signatureField.id },
});
expect(field.inserted).toBe(false);
}).toPass({ timeout: 10_000 });
}
});
@@ -229,6 +229,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: false, // unchecked
documentCompleted: true,
documentDeleted: false, // unchecked
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
});
@@ -244,9 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page
.getByRole('checkbox', { name: 'Email recipients with a signing request' })
.uncheck();
await page.getByRole('checkbox', { name: 'Email recipients with a signing request' }).uncheck();
await page
.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true })
.uncheck();
@@ -270,6 +269,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerRecipientExpired: true,
ownerDocumentCompleted: false,
});
@@ -290,6 +290,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerRecipientExpired: true,
ownerDocumentCompleted: false,
});
@@ -315,6 +316,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
});
@@ -335,6 +337,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerRecipientExpired: true,
ownerDocumentCompleted: true,
});
});
+100 -3
View File
@@ -2,6 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { DateTime } from 'luxon';
import { z } from 'zod';
@@ -14,6 +15,15 @@ import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import {
forgotPasswordRateLimit,
loginRateLimit,
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
@@ -51,6 +61,19 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const loginLimitResult = await loginRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const loginLimited = rateLimitResponse(c, loginLimitResult);
if (loginLimited) {
throw new HTTPException(429, {
res: loginLimited,
});
}
const csrfCookieToken = await getCsrfCookie(c);
// Todo: (RR7) Add logging here.
@@ -152,6 +175,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Signup endpoint.
*/
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
@@ -160,6 +185,18 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { name, email, password, signature } = c.req.valid('json');
const signupLimitResult = await signupRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const signupLimited = rateLimitResponse(c, signupLimitResult);
if (signupLimited) {
throw new HTTPException(429, {
res: signupLimited,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
@@ -219,7 +256,24 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Verify email endpoint.
*/
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
const { state, userId } = await verifyEmail({ token: c.req.valid('json').token });
const requestMetadata = c.get('requestMetadata');
const { token } = c.req.valid('json');
const verifyLimitResult = await verifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const verifyLimited = rateLimitResponse(c, verifyLimitResult);
if (verifyLimited) {
throw new HTTPException(429, {
res: verifyLimited,
});
}
const { state, userId } = await verifyEmail({ token });
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
@@ -234,8 +288,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Resend verification email endpoint.
*/
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const resendLimitResult = await resendVerifyEmailRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const resendLimited = rateLimitResponse(c, resendLimitResult);
if (resendLimited) {
throw new HTTPException(429, {
res: resendLimited,
});
}
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
@@ -249,8 +318,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Forgot password endpoint.
*/
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email } = c.req.valid('json');
const forgotLimitResult = await forgotPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: email,
});
const forgotLimited = rateLimitResponse(c, forgotLimitResult);
if (forgotLimited) {
throw new HTTPException(429, {
res: forgotLimited,
});
}
if (
email.toLowerCase() === legacyServiceAccountEmail() ||
email.toLowerCase() === deletedServiceAccountEmail()
@@ -268,8 +352,23 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Reset password endpoint.
*/
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { token, password } = c.req.valid('json');
const resetLimitResult = await resetPasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
const resetLimited = rateLimitResponse(c, resetLimitResult);
if (resetLimited) {
throw new HTTPException(429, {
res: resetLimited,
});
}
const user = await getUserByResetToken({ token });
if (
@@ -279,8 +378,6 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
return c.text('FORBIDDEN', 403);
}
const requestMetadata = c.get('requestMetadata');
const { userId } = await resetPassword({
token,
password,
+15
View File
@@ -3,8 +3,11 @@ import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rateLimitResponse } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { passkeyRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
@@ -23,6 +26,18 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const passkeyLimitResult = await passkeyRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
});
const passkeyLimited = rateLimitResponse(c, passkeyLimitResult);
if (passkeyLimited) {
throw new HTTPException(429, {
res: passkeyLimited,
});
}
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
@@ -0,0 +1,54 @@
import { Trans } from '@lingui/react/macro';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateRecipientExpiredProps = {
documentName: string;
recipientName: string;
recipientEmail: string;
documentLink: string;
assetBaseUrl: string;
};
export const TemplateRecipientExpired = ({
documentName,
recipientName,
recipientEmail,
documentLink,
assetBaseUrl,
}: TemplateRecipientExpiredProps) => {
const displayName = recipientName || recipientEmail;
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold text-primary">
<Trans>
Signing window expired for "{displayName}" on "{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>
The signing window for {displayName} on document "{documentName}" has expired. You can
resend the document to extend their deadline or cancel the document.
</Trans>
</Text>
<Section className="my-4 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center text-sm font-medium text-white no-underline"
href={documentLink}
>
<Trans>View Document</Trans>
</Button>
</Section>
</Section>
</>
);
};
export default TemplateRecipientExpired;
@@ -0,0 +1,68 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateRecipientExpiredProps } from '../template-components/template-recipient-expired';
import { TemplateRecipientExpired } from '../template-components/template-recipient-expired';
export type RecipientExpiredEmailTemplateProps = Partial<TemplateRecipientExpiredProps>;
export const RecipientExpiredTemplate = ({
documentName = 'Open Source Pledge.pdf',
recipientName = 'John Doe',
recipientEmail = 'john@example.com',
documentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
}: RecipientExpiredEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`The signing window for "${recipientName}" on document "${documentName}" has expired.`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateRecipientExpired
documentName={documentName}
recipientName={recipientName}
recipientEmail={recipientEmail}
documentLink={documentLink}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Html>
);
};
export default RecipientExpiredTemplate;
@@ -0,0 +1,66 @@
import type { DurationLikeObject } from 'luxon';
import { Duration } from 'luxon';
import { z } from 'zod';
export const ZEnvelopeExpirationDurationPeriod = z.object({
unit: z.enum(['day', 'week', 'month', 'year']),
amount: z.number().int().min(1),
});
export const ZEnvelopeExpirationDisabledPeriod = z.object({
disabled: z.literal(true),
});
export const ZEnvelopeExpirationPeriod = z.union([
ZEnvelopeExpirationDurationPeriod,
ZEnvelopeExpirationDisabledPeriod,
]);
export type TEnvelopeExpirationPeriod = z.infer<typeof ZEnvelopeExpirationPeriod>;
export type TEnvelopeExpirationDurationPeriod = z.infer<typeof ZEnvelopeExpirationDurationPeriod>;
const UNIT_TO_LUXON_KEY: Record<
TEnvelopeExpirationDurationPeriod['unit'],
keyof DurationLikeObject
> = {
day: 'days',
week: 'weeks',
month: 'months',
year: 'years',
};
export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationDurationPeriod = {
unit: 'month',
amount: 3,
};
export const getEnvelopeExpirationDuration = (
period: TEnvelopeExpirationDurationPeriod,
): Duration => {
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
};
/**
* Resolve the concrete expiresAt timestamp from a raw expiration period (from JSON column).
*
* - `null` means use the default period (3 months).
* - `{ disabled: true }` means never expires (returns null).
* - `{ unit, amount }` means compute the timestamp from now + duration.
*/
export const resolveExpiresAt = (rawPeriod: unknown): Date | null => {
if (rawPeriod === null || rawPeriod === undefined) {
const duration = getEnvelopeExpirationDuration(DEFAULT_ENVELOPE_EXPIRATION_PERIOD);
return new Date(Date.now() + duration.toMillis());
}
const parsed = ZEnvelopeExpirationPeriod.parse(rawPeriod);
if ('disabled' in parsed) {
return null;
}
const duration = getEnvelopeExpirationDuration(parsed);
return new Date(Date.now() + duration.toMillis());
};
+10
View File
@@ -9,6 +9,7 @@ export enum AppErrorCode {
'EXPIRED_CODE' = 'EXPIRED_CODE',
'INVALID_BODY' = 'INVALID_BODY',
'INVALID_REQUEST' = 'INVALID_REQUEST',
'RECIPIENT_EXPIRED' = 'RECIPIENT_EXPIRED',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
@@ -23,6 +24,7 @@ export enum AppErrorCode {
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
{
[AppErrorCode.ALREADY_EXISTS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_EXPIRED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.EXPIRED_CODE]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_BODY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
@@ -62,6 +64,11 @@ type AppErrorOptions = {
* Mainly used for API -> Frontend communication and logging filtering.
*/
statusCode?: number;
/**
* Optional headers to include when this error is returned in an API response.
*/
headers?: Record<string, string>;
};
export class AppError extends Error {
@@ -80,6 +87,8 @@ export class AppError extends Error {
*/
statusCode?: number;
headers?: Record<string, string>;
name = 'AppError';
/**
@@ -95,6 +104,7 @@ export class AppError extends Error {
this.code = errorCode;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
this.headers = options?.headers;
}
/**
+26
View File
@@ -1,16 +1,29 @@
import { JobClient } from './client/client';
import { SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION } from './definitions/emails/send-2fa-token-email';
import { SEND_COMPLETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-completed-email';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-direct-template-created-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-super-delete-email';
import { SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION } from './definitions/emails/send-forgot-password-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-pending-email';
import { SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-removed-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-resend-document-email';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
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 { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
/**
@@ -28,9 +41,22 @@ export const jobsClient = new JobClient([
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION,
BULK_SEND_TEMPLATE_JOB_DEFINITION,
EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SEND_PENDING_EMAIL_JOB_DEFINITION,
SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION,
SEND_COMPLETED_EMAIL_JOB_DEFINITION,
SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION,
SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION,
SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION,
SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
@@ -36,6 +36,11 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
trigger: {
name: Name;
schema?: z.ZodType<Schema>;
/**
* Optional cron expression (e.g., "* * * * *" for every minute).
* When set, the job runs on a schedule instead of being event-triggered.
*/
cron?: string;
};
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
};
+10
View File
@@ -16,4 +16,14 @@ export abstract class BaseJobProvider {
public getApiHandler(): (req: HonoContext) => Promise<Response | void> {
throw new Error('Not implemented');
}
/**
* Start the cron scheduler for any registered cron jobs.
*
* No-op for providers that handle cron scheduling externally (e.g. Inngest).
* Must be called explicitly at application startup.
*/
public startCron(): void {
// No-op by default — providers override if needed.
}
}
+11
View File
@@ -26,4 +26,15 @@ export class JobClient<T extends ReadonlyArray<JobDefinition> = []> {
public getApiHandler() {
return this._provider.getApiHandler();
}
/**
* Start the cron scheduler for any registered cron jobs.
*
* Call this once at application startup after the instance is ready to
* process requests. No-op for providers that handle cron externally
* (e.g. Inngest).
*/
public startCron() {
this._provider.startCron();
}
}
+5 -4
View File
@@ -35,16 +35,17 @@ export class InngestJobProvider extends BaseJobProvider {
}
public defineJob<N extends string, T>(job: JobDefinition<N, T>): void {
console.log('defining job', job.id);
const triggerConfig: { cron: string } | { event: N } = job.trigger.cron
? { cron: job.trigger.cron }
: { event: job.trigger.name };
const fn = this._client.createFunction(
{
id: job.id,
name: job.name,
optimizeParallelism: job.optimizeParallelism ?? false,
},
{
event: job.trigger.name,
},
triggerConfig,
async (ctx) => {
const io = this.convertInngestIoToJobRunIo(ctx);
+152 -1
View File
@@ -1,5 +1,6 @@
import { sha256 } from '@noble/hashes/sha256';
import { sha256 } from '@noble/hashes/sha2';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import { CronExpressionParser } from 'cron-parser';
import type { Context as HonoContext } from 'hono';
import { prisma } from '@documenso/prisma';
@@ -16,10 +17,33 @@ import {
import type { Json } from './_internal/json';
import { BaseJobProvider } from './base';
/**
* Build a deterministic BackgroundJob ID for a cron run so that multiple
* instances of the local provider racing to enqueue the same slot will
* collide on the primary key instead of creating duplicates.
*/
const createCronRunId = (jobId: string, scheduledFor: Date): string => {
const key = `cron:${jobId}:${scheduledFor.toISOString()}`;
const hash = Buffer.from(sha256(key)).toString('hex').slice(0, 24);
return `cron_${hash}`;
};
type CronJobEntry = {
definition: JobDefinition;
cron: string;
lastTickAt: Date;
};
const CRON_POLL_INTERVAL_MS = 30_000; // 30 seconds
const CRON_POLL_JITTER_MS = 5_000; // 0-5 seconds random offset
export class LocalJobProvider extends BaseJobProvider {
private static _instance: LocalJobProvider;
private _jobDefinitions: Record<string, JobDefinition> = {};
private _cronJobs: CronJobEntry[] = [];
private _cronPoller: NodeJS.Timeout | null = null;
private constructor() {
super();
@@ -38,6 +62,133 @@ export class LocalJobProvider extends BaseJobProvider {
...definition,
enabled: definition.enabled ?? true,
};
if (definition.trigger.cron && definition.enabled !== false) {
const alreadyRegistered = this._cronJobs.some((job) => job.definition.id === definition.id);
if (!alreadyRegistered) {
this._cronJobs.push({
definition: {
...definition,
enabled: definition.enabled ?? true,
},
cron: definition.trigger.cron,
lastTickAt: new Date(),
});
console.log(`[JOBS]: Registered cron job ${definition.id} (${definition.trigger.cron})`);
}
}
}
/**
* Start the single cron poller for all registered cron jobs.
*
* Must be called explicitly at application startup after all jobs have been
* defined. The poller runs every 30 seconds (+ random jitter to avoid
* thundering herd across instances) and evaluates all registered cron jobs
* for due slots.
*
* For each due slot it creates a BackgroundJob row with a deterministic ID.
* If the insert succeeds the job is dispatched; if it fails with a unique
* constraint violation (P2002) another instance already claimed that slot.
*/
public override startCron() {
if (this._cronPoller) {
return;
}
if (this._cronJobs.length === 0) {
return;
}
const tick = () => {
const jitter = Math.floor(Math.random() * CRON_POLL_JITTER_MS);
this._cronPoller = setTimeout(() => {
void this.processCronTick().finally(tick);
}, CRON_POLL_INTERVAL_MS + jitter);
};
tick();
console.log(`[JOBS]: Started cron poller for ${this._cronJobs.length} job(s)`);
}
private async processCronTick() {
for (const cronJob of this._cronJobs) {
try {
const dueSlots = this.getDueCronSlots(cronJob);
cronJob.lastTickAt = new Date();
if (dueSlots.length === 0) {
continue;
}
// Only take the latest slot — sweep-style jobs don't need to catch up
// every missed slot after downtime, just the most recent one.
const scheduledFor = dueSlots[dueSlots.length - 1];
const deterministicId = createCronRunId(cronJob.definition.id, scheduledFor);
const pendingJob = await prisma.backgroundJob
.create({
data: {
id: deterministicId,
jobId: cronJob.definition.id,
name: cronJob.definition.name,
version: cronJob.definition.version,
payload: { scheduledFor: scheduledFor.toISOString() },
},
})
.catch((error: unknown) => {
// P2002 = unique constraint violation — another instance already enqueued this slot.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return null;
}
throw error;
});
if (!pendingJob) {
continue;
}
await this.submitJobToEndpoint({
jobId: pendingJob.id,
jobDefinitionId: pendingJob.jobId,
data: {
name: cronJob.definition.trigger.name,
payload: { scheduledFor: scheduledFor.toISOString() },
},
isRetry: false,
});
} catch (error) {
console.error(`[JOBS]: Cron tick failed for ${cronJob.definition.id}`, error);
}
}
}
/**
* Use cron-parser to find all cron slots that are due between the last tick
* and now.
*/
private getDueCronSlots(cronJob: CronJobEntry): Date[] {
const expr = CronExpressionParser.parse(cronJob.cron, {
currentDate: cronJob.lastTickAt,
});
const now = new Date();
const slots: Date[] = [];
let next = expr.next();
while (next.toDate() <= now) {
slots.push(next.toDate());
next = expr.next();
}
return slots;
}
public async triggerJob(options: SimpleTriggerJobOptions) {
@@ -0,0 +1,133 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '../../../server-only/2fa/email/constants';
import { generateTwoFactorTokenFromEmail } from '../../../server-only/2fa/email/generate-2fa-token-from-email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSend2FATokenEmailJobDefinition } from './send-2fa-token-email';
export const run = async ({
payload,
io,
}: {
payload: TSend2FATokenEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, recipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
id: recipientId,
},
},
},
include: {
recipients: {
where: {
id: recipientId,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const [recipient] = envelope.recipients;
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
if (!isRecipientEmailValidForSending(recipient)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient is missing email address',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
envelopeId,
email: recipient.email,
});
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const subject = i18n._(msg`Your two-factor authentication code`);
const template = createElement(AccessAuth2FAEmailTemplate, {
documentTitle: envelope.title,
userName: recipient.name,
userEmail: recipient.email,
code: twoFactorTokenToken,
expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED,
envelopeId: envelope.id,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
};
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID = 'send.2fa.token.email';
const SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSend2FATokenEmailJobDefinition = z.infer<
typeof SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION = {
id: SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID,
name: 'Send 2FA Token Email',
version: '1.0.0',
trigger: {
name: SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID,
schema: SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-2fa-token-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_2FA_TOKEN_EMAIL_JOB_DEFINITION_ID,
TSend2FATokenEmailJobDefinition
>;
@@ -0,0 +1,20 @@
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendCompletedEmailJobDefinition } from './send-completed-email';
export const run = async ({
payload,
}: {
payload: TSendCompletedEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, requestMetadata } = payload;
await sendCompletedEmail({
id: {
type: 'envelopeId',
id: envelopeId,
},
requestMetadata,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID = 'send.document.completed.email';
const SEND_COMPLETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendCompletedEmailJobDefinition = z.infer<
typeof SEND_COMPLETED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_COMPLETED_EMAIL_JOB_DEFINITION = {
id: SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Completed Email',
version: '1.0.0',
trigger: {
name: SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_COMPLETED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-completed-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_COMPLETED_EMAIL_JOB_DEFINITION_ID,
TSendCompletedEmailJobDefinition
>;
@@ -0,0 +1,105 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentSource } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDirectTemplateCreatedEmailJobDefinition } from './send-direct-template-created-email';
export const run = async ({
payload,
}: {
payload: TSendDirectTemplateCreatedEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, teamId, directRecipientId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
source: DocumentSource.TEMPLATE_DIRECT_LINK,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
documentMeta: true,
team: {
select: {
url: true,
},
},
},
});
if (!envelope) {
throw new Error('Document not found');
}
const directRecipient = await prisma.recipient.findFirst({
where: {
id: directRecipientId,
envelopeId,
},
});
if (!directRecipient) {
throw new Error('Direct recipient not found on envelope');
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const templateOwner = envelope.user;
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${
envelope.id
}`;
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipient.email,
recipientRole: directRecipient.role,
documentLink,
documentName: envelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
};
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID = 'send.direct.template.created.email';
const SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
teamId: z.number(),
directRecipientId: z.number(),
});
export type TSendDirectTemplateCreatedEmailJobDefinition = z.infer<
typeof SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION = {
id: SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Direct Template Created Email',
version: '1.0.0',
trigger: {
name: SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-direct-template-created-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DIRECT_TEMPLATE_CREATED_EMAIL_JOB_DEFINITION_ID,
TSendDirectTemplateCreatedEmailJobDefinition
>;
@@ -82,38 +82,36 @@ export const run = async ({
isRecipientEmailValidForSending(recipient),
);
await io.runTask('send-cancellation-emails', async () => {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
await Promise.all(
recipientsToNotify.map(async (recipient) => {
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: documentOwner.name || undefined,
inviterEmail: documentOwner.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
cancellationReason: cancellationReason || 'The document has been cancelled.',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
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: i18n._(msg`Document "${envelope.title}" Cancelled`),
html,
text,
});
}),
);
});
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" Cancelled`),
html,
text,
});
}),
);
};
@@ -0,0 +1,68 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentSuperDeleteEmailJobDefinition } from './send-document-super-delete-email';
export const run = async ({
payload,
io,
}: {
payload: TSendDocumentSuperDeleteEmailJobDefinition;
io: JobRunIO;
}) => {
const { userId, documentTitle, reason, teamId } = payload;
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { email: true, name: true },
});
const { branding, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentSuperDeleteEmailTemplate, {
documentName: documentTitle,
reason,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: senderEmail,
subject: i18n._(msg`Document Deleted!`),
html,
text,
});
};
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID = 'send.document.super.delete.email';
const SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
documentTitle: z.string(),
reason: z.string(),
teamId: z.number(),
});
export type TSendDocumentSuperDeleteEmailJobDefinition = z.infer<
typeof SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION = {
id: SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID,
name: 'Send Document Super Delete Email',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-super-delete-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_SUPER_DELETE_EMAIL_JOB_DEFINITION_ID,
TSendDocumentSuperDeleteEmailJobDefinition
>;
@@ -0,0 +1,72 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { env } from '../../../utils/env';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendForgotPasswordEmailJobDefinition } from './send-forgot-password-email';
export const run = async ({
payload,
io,
}: {
payload: TSendForgotPasswordEmailJobDefinition;
io: JobRunIO;
}) => {
const { userId } = payload;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
include: {
passwordResetTokens: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (user.passwordResetTokens.length === 0) {
throw new Error('No password reset token found for user');
}
const token = user.passwordResetTokens[0].token;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template),
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
await mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
subject: i18n._(msg`Forgot Password?`),
html,
text,
});
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID = 'send.forgot.password.email';
const SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
});
export type TSendForgotPasswordEmailJobDefinition = z.infer<
typeof SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION = {
id: SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID,
name: 'Send Forgot Password Email',
version: '1.0.0',
trigger: {
name: SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID,
schema: SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-forgot-password-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_FORGOT_PASSWORD_EMAIL_JOB_DEFINITION_ID,
TSendForgotPasswordEmailJobDefinition
>;
@@ -80,41 +80,36 @@ export const run = async ({
continue;
}
await io.runTask(
`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});
},
);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});
}
};
@@ -75,40 +75,35 @@ export const run = async ({
continue;
}
await io.runTask(
`send-organisation-member-left-email--${oldMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
},
);
await mailer.sendMail({
to: member.user.email,
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
}
};
@@ -0,0 +1,117 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { RecipientExpiredTemplate } from '@documenso/email/templates/recipient-expired';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOwnerRecipientExpiredEmailJobDefinition } from './send-owner-recipient-expired-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOwnerRecipientExpiredEmailJobDefinition;
io: JobRunIO;
}) => {
const { recipientId, envelopeId } = payload;
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
url: true,
},
},
},
});
if (!envelope) {
throw new Error(`Envelope ${envelopeId} not found`);
}
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelopeId,
},
});
if (!recipient) {
throw new Error(`Recipient ${recipientId} not found on envelope ${envelopeId}`);
}
const { documentMeta, user: documentOwner } = envelope;
const isEmailEnabled = extractDerivedDocumentEmailSettings(documentMeta).ownerRecipientExpired;
if (!isEmailEnabled) {
return;
}
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
const documentLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team.url)}/${envelope.id}`;
const template = createElement(RecipientExpiredTemplate, {
documentName: envelope.title,
recipientName: recipient.name || recipient.email,
recipientEmail: recipient.email,
documentLink,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
await io.runTask('send-owner-recipient-expired-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: documentOwner.name || '',
address: documentOwner.email,
},
from: senderEmail,
subject: i18n._(
msg`Signing window expired for "${recipient.name || recipient.email}" on "${envelope.title}"`,
),
html,
text,
});
});
};
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID = 'send.owner.recipient.expired.email';
const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
envelopeId: z.string(),
});
export type TSendOwnerRecipientExpiredEmailJobDefinition = z.infer<
typeof SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION = {
id: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Owner Recipient Expired Email',
version: '1.0.0',
trigger: {
name: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-owner-recipient-expired-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION_ID,
TSendOwnerRecipientExpiredEmailJobDefinition
>;
@@ -0,0 +1,20 @@
import { sendPendingEmail } from '../../../server-only/document/send-pending-email';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendPendingEmailJobDefinition } from './send-pending-email';
export const run = async ({
payload,
}: {
payload: TSendPendingEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, recipientId } = payload;
await sendPendingEmail({
id: {
type: 'envelopeId',
id: envelopeId,
},
recipientId,
});
};
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email';
const SEND_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
recipientId: z.number(),
});
export type TSendPendingEmailJobDefinition = z.infer<
typeof SEND_PENDING_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_PENDING_EMAIL_JOB_DEFINITION = {
id: SEND_PENDING_EMAIL_JOB_DEFINITION_ID,
name: 'Send Pending Email',
version: '1.0.0',
trigger: {
name: SEND_PENDING_EMAIL_JOB_DEFINITION_ID,
schema: SEND_PENDING_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-pending-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_PENDING_EMAIL_JOB_DEFINITION_ID,
TSendPendingEmailJobDefinition
>;
@@ -105,25 +105,23 @@ export const run = async ({
assetBaseUrl,
});
await io.runTask('send-recipient-signed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${envelope.title}"`),
html,
text,
});
await mailer.sendMail({
to: {
name: owner.name ?? '',
address: owner.email,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${envelope.title}"`),
html,
text,
});
};
@@ -0,0 +1,215 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import {
DocumentStatus,
EnvelopeType,
OrganisationType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { isDocumentCompleted } from '../../../utils/document';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendResendDocumentEmailJobDefinition } from './send-resend-document-email';
export const run = async ({
payload,
}: {
payload: TSendResendDocumentEmailJobDefinition;
io: JobRunIO;
}) => {
const { envelopeId, userId, recipientIds, requestMetadata } = payload;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
},
include: {
recipients: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
if (!envelope) {
throw new Error('Document not found');
}
if (envelope.status === DocumentStatus.DRAFT) {
throw new Error('Can not send draft document');
}
if (isDocumentCompleted(envelope.status)) {
throw new Error('Can not send completed document');
}
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const recipientsToRemind = envelope.recipients.filter(
(recipient) =>
recipientIds.includes(recipient.id) &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC,
);
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return;
}
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = envelope.documentMeta.message || '';
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
envelope.documentMeta.message ||
i18n._(
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
);
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': envelope.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? envelope.team?.teamEmail?.email || user.email
: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
organisationType,
teamName: envelope.team?.name,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
}),
);
};
@@ -0,0 +1,35 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID = 'send.resend.document.email';
const SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
userId: z.number(),
teamId: z.number(),
recipientIds: z.array(z.number()),
requestMetadata: z.any().optional(),
});
export type TSendResendDocumentEmailJobDefinition = z.infer<
typeof SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION = {
id: SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Resend Document Email',
version: '1.0.0',
trigger: {
name: SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-resend-document-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_RESEND_DOCUMENT_EMAIL_JOB_DEFINITION_ID,
TSendResendDocumentEmailJobDefinition
>;
@@ -0,0 +1,36 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import type { JobRunIO } from '../../client/_internal/job';
import type { TCleanupRateLimitsJobDefinition } from './cleanup-rate-limits';
const BATCH_SIZE = 10_000;
export const run = async ({ io }: { payload: TCleanupRateLimitsJobDefinition; io: JobRunIO }) => {
const cutoff = DateTime.now().minus({ hours: 24 }).toJSDate();
let totalDeleted = 0;
let deleted = 0;
do {
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
// to avoid long-running transactions that could lock the table.
deleted = await prisma.$executeRaw`
DELETE FROM "RateLimit"
WHERE ctid IN (
SELECT ctid FROM "RateLimit"
WHERE "createdAt" < ${cutoff}
LIMIT ${BATCH_SIZE}
)
`;
totalDeleted += deleted;
} while (deleted >= BATCH_SIZE);
if (totalDeleted > 0) {
io.logger.info(`Cleaned up ${totalDeleted} expired rate limit entries`);
} else {
io.logger.info('No expired rate limit entries to clean up');
}
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID = 'internal.cleanup-rate-limits';
const CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA = z.object({});
export type TCleanupRateLimitsJobDefinition = z.infer<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA
>;
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
id: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
name: 'Cleanup Rate Limits',
version: '1.0.0',
trigger: {
name: CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
schema: CLEANUP_RATE_LIMITS_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./cleanup-rate-limits.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof CLEANUP_RATE_LIMITS_JOB_DEFINITION_ID,
TCleanupRateLimitsJobDefinition
>;
@@ -0,0 +1,53 @@
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TExpireRecipientsSweepJobDefinition } from './expire-recipients-sweep';
export const run = async ({
io,
}: {
payload: TExpireRecipientsSweepJobDefinition;
io: JobRunIO;
}) => {
const now = new Date();
const expiredRecipients = await prisma.recipient.findMany({
where: {
expiresAt: {
lte: now,
},
expirationNotifiedAt: null,
signingStatus: {
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
},
envelope: {
status: DocumentStatus.PENDING,
},
},
select: {
id: true,
},
take: 1000, // Limit to 1000 to avoid long-running jobs. Will be picked up in the next run if there are more.
});
if (expiredRecipients.length === 0) {
io.logger.info('No expired recipients found');
return;
}
io.logger.info(`Found ${expiredRecipients.length} expired recipients`);
await Promise.allSettled(
expiredRecipients.map(async (recipient) => {
await jobs.triggerJob({
name: 'internal.process-recipient-expired',
payload: {
recipientId: recipient.id,
},
});
}),
);
};
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID = 'internal.expire-recipients-sweep';
const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
export type TExpireRecipientsSweepJobDefinition = z.infer<
typeof EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA
>;
export const EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION = {
id: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
name: 'Expire Recipients Sweep',
version: '1.0.0',
trigger: {
name: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
schema: EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_SCHEMA,
cron: '*/15 * * * *', // Every 15 minutes.
},
handler: async ({ payload, io }) => {
const handler = await import('./expire-recipients-sweep.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION_ID,
TExpireRecipientsSweepJobDefinition
>;
@@ -0,0 +1,95 @@
import { SigningStatus, WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
} from '../../../types/webhook-payload';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TProcessRecipientExpiredJobDefinition } from './process-recipient-expired';
export const run = async ({
payload,
io,
}: {
payload: TProcessRecipientExpiredJobDefinition;
io: JobRunIO;
}) => {
const { recipientId } = payload;
// Atomic idempotency guard — only one concurrent worker wins.
// Wrapping in runTask caches the result so that on retry the claim is not
// re-evaluated and subsequent steps can still proceed.
const claimedCount = await io.runTask('claim-recipient', async () => {
const result = await prisma.recipient.updateMany({
where: {
id: recipientId,
expirationNotifiedAt: null,
signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] },
},
data: { expirationNotifiedAt: new Date() },
});
return result.count;
});
if (claimedCount === 0) {
io.logger.info(`Recipient ${recipientId} already processed or no longer eligible, skipping`);
return;
}
// Fetch recipient (now marked) with its envelope for downstream steps.
// Re-fetch after claiming so that expirationNotifiedAt reflects the updated value
// and webhook consumers see consistent state.
const recipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipientId },
include: {
envelope: {
include: { recipients: true, documentMeta: true },
},
},
});
const { envelope } = recipient;
io.logger.info(
`Recipient ${recipientId} (${recipient.email}) expired on envelope ${recipient.envelopeId}`,
);
// Create audit log entry — wrapped so a retry skips this if it already succeeded.
await io.runTask('create-audit-log', async () => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED,
envelopeId: recipient.envelopeId,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
},
}),
});
});
// Trigger webhook for recipient expiration.
await triggerWebhook({
event: WebhookTriggerEvents.RECIPIENT_EXPIRED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
userId: envelope.userId,
teamId: envelope.teamId,
});
// Trigger email notification to the document owner.
await jobs.triggerJob({
name: 'send.owner.recipient.expired.email',
payload: {
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
},
});
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import { type JobDefinition } from '../../client/_internal/job';
const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID = 'internal.process-recipient-expired';
const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA = z.object({
recipientId: z.number(),
});
export type TProcessRecipientExpiredJobDefinition = z.infer<
typeof PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA
>;
export const PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION = {
id: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
name: 'Process Recipient Expired',
version: '1.0.0',
trigger: {
name: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
schema: PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./process-recipient-expired.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION_ID,
TProcessRecipientExpiredJobDefinition
>;
@@ -21,7 +21,6 @@ import { signPdf } from '@documenso/signing';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
@@ -323,20 +322,21 @@ export const run = async ({
};
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({
id: { type: 'envelopeId', id: envelopeId },
if (shouldSendCompletedEmail) {
await io.triggerJob('trigger-send-completed-email', {
name: 'send.document.completed.email',
payload: {
envelopeId,
requestMetadata,
});
}
});
},
});
}
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
@@ -1,30 +1,21 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { DocumentStatus, SendStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export type AdminSuperDeleteDocumentOptions = {
envelopeId: string;
reason: string;
requestMetadata?: RequestMetadata;
};
export const adminSuperDeleteDocument = async ({
envelopeId,
reason,
requestMetadata,
}: AdminSuperDeleteDocumentOptions) => {
const envelope = await prisma.envelope.findUnique({
@@ -32,7 +23,6 @@ export const adminSuperDeleteDocument = async ({
id: envelopeId,
},
include: {
recipients: true,
documentMeta: true,
user: {
select: {
@@ -50,75 +40,14 @@ export const adminSuperDeleteDocument = async ({
});
}
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { status, user } = envelope;
const { user } = envelope;
const isDocumentDeletedEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).documentDeleted;
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
// if the document is pending, send cancellation emails to all recipients
if (
status === DocumentStatus.PENDING &&
recipientsToNotify.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const lang = envelope.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
}
// always hard delete if deleted from admin
return await prisma.$transaction(async (tx) => {
// Always hard delete if deleted from admin.
const deletedEnvelope = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId,
@@ -133,4 +62,21 @@ export const adminSuperDeleteDocument = async ({
return await tx.envelope.delete({ where: { id: envelopeId } });
});
// Notify the document owner after the hard delete transaction commits.
// We only send the owner notification; recipient cancellation emails are
// omitted because the recipients are hard-deleted with the envelope.
if (isDocumentDeletedEmailEnabled) {
await jobs.triggerJob({
name: 'send.document.super.delete.email',
payload: {
userId: user.id,
documentTitle: envelope.title,
reason,
teamId: envelope.teamId,
},
});
}
return deletedEnvelope;
};
@@ -29,10 +29,10 @@ import {
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -94,6 +94,8 @@ export const completeDocumentWithToken = async ({
const [recipient] = envelope.recipients;
assertRecipientNotExpired(recipient);
if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -314,7 +316,13 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
@@ -1,4 +1,4 @@
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
@@ -9,6 +9,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
export type RejectDocumentWithTokenOptions = {
token: string;
@@ -42,6 +43,14 @@ export async function rejectDocumentWithToken({
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
@@ -1,34 +1,12 @@
import { createElement } from 'react';
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { msg } from '@lingui/core/macro';
import {
DocumentStatus,
EnvelopeType,
OrganisationType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { jobs } from '../../jobs/client';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type ResendDocumentOptions = {
@@ -46,17 +24,6 @@ export const resendDocument = async ({
teamId,
requestMetadata,
}: ResendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
name: true,
},
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -69,12 +36,6 @@ export const resendDocument = async ({
include: {
recipients: true,
documentMeta: true,
team: {
select: {
teamEmail: true,
name: true,
},
},
},
});
@@ -94,143 +55,45 @@ export const resendDocument = async ({
throw new Error('Can not send completed document');
}
// Refresh the expiresAt on each resent recipient.
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
const recipientsToRemind = envelope.recipients.filter(
(recipient) =>
recipients.includes(recipient.id) && recipient.signingStatus === SigningStatus.NOT_SIGNED,
recipients.includes(recipient.id) &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC,
);
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
if (!isRecipientSigningRequestEmailEnabled) {
return envelope;
// Extend the expiration deadline for recipients being resent.
if (expiresAt && recipientsToRemind.length > 0) {
await prisma.recipient.updateMany({
where: {
id: {
in: recipientsToRemind.map((r) => r.id),
},
},
data: {
expiresAt,
expirationNotifiedAt: null,
},
});
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
// Dispatch the email sending to a background job so that email delivery
// failures don't block the resend operation and can be retried independently.
if (recipientsToRemind.length > 0) {
await jobs.triggerJob({
name: 'send.resend.document.email',
payload: {
envelopeId: envelope.id,
userId,
teamId: envelope.teamId,
recipientIds: recipientsToRemind.map((r) => r.id),
requestMetadata,
},
meta: envelope.documentMeta,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return;
}
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient;
const selfSigner = email === user.email;
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
.toLowerCase();
let emailMessage = envelope.documentMeta.message || '';
let emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} this document`);
if (selfSigner) {
emailMessage = i18n._(
msg`You have initiated the document ${`"${envelope.title}"`} that requires you to ${recipientActionVerb} it.`,
);
emailSubject = i18n._(msg`Reminder: Please ${recipientActionVerb} your document`);
}
if (organisationType === OrganisationType.ORGANISATION) {
emailSubject = i18n._(
msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
);
emailMessage =
envelope.documentMeta.message ||
i18n._(
msg`${user.name || user.email} on behalf of "${envelope.team.name}" has invited you to ${recipientActionVerb} the document "${envelope.title}".`,
);
}
const customEmailTemplate = {
'signer.name': name,
'signer.email': email,
'document.name': envelope.title,
};
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail:
organisationType === OrganisationType.ORGANISATION
? envelope.team?.teamEmail?.email || user.email
: user.email,
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate),
role: recipient.role,
selfSigner,
organisationType,
teamName: envelope.team?.name,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await prisma.$transaction(
async (tx) => {
await mailer.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: envelope.documentMeta.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
customEmailTemplate,
)
: emailSubject,
html,
text,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
emailType: recipientEmailType,
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
isResending: true,
},
}),
});
},
{ timeout: 30_000 },
);
}),
);
}
return envelope;
};
@@ -22,6 +22,8 @@ import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
const TWENTY_MB_IN_BYTES = 20 * 1024 * 1024;
export interface SendDocumentOptions {
id: EnvelopeIdOptions;
requestMetadata?: RequestMetadata;
@@ -81,7 +83,7 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const { user: owner } = envelope;
const completedDocumentEmailAttachments = await Promise.all(
let completedDocumentEmailAttachments = await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => {
const file = await getFileServerSide(envelopeItem.documentData);
@@ -97,6 +99,16 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
}),
);
const allAttachmentsSize = completedDocumentEmailAttachments.reduce(
(acc, attachment) => acc + attachment.content.length,
0,
);
// If the total size of attachments exceeds 20MB, do not include attachments and instead provide a download link in the email body.
if (allAttachmentsSize > TWENTY_MB_IN_BYTES) {
completedDocumentEmailAttachments = [];
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
let documentOwnerDownloadLink = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(
@@ -10,6 +10,7 @@ import {
WebhookTriggerEvents,
} from '@prisma/client';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -257,6 +258,28 @@ export const sendDocument = async ({
});
}
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
// Set expiresAt on each recipient that hasn't already signed/rejected.
// Exclude CC recipients since they don't sign and shouldn't be subject to expiry.
if (expiresAt) {
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
signingStatus: {
notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED],
},
role: {
not: RecipientRole.CC,
},
},
data: {
expiresAt,
expirationNotifiedAt: null,
},
});
}
return await tx.envelope.update({
where: {
id: envelope.id,
@@ -163,6 +163,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
isRecipientsTurn: true,
isCompleted: false,
isRejected: false,
isExpired: false,
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
@@ -13,6 +13,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientExpired } from '../../utils/recipients';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@@ -56,7 +57,9 @@ export const ZEnvelopeForSigningResponse = z.object({
email: true,
name: true,
documentDeletedAt: true,
expired: true,
expired: true, //!: deprecated Not in use. To be removed in a future migration.
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
signingOrder: true,
@@ -102,7 +105,8 @@ export const ZEnvelopeForSigningResponse = z.object({
email: true,
name: true,
documentDeletedAt: true,
expired: true,
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
token: true,
@@ -126,6 +130,7 @@ export const ZEnvelopeForSigningResponse = z.object({
isCompleted: z.boolean(),
isRejected: z.boolean(),
isExpired: z.boolean(),
isRecipientsTurn: z.boolean(),
sender: z.object({
@@ -291,6 +296,7 @@ export const getEnvelopeForRecipientSigning = async ({
isRejected:
recipient.signingStatus === SigningStatus.REJECTED ||
envelope.status === DocumentStatus.REJECTED,
isExpired: isRecipientExpired(recipient),
sender,
settings: {
includeSenderDetails: settings.includeSenderDetails,
@@ -3,6 +3,7 @@ import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
export type RemovedSignedFieldWithTokenOptions = {
@@ -56,6 +57,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document ${envelope.id} must be pending`);
}
assertRecipientNotExpired(recipient);
if (
recipient?.signingStatus === SigningStatus.SIGNED ||
field.recipient.signingStatus === SigningStatus.SIGNED
@@ -25,6 +25,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = {
@@ -108,6 +109,8 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${envelope.id} must be pending for signing`);
}
assertRecipientNotExpired(recipient);
if (
recipient.signingStatus === SigningStatus.SIGNED ||
field.recipient.signingStatus === SigningStatus.SIGNED
@@ -0,0 +1,95 @@
import type { Context } from 'hono';
import type { MiddlewareHandler } from 'hono/types';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getIpAddress } from '../../universal/get-ip-address';
import type { RateLimitCheckResult } from './rate-limit';
import type { createRateLimit } from './rate-limit';
/**
* Set rate limit response headers on a Hono context.
*/
const setRateLimitHeaders = (c: Context, result: RateLimitCheckResult) => {
c.header('X-RateLimit-Limit', String(result.limit));
c.header('X-RateLimit-Remaining', String(result.remaining));
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
};
/**
* Create a Hono middleware that applies rate limiting to a route.
*
* Uses IP address for identification. Optionally accepts an identifier
* function for per-user/per-entity limiting.
*/
export const createRateLimitMiddleware = (
limiter: ReturnType<typeof createRateLimit>,
options?: { identifierFn?: (c: Context) => string | undefined },
): MiddlewareHandler => {
return async (c, next) => {
let ip: string;
try {
ip = getIpAddress(c.req.raw);
} catch {
ip = 'unknown';
}
const identifier = options?.identifierFn?.(c);
const result = await limiter.check({ ip, identifier });
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
await next();
};
};
/**
* Helper for inline rate limit checks in Hono auth routes.
*
* Returns a 429 Response with rate limit headers if limited, or `null` if allowed.
*/
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
setRateLimitHeaders(c, result);
if (result.isLimited) {
c.header(
'Retry-After',
String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
);
return c.json({ error: 'Too many requests, please try again later.' }, 429);
}
return null;
};
/**
* Helper for inline rate limit checks in tRPC routes.
*
* Throws an AppError with TOO_MANY_REQUESTS code if limited.
*/
export const assertRateLimit = (result: RateLimitCheckResult): void => {
if (result.isLimited) {
const retryAfter = String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message: 'Too many requests, please try again later.',
headers: {
'X-RateLimit-Limit': String(result.limit),
'X-RateLimit-Remaining': String(result.remaining),
'X-RateLimit-Reset': String(Math.ceil(result.reset.getTime() / 1000)),
'Retry-After': retryAfter,
},
});
}
};
@@ -0,0 +1,197 @@
import { prisma } from '@documenso/prisma';
import { logger } from '../../utils/logger';
type WindowUnit = 's' | 'm' | 'h' | 'd';
type WindowStr = `${number}${WindowUnit}`;
type RateLimitConfig = {
action: string;
max: number;
globalMax?: number;
window: WindowStr;
};
type CheckParams = {
ip: string;
identifier?: string;
};
export type RateLimitCheckResult = {
isLimited: boolean;
remaining: number;
limit: number;
reset: Date;
};
/**
* Parse window string (e.g., '1h', '15m', '30s') to milliseconds.
*/
export const parseWindow = (window: WindowStr): number => {
const value = parseInt(window.slice(0, -1), 10);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const unit = window.slice(-1) as WindowUnit;
const multipliers: Record<WindowUnit, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return value * multipliers[unit];
};
/**
* Compute the current time bucket for the given window size.
*/
export const getBucket = (windowMs: number): Date => {
const now = Date.now();
return new Date(now - (now % windowMs));
};
/**
* Create a rate limiter with the given configuration.
*
* Uses bucketed counters in the database for distributed rate limiting
* across multiple instances. Each check atomically increments the counter
* and returns the new count.
*/
export const createRateLimit = (config: RateLimitConfig) => {
const windowMs = parseWindow(config.window);
return {
async check(params: CheckParams): Promise<RateLimitCheckResult> {
const bucket = getBucket(windowMs);
const reset = new Date(bucket.getTime() + windowMs);
const ipLimit = config.globalMax ?? config.max;
if (process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true') {
return {
isLimited: false,
remaining: ipLimit,
limit: ipLimit,
reset,
};
}
try {
// Always upsert the IP counter.
const ipResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
},
},
create: {
key: `ip:${params.ip}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
// Check IP against globalMax if set, or against max if no identifier is provided.
let ipCheckLimit = config.globalMax;
if (!params.identifier) {
ipCheckLimit = config.max;
}
if (ipCheckLimit && ipResult.count > ipCheckLimit) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'ip',
key: params.ip,
count: ipResult.count,
limit: ipCheckLimit,
});
return {
isLimited: true,
remaining: 0,
limit: ipCheckLimit,
reset,
};
}
// Upsert the identifier counter if provided.
if (params.identifier) {
const identifierResult = await prisma.rateLimit.upsert({
where: {
key_action_bucket: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
},
},
create: {
key: `id:${params.identifier}`,
action: config.action,
bucket,
count: 1,
},
update: {
count: { increment: 1 },
},
});
if (identifierResult.count > config.max) {
logger.warn({
msg: 'Rate limit exceeded',
action: config.action,
keyType: 'identifier',
key: params.identifier,
count: identifierResult.count,
limit: config.max,
});
return {
isLimited: true,
remaining: 0,
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, config.max - identifierResult.count),
limit: config.max,
reset,
};
}
return {
isLimited: false,
remaining: Math.max(0, ipLimit - ipResult.count),
limit: ipLimit,
reset,
};
} catch (error) {
// Fail-open: if the rate limit DB query fails, allow the request through.
logger.error({
msg: 'Rate limit check failed, failing open',
action: config.action,
error,
});
const limit = params.identifier ? config.max : ipLimit;
return {
isLimited: false,
remaining: limit,
limit,
reset,
};
}
},
};
};
@@ -0,0 +1,99 @@
import { createRateLimit } from './rate-limit';
// ---- Auth (Tier 1 - Critical, sends emails) ----
export const signupRateLimit = createRateLimit({
action: 'auth.signup',
max: 10,
window: '1h',
});
export const forgotPasswordRateLimit = createRateLimit({
action: 'auth.forgot-password',
max: 3,
globalMax: 20,
window: '1h',
});
export const resendVerifyEmailRateLimit = createRateLimit({
action: 'auth.resend-verify-email',
max: 3,
globalMax: 20,
window: '1h',
});
export const request2FAEmailRateLimit = createRateLimit({
action: 'auth.request-2fa-email',
max: 5,
globalMax: 20,
window: '15m',
});
// ---- Auth (Tier 2 - Unauthenticated) ----
export const loginRateLimit = createRateLimit({
action: 'auth.login',
max: 10,
globalMax: 50,
window: '15m',
});
export const resetPasswordRateLimit = createRateLimit({
action: 'auth.reset-password',
max: 5,
globalMax: 20,
window: '1h',
});
export const verifyEmailRateLimit = createRateLimit({
action: 'auth.verify-email',
max: 5,
globalMax: 20,
window: '15m',
});
export const passkeyRateLimit = createRateLimit({
action: 'auth.passkey',
max: 10,
globalMax: 50,
window: '15m',
});
export const linkOrgAccountRateLimit = createRateLimit({
action: 'auth.link-org-account',
max: 5,
globalMax: 20,
window: '1h',
});
// ---- API (Tier 4 - Standard) ----
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 100,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 100,
window: '1m',
});
export const apiTrpcRateLimit = createRateLimit({
action: 'api.trpc',
max: 100,
window: '1m',
});
export const aiRateLimit = createRateLimit({
action: 'api.ai',
max: 3,
window: '1m',
});
export const fileUploadRateLimit = createRateLimit({
action: 'api.file-upload',
max: 20,
window: '1m',
});
@@ -1,6 +1,3 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
@@ -18,14 +15,11 @@ import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '@documenso/email/templates/document-created-from-direct-template';
import { jobs } from '@documenso/lib/jobs/client';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/field-router/schema';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
@@ -48,8 +42,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { getEmailContext } from '../email/get-email-context';
@@ -156,7 +148,7 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
const { settings } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
@@ -164,7 +156,7 @@ export const createDocumentFromDirectTemplate = async ({
},
});
const { recipients, directLink, user: templateOwner } = directTemplateEnvelope;
const { recipients, directLink } = directTemplateEnvelope;
const directTemplateRecipient = recipients.find(
(recipient) => recipient.id === directLink.directTemplateRecipientId,
@@ -755,37 +747,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
recipientRole: directTemplateRecipient.role,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(createdEnvelope.team?.url)}/${
createdEnvelope.id
}`,
documentName: createdEnvelope.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
{
name: templateOwner.name || '',
address: templateOwner.email,
},
],
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,
});
return {
createdEnvelope,
token: createdDirectRecipient.token,
@@ -793,6 +754,17 @@ export const createDocumentFromDirectTemplate = async ({
};
});
// Dispatch the email notification to the template owner as a background job.
// This is outside the transaction so email failures don't roll back document creation.
await jobs.triggerJob({
name: 'send.direct.template.created.email',
payload: {
envelopeId: createdEnvelope.id,
teamId: createdEnvelope.teamId,
directRecipientId: recipientId,
},
});
try {
// This handles sending emails and sealing the document if required.
await sendDocument({
@@ -17,6 +17,7 @@ import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import type { TEnvelopeExpirationPeriod } from '../../constants/envelope-expiration';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZDefaultRecipientsSchema } from '../../types/default-recipients';
@@ -119,6 +120,7 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
};
formValues?: TDocumentFormValues;
@@ -521,6 +523,8 @@ export const createDocumentFromTemplate = async ({
override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod:
override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
}),
});
@@ -3,7 +3,7 @@ import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { ONE_DAY } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password';
import { jobs } from '../../jobs/client';
export const forgotPassword = async ({ email }: { email: string }) => {
const user = await prisma.user.findFirst({
@@ -46,7 +46,10 @@ export const forgotPassword = async ({ email }: { email: string }) => {
},
});
await sendForgotPassword({
userId: user.id,
}).catch((err) => console.error(err));
await jobs.triggerJob({
name: 'send.forgot.password.email',
payload: {
userId: user.id,
},
});
};
@@ -61,7 +61,8 @@ export const generateSampleWebhookPayload = (
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -81,7 +82,8 @@ export const generateSampleWebhookPayload = (
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -120,7 +122,8 @@ export const generateSampleWebhookPayload = (
role: RecipientRole.VIEWER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -139,7 +142,8 @@ export const generateSampleWebhookPayload = (
role: RecipientRole.SIGNER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -168,7 +172,8 @@ export const generateSampleWebhookPayload = (
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -185,7 +190,8 @@ export const generateSampleWebhookPayload = (
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -222,7 +228,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
rejectionReason: null,
},
@@ -243,7 +250,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
rejectionReason: null,
},
@@ -270,7 +278,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -291,7 +300,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -314,7 +324,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -335,7 +346,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -374,7 +386,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
},
],
@@ -391,7 +404,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
},
],
@@ -425,6 +439,7 @@ export const generateSampleWebhookPayload = (
recipientRemoved: true,
documentCompleted: true,
ownerDocumentCompleted: true,
ownerRecipientExpired: true,
recipientSigningRequest: true,
},
},
@@ -437,7 +452,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: {
accessAuth: null,
@@ -460,7 +476,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: {
accessAuth: null,
@@ -480,5 +497,53 @@ export const generateSampleWebhookPayload = (
};
}
if (event === WebhookTriggerEvents.RECIPIENT_EXPIRED) {
const expiresAt = new Date(now.getTime() - 60 * 1000); // Expired 1 minute ago
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expiresAt,
expirationNotifiedAt: now,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.Recipient[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expiresAt,
expirationNotifiedAt: now,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`);
};
@@ -67,7 +67,9 @@ export const listDocumentsHandler = async (req: Request) => {
name: recipient.name,
token: recipient.token,
documentDeletedAt: recipient.documentDeletedAt,
expired: recipient.expired,
expired: recipient.expired, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: recipient.expiresAt,
expirationNotifiedAt: recipient.expirationNotifiedAt,
signedAt: recipient.signedAt,
authOptions: recipient.authOptions,
signingOrder: recipient.signingOrder,
+14
View File
@@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_VIEWED', // When the document is viewed by a recipient.
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_RECIPIENT_EXPIRED', // When a recipient's signing window expires.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
@@ -694,6 +695,18 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
}),
});
/**
* Event: Recipient's signing window expired.
*/
export const ZDocumentAuditLogEventRecipientExpiredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED),
data: z.object({
recipientEmail: z.string(),
recipientName: z.string(),
recipientId: z.number(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -739,6 +752,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema,
]),
);
+9
View File
@@ -10,6 +10,7 @@ export enum DocumentEmailEvents {
DocumentCompleted = 'documentCompleted',
DocumentDeleted = 'documentDeleted',
OwnerDocumentCompleted = 'ownerDocumentCompleted',
OwnerRecipientExpired = 'ownerRecipientExpired',
}
export const ZDocumentEmailSettingsSchema = z
@@ -52,6 +53,12 @@ export const ZDocumentEmailSettingsSchema = z
.boolean()
.describe('Whether to send an email to the document owner when the document is complete.')
.default(true),
ownerRecipientExpired: z
.boolean()
.describe(
"Whether to send an email to the document owner when a recipient's signing window has expired.",
)
.default(true),
})
.strip()
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
@@ -78,6 +85,7 @@ export const extractDerivedDocumentEmailSettings = (
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
ownerRecipientExpired: emailSettings.ownerRecipientExpired,
};
};
@@ -89,4 +97,5 @@ export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
ownerRecipientExpired: true,
};
+2
View File
@@ -3,6 +3,7 @@ import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client
import { z } from 'zod';
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
@@ -128,6 +129,7 @@ export const ZDocumentMetaCreateSchema = z.object({
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
+1
View File
@@ -71,6 +71,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
+1
View File
@@ -55,6 +55,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
+9 -3
View File
@@ -23,7 +23,9 @@ export const ZRecipientSchema = RecipientSchema.pick({
name: true,
token: true,
documentDeletedAt: true,
expired: true,
expired: true, // deprecated Not in use. To be removed in a future migration.
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
signingOrder: true,
@@ -50,7 +52,9 @@ export const ZRecipientLiteSchema = RecipientSchema.pick({
name: true,
token: true,
documentDeletedAt: true,
expired: true,
expired: true, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
signingOrder: true,
@@ -75,7 +79,9 @@ export const ZRecipientManySchema = RecipientSchema.pick({
name: true,
token: true,
documentDeletedAt: true,
expired: true,
expired: true, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: true,
expirationNotifiedAt: true,
signedAt: true,
authOptions: true,
signingOrder: true,
+4 -2
View File
@@ -26,7 +26,8 @@ export const ZWebhookRecipientSchema = z.object({
name: z.string(),
token: z.string(),
documentDeletedAt: z.date().nullable(),
expired: z.date().nullable(),
expiresAt: z.date().nullable(),
expirationNotifiedAt: z.date().nullable(),
signedAt: z.date().nullable(),
authOptions: z.any().nullable(),
signingOrder: z.number().nullable(),
@@ -116,7 +117,8 @@ export const mapEnvelopeToWebhookDocumentPayload = (
name: recipient.name,
token: recipient.token,
documentDeletedAt: recipient.documentDeletedAt,
expired: recipient.expired,
expiresAt: recipient.expiresAt,
expirationNotifiedAt: recipient.expirationNotifiedAt,
signedAt: recipient.signedAt,
authOptions: recipient.authOptions,
signingOrder: recipient.signingOrder,
@@ -571,6 +571,14 @@ export const formatDocumentAuditLogAction = (
you: msg`You deleted an envelope item with title ${data.envelopeItemTitle}`,
user: msg`${user} deleted an envelope item with title ${data.envelopeItemTitle}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, ({ data }) => ({
anonymous: msg({
message: `Recipient signing window expired`,
context: `Audit log format`,
}),
you: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
user: msg`Signing window expired for ${data.recipientName || data.recipientEmail}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED }, ({ data }) => {
const message = msg({
message: `The document ownership was delegated to ${data.delegatedOwnerName || data.delegatedOwnerEmail} on behalf of ${data.teamName}`,
+4
View File
@@ -62,6 +62,10 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
// Envelope expiration.
envelopeExpirationPeriod:
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
} satisfies Omit<DocumentMeta, 'id'>;
};
+4
View File
@@ -8,6 +8,7 @@ import {
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
@@ -138,6 +139,9 @@ export const generateDefaultOrganisationSettings = (): Omit<
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
defaultRecipients: null,
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
aiFeaturesEnabled: false,
};
};
+21
View File
@@ -5,6 +5,7 @@ import { z } from 'zod';
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 { extractLegacyIds } from '../universal/id';
/**
@@ -94,3 +95,23 @@ export const mapRecipientToLegacyRecipient = (
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
return z.string().email().safeParse(recipient.email).success;
};
/**
* Whether the recipient's signing window has expired.
*/
export const isRecipientExpired = (recipient: { expiresAt: Date | null }) => {
return Boolean(recipient.expiresAt && new Date(recipient.expiresAt) <= new Date());
};
/**
* Asserts that the recipient's signing window has not expired.
*
* Throws an AppError with RECIPIENT_EXPIRED if the expiration date has passed.
*/
export const assertRecipientNotExpired = (recipient: { expiresAt: Date | null }) => {
if (isRecipientExpired(recipient)) {
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
message: 'Recipient signing window has expired',
});
}
};
+3
View File
@@ -205,6 +205,9 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
// emailReplyToName: null,
defaultRecipients: null,
envelopeExpirationPeriod: null,
aiFeaturesEnabled: null,
};
};
@@ -0,0 +1,18 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'RECIPIENT_EXPIRED';
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "envelopeExpirationPeriod" JSONB;
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "envelopeExpirationPeriod" JSONB;
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "expirationNotifiedAt" TIMESTAMP(3),
ADD COLUMN "expiresAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "envelopeExpirationPeriod" JSONB;
-- CreateIndex
CREATE INDEX "Recipient_expiresAt_idx" ON "Recipient"("expiresAt");
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "RateLimit" (
"key" TEXT NOT NULL,
"action" TEXT NOT NULL,
"bucket" TIMESTAMP(3) NOT NULL,
"count" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("key","action","bucket")
);
-- CreateIndex
CREATE INDEX "RateLimit_createdAt_idx" ON "RateLimit"("createdAt");
+43 -21
View File
@@ -172,6 +172,7 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED
DOCUMENT_REJECTED
DOCUMENT_CANCELLED
RECIPIENT_EXPIRED
}
model Webhook {
@@ -500,7 +501,7 @@ enum DocumentDistributionMethod {
NONE
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
model DocumentMeta {
id String @id @default(cuid())
subject String?
@@ -522,6 +523,8 @@ model DocumentMeta {
emailReplyTo String?
emailId String?
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
envelope Envelope?
}
@@ -569,29 +572,32 @@ enum RecipientRole {
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
model Recipient {
id Int @id @default(autoincrement())
envelopeId String
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
documentDeletedAt DateTime?
expired DateTime?
signedAt DateTime?
authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema)
signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.")
rejectionReason String?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
fields Field[]
signatures Signature[]
id Int @id @default(autoincrement())
envelopeId String
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
documentDeletedAt DateTime?
expired DateTime? // deprecated Not in use. To be removed in a future migration.
expiresAt DateTime?
expirationNotifiedAt DateTime?
signedAt DateTime?
authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema)
signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.")
rejectionReason String?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
fields Field[]
signatures Signature[]
@@index([token])
@@index([email])
@@index([envelopeId])
@@index([signedAt])
@@index([expiresAt])
}
enum FieldType {
@@ -808,7 +814,7 @@ enum OrganisationMemberInviteStatus {
DECLINED
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';"])
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
model OrganisationGlobalSettings {
id String @id
organisation Organisation?
@@ -840,11 +846,13 @@ model OrganisationGlobalSettings {
brandingUrl String @default("")
brandingCompanyDetails String @default("")
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
// AI features settings.
aiFeaturesEnabled Boolean @default(false)
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';"])
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"])
model TeamGlobalSettings {
id String @id
team Team?
@@ -877,6 +885,8 @@ model TeamGlobalSettings {
brandingUrl String?
brandingCompanyDetails String?
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
// AI features settings.
aiFeaturesEnabled Boolean?
}
@@ -1076,3 +1086,15 @@ model Counter {
id String @id
value Int
}
model RateLimit {
key String
action String
bucket DateTime
count Int @default(1)
createdAt DateTime @default(now())
@@id([key, action, bucket])
@@index([createdAt])
}
@@ -1,5 +1,4 @@
import { adminSuperDeleteDocument } from '@documenso/lib/server-only/admin/admin-super-delete-document';
import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email';
import { adminProcedure } from '../trpc';
import {
@@ -19,10 +18,9 @@ export const deleteDocumentRoute = adminProcedure
},
});
await sendDeleteEmail({ envelopeId: id, reason });
await adminSuperDeleteDocument({
envelopeId: id,
reason,
requestMetadata: ctx.metadata.requestMetadata,
});
});
@@ -2,8 +2,10 @@ import { EnvelopeType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { DateTime } from 'luxon';
import { jobs } from '@documenso/lib/jobs/client';
import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants';
import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { request2FAEmailRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
@@ -21,7 +23,12 @@ export const accessAuthRequest2FAEmailRoute = procedure
try {
const { token } = input;
const user = ctx.user;
const rateLimitResult = await request2FAEmailRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
// Get document and recipient by token
const envelope = await prisma.envelope.findFirst({
@@ -63,18 +70,14 @@ export const accessAuthRequest2FAEmailRoute = procedure
});
}
// if (user && recipient.email !== user.email) {
// throw new TRPCError({
// code: 'UNAUTHORIZED',
// message: 'User does not match recipient',
// });
// }
const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES });
await send2FATokenEmail({
token,
envelopeId: envelope.id,
await jobs.triggerJob({
name: 'send.2fa.token.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
return {
@@ -1,4 +1,6 @@
import { linkOrganisationAccount } from '@documenso/ee/server-only/lib/link-organisation-account';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { linkOrgAccountRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { procedure } from '../trpc';
import {
@@ -15,6 +17,13 @@ export const linkOrganisationAccountRoute = procedure
.mutation(async ({ input, ctx }) => {
const { token } = input;
const rateLimitResult = await linkOrgAccountRateLimit.check({
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
await linkOrganisationAccount({
token,
requestMeta: ctx.metadata.requestMetadata,

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