diff --git a/packages/email/template-components/template-document-recipient-signed.tsx b/packages/email/template-components/template-document-recipient-signed.tsx
new file mode 100644
index 000000000..88d6bb1d4
--- /dev/null
+++ b/packages/email/template-components/template-document-recipient-signed.tsx
@@ -0,0 +1,56 @@
+import { Trans } from '@lingui/macro';
+
+import { Column, Img, Section, Text } from '../components';
+import { TemplateDocumentImage } from './template-document-image';
+
+export interface TemplateDocumentRecipientSignedProps {
+ documentName: string;
+ recipientName: string;
+ recipientEmail: string;
+ assetBaseUrl: string;
+}
+
+export const TemplateDocumentRecipientSigned = ({
+ documentName,
+ recipientName,
+ recipientEmail,
+ assetBaseUrl,
+}: TemplateDocumentRecipientSignedProps) => {
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ const recipientReference = recipientName || recipientEmail;
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Completed
+
+
+
+
+
+
+ {recipientReference} has signed "{documentName}"
+
+
+
+
+ {recipientReference} has completed signing the document.
+
+
+ >
+ );
+};
+
+export default TemplateDocumentRecipientSigned;
diff --git a/packages/email/templates/document-recipient-signed.tsx b/packages/email/templates/document-recipient-signed.tsx
new file mode 100644
index 000000000..36d0c78c1
--- /dev/null
+++ b/packages/email/templates/document-recipient-signed.tsx
@@ -0,0 +1,70 @@
+import { msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+
+import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
+import { useBranding } from '../providers/branding';
+import { TemplateDocumentRecipientSigned } from '../template-components/template-document-recipient-signed';
+import { TemplateFooter } from '../template-components/template-footer';
+
+export interface DocumentRecipientSignedEmailTemplateProps {
+ documentName?: string;
+ recipientName?: string;
+ recipientEmail?: string;
+ assetBaseUrl?: string;
+}
+
+export const DocumentRecipientSignedEmailTemplate = ({
+ documentName = 'Open Source Pledge.pdf',
+ recipientName = 'John Doe',
+ recipientEmail = 'lucas@documenso.com',
+ assetBaseUrl = 'http://localhost:3002',
+}: DocumentRecipientSignedEmailTemplateProps) => {
+ const { _ } = useLingui();
+ const branding = useBranding();
+
+ const recipientReference = recipientName || recipientEmail;
+
+ const previewText = msg`${recipientReference} has signed ${documentName}`;
+
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+
+
+
+ {branding.brandingEnabled && branding.brandingLogo ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DocumentRecipientSignedEmailTemplate;
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index 366c2f517..988208a0d 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -1,5 +1,6 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
+import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
@@ -19,6 +20,7 @@ export const jobsClient = new JobClient([
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
+ SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts
new file mode 100644
index 000000000..7fff47395
--- /dev/null
+++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts
@@ -0,0 +1,130 @@
+import { createElement } from 'react';
+
+import { msg } from '@lingui/macro';
+import { z } from 'zod';
+
+import { mailer } from '@documenso/email/mailer';
+import { DocumentRecipientSignedEmailTemplate } from '@documenso/email/templates/document-recipient-signed';
+import { prisma } from '@documenso/prisma';
+
+import { getI18nInstance } from '../../../client-only/providers/i18n.server';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
+import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
+import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
+import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
+import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
+import { type JobDefinition } from '../../client/_internal/job';
+
+const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.signed.email';
+
+const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
+ documentId: z.number(),
+ recipientId: z.number(),
+});
+
+export const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION = {
+ id: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID,
+ name: 'Send Recipient Signed Email',
+ version: '1.0.0',
+ trigger: {
+ name: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID,
+ schema: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const { documentId, recipientId } = payload;
+
+ const document = await prisma.document.findFirst({
+ where: {
+ id: documentId,
+ Recipient: {
+ some: {
+ id: recipientId,
+ },
+ },
+ },
+ include: {
+ Recipient: {
+ where: {
+ id: recipientId,
+ },
+ },
+ User: true,
+ documentMeta: true,
+ team: {
+ include: {
+ teamGlobalSettings: true,
+ },
+ },
+ },
+ });
+
+ if (!document) {
+ throw new Error('Document not found');
+ }
+
+ if (document.Recipient.length === 0) {
+ throw new Error('Document has no recipients');
+ }
+
+ const isRecipientSignedEmailEnabled = extractDerivedDocumentEmailSettings(
+ document.documentMeta,
+ ).recipientSigned;
+
+ if (!isRecipientSignedEmailEnabled) {
+ return;
+ }
+
+ const [recipient] = document.Recipient;
+ const { email: recipientEmail, name: recipientName } = recipient;
+ const { User: owner } = document;
+
+ const recipientReference = recipientName || recipientEmail;
+
+ // Don't send notification if the owner is the one who signed
+ if (owner.email === recipientEmail) {
+ return;
+ }
+
+ const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
+ const i18n = await getI18nInstance(document.documentMeta?.language);
+
+ const template = createElement(DocumentRecipientSignedEmailTemplate, {
+ documentName: document.title,
+ recipientName,
+ recipientEmail,
+ assetBaseUrl,
+ });
+
+ await io.runTask('send-recipient-signed-email', async () => {
+ const branding = document.team?.teamGlobalSettings
+ ? teamGlobalSettingsToBranding(document.team.teamGlobalSettings)
+ : undefined;
+
+ const [html, text] = await Promise.all([
+ renderEmailWithI18N(template, { lang: document.documentMeta?.language, branding }),
+ renderEmailWithI18N(template, {
+ lang: document.documentMeta?.language,
+ branding,
+ plainText: true,
+ }),
+ ]);
+
+ await mailer.sendMail({
+ to: {
+ name: owner.name ?? '',
+ address: owner.email,
+ },
+ from: {
+ name: FROM_NAME,
+ address: FROM_ADDRESS,
+ },
+ subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
+ html,
+ text,
+ });
+ });
+ },
+} as const satisfies JobDefinition<
+ typeof SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID,
+ z.infer
+>;
diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts
index 09e21b964..3f3338b13 100644
--- a/packages/lib/server-only/document/complete-document-with-token.ts
+++ b/packages/lib/server-only/document/complete-document-with-token.ts
@@ -8,8 +8,8 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
+ WebhookTriggerEvents,
} from '@documenso/prisma/client';
-import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth';
@@ -139,6 +139,14 @@ export const completeDocumentWithToken = async ({
});
});
+ await jobs.triggerJob({
+ name: 'send.recipient.signed.email',
+ payload: {
+ documentId: document.id,
+ recipientId: recipient.id,
+ },
+ });
+
const pendingRecipients = await prisma.recipient.findMany({
select: {
id: true,
diff --git a/packages/lib/types/document-email.ts b/packages/lib/types/document-email.ts
index b417b3924..7a0e07305 100644
--- a/packages/lib/types/document-email.ts
+++ b/packages/lib/types/document-email.ts
@@ -6,6 +6,7 @@ import { DocumentDistributionMethod } from '@documenso/prisma/client';
export enum DocumentEmailEvents {
RecipientSigningRequest = 'recipientSigningRequest',
RecipientRemoved = 'recipientRemoved',
+ RecipientSigned = 'recipientSigned',
DocumentPending = 'documentPending',
DocumentCompleted = 'documentCompleted',
DocumentDeleted = 'documentDeleted',
@@ -16,6 +17,7 @@ export const ZDocumentEmailSettingsSchema = z
.object({
recipientSigningRequest: z.boolean().default(true),
recipientRemoved: z.boolean().default(true),
+ recipientSigned: z.boolean().default(true),
documentPending: z.boolean().default(true),
documentCompleted: z.boolean().default(true),
documentDeleted: z.boolean().default(true),
@@ -25,6 +27,7 @@ export const ZDocumentEmailSettingsSchema = z
.catch(() => ({
recipientSigningRequest: true,
recipientRemoved: true,
+ recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
@@ -48,6 +51,7 @@ export const extractDerivedDocumentEmailSettings = (
return {
recipientSigningRequest: false,
recipientRemoved: false,
+ recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
diff --git a/packages/ui/components/document/document-email-checkboxes.tsx b/packages/ui/components/document/document-email-checkboxes.tsx
index 9ec1abdcd..5da263dc4 100644
--- a/packages/ui/components/document/document-email-checkboxes.tsx
+++ b/packages/ui/components/document/document-email-checkboxes.tsx
@@ -23,6 +23,45 @@ export const DocumentEmailCheckboxes = ({
}: DocumentEmailCheckboxesProps) => {
return (
+
+
+ onChange({ ...value, [DocumentEmailEvents.RecipientSigned]: Boolean(checked) })
+ }
+ />
+
+
+
+
- Document completed email to the owner
+ Document completed email