diff --git a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx
index 895eed438..081f22348 100644
--- a/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx
+++ b/apps/web/src/app/(dashboard)/templates/[id]/template-page-view.tsx
@@ -14,6 +14,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type';
+import { TemplateBulkSendDialog } from '~/components/templates/template-bulk-send-dialog';
import { DataTableActionDropdown } from '../data-table-action-dropdown';
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
@@ -111,6 +112,8 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
+
+
+ }
+ />
+
setDeleteDialogOpen(true)}
diff --git a/apps/web/src/components/templates/template-bulk-send-dialog.tsx b/apps/web/src/components/templates/template-bulk-send-dialog.tsx
new file mode 100644
index 000000000..a21b20c7c
--- /dev/null
+++ b/apps/web/src/components/templates/template-bulk-send-dialog.tsx
@@ -0,0 +1,275 @@
+'use client';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+import { File as FileIcon, Upload, X } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+
+import { trpc } from '@documenso/trpc/react';
+import { Button } from '@documenso/ui/primitives/button';
+import { Checkbox } from '@documenso/ui/primitives/checkbox';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@documenso/ui/primitives/dialog';
+import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
+import { useToast } from '@documenso/ui/primitives/use-toast';
+
+import { useOptionalCurrentTeam } from '~/providers/team';
+
+const ZBulkSendFormSchema = z.object({
+ file: z.instanceof(File),
+ sendImmediately: z.boolean().default(false),
+});
+
+type TBulkSendFormSchema = z.infer;
+
+export type TemplateBulkSendDialogProps = {
+ templateId: number;
+ recipients: Array<{ email: string; name?: string | null }>;
+ trigger?: React.ReactNode;
+ onSuccess?: () => void;
+};
+
+export const TemplateBulkSendDialog = ({
+ templateId,
+ recipients,
+ trigger,
+ onSuccess,
+}: TemplateBulkSendDialogProps) => {
+ const { _ } = useLingui();
+ const { toast } = useToast();
+
+ const team = useOptionalCurrentTeam();
+
+ const form = useForm({
+ resolver: zodResolver(ZBulkSendFormSchema),
+ defaultValues: {
+ sendImmediately: false,
+ },
+ });
+
+ const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
+
+ const onDownloadTemplate = () => {
+ const headers = recipients.flatMap((_, index) => [
+ `recipient_${index + 1}_email`,
+ `recipient_${index + 1}_name`,
+ ]);
+
+ const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
+
+ const csv = [headers.join(','), exampleRow.join(',')].join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = window.URL.createObjectURL(blob);
+
+ const a = Object.assign(document.createElement('a'), {
+ href: url,
+ download: 'template.csv',
+ });
+
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ };
+
+ const onSubmit = async (values: TBulkSendFormSchema) => {
+ try {
+ const csv = await values.file.text();
+
+ await uploadBulkSend({
+ templateId,
+ teamId: team?.id,
+ csv: csv,
+ sendImmediately: values.sendImmediately,
+ });
+
+ toast({
+ title: _(msg`Success`),
+ description: _(
+ msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
+ ),
+ });
+
+ form.reset();
+ onSuccess?.();
+ } catch (err) {
+ console.error(err);
+
+ toast({
+ title: 'Error',
+ description: 'Failed to upload CSV. Please check the file format and try again.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/package-lock.json b/package-lock.json
index 9ae01d2d5..03988dc46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13836,6 +13836,12 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
+ "node_modules/csv-parse": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
+ "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
+ "license": "MIT"
+ },
"node_modules/cytoscape": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz",
@@ -35031,6 +35037,7 @@
"@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
+ "csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"luxon": "^3.4.0",
diff --git a/packages/email/templates/bulk-send-complete.tsx b/packages/email/templates/bulk-send-complete.tsx
new file mode 100644
index 000000000..52c8416fd
--- /dev/null
+++ b/packages/email/templates/bulk-send-complete.tsx
@@ -0,0 +1,91 @@
+import { Trans, msg } from '@lingui/macro';
+import { useLingui } from '@lingui/react';
+
+import { Body, Container, Head, Html, Preview, Section, Text } from '../components';
+import { TemplateFooter } from '../template-components/template-footer';
+
+export interface BulkSendCompleteEmailProps {
+ userName: string;
+ templateName: string;
+ totalProcessed: number;
+ successCount: number;
+ failedCount: number;
+ errors: string[];
+ assetBaseUrl?: string;
+}
+
+export const BulkSendCompleteEmail = ({
+ userName,
+ templateName,
+ totalProcessed,
+ successCount,
+ failedCount,
+ errors,
+}: BulkSendCompleteEmailProps) => {
+ const { _ } = useLingui();
+
+ return (
+
+
+ {_(msg`Bulk send operation complete for template "${templateName}"`)}
+
+
+
+
+
+ Hi {userName},
+
+
+
+ Your bulk send operation for template "{templateName}" has completed.
+
+
+
+ Summary:
+
+
+
+ -
+ Total rows processed: {totalProcessed}
+
+ -
+ Successfully created: {successCount}
+
+ -
+ Failed: {failedCount}
+
+
+
+ {failedCount > 0 && (
+
+
+ The following errors occurred:
+
+
+
+ {errors.map((error, index) => (
+ -
+ {error}
+
+ ))}
+
+
+ )}
+
+
+
+ You can view the created documents in your dashboard under the "Documents created
+ from template" section.
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index 988208a0d..6b0cbe693 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -6,6 +6,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
+import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
/**
@@ -21,6 +22,7 @@ export const jobsClient = new JobClient([
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
+ BULK_SEND_TEMPLATE_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;
diff --git a/packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts b/packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts
new file mode 100644
index 000000000..a16def8cf
--- /dev/null
+++ b/packages/lib/jobs/definitions/emails/send-bulk-complete-email.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+
+import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
+import { type JobDefinition } from '../../client/_internal/job';
+
+const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID = 'send.bulk.complete.email';
+
+const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
+ userId: z.number(),
+ templateId: z.number(),
+ templateName: z.string(),
+ totalProcessed: z.number(),
+ successCount: z.number(),
+ failedCount: z.number(),
+ errors: z.array(z.string()),
+ requestMetadata: ZRequestMetadataSchema.optional(),
+});
+
+export type TSendBulkCompleteEmailJobDefinition = z.infer<
+ typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA
+>;
+
+export const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION = {
+ id: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
+ name: 'Send Bulk Complete Email',
+ version: '1.0.0',
+ trigger: {
+ name: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
+ schema: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./send-bulk-complete-email.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
+ TSendBulkCompleteEmailJobDefinition
+>;
diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts
new file mode 100644
index 000000000..bce18752f
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/bulk-send-template.handler.ts
@@ -0,0 +1,208 @@
+import { createElement } from 'react';
+
+import { msg } from '@lingui/macro';
+import { parse } from 'csv-parse/sync';
+import { z } from 'zod';
+
+import { mailer } from '@documenso/email/mailer';
+import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
+import { sendDocument } from '@documenso/lib/server-only/document/send-document';
+import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
+import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
+import { prisma } from '@documenso/prisma';
+import type { TeamGlobalSettings } from '@documenso/prisma/client';
+
+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 { AppError } from '../../../errors/app-error';
+import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
+import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
+
+const ZRecipientRowSchema = z.object({
+ name: z.string().optional(),
+ email: z.union([
+ z.string().email({ message: 'Value must be a valid email or empty string' }),
+ z.string().max(0, { message: 'Value must be a valid email or empty string' }),
+ ]),
+});
+
+export const run = async ({
+ payload,
+ io,
+}: {
+ payload: TBulkSendTemplateJobDefinition;
+ io: JobRunIO;
+}) => {
+ const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
+
+ const template = await getTemplateById({
+ id: templateId,
+ userId,
+ teamId,
+ });
+
+ if (!template) {
+ throw new Error('Template not found');
+ }
+
+ const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
+
+ if (rows.length > 100) {
+ throw new Error('Maximum 100 rows allowed per upload');
+ }
+
+ const { recipients } = template;
+
+ // Validate CSV structure
+ const csvHeaders = Object.keys(rows[0]);
+ const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
+
+ for (const header of requiredHeaders) {
+ if (!csvHeaders.includes(header)) {
+ throw new Error(`Missing required column: ${header}`);
+ }
+ }
+
+ const user = await prisma.user.findFirstOrThrow({
+ where: {
+ id: userId,
+ },
+ select: {
+ email: true,
+ name: true,
+ },
+ });
+
+ const results = {
+ success: 0,
+ failed: 0,
+ errors: Array(),
+ };
+
+ // Process each row
+ for (const [rowIndex, row] of rows.entries()) {
+ try {
+ for (const [recipientIndex] of recipients.entries()) {
+ const nameKey = `recipient_${recipientIndex + 1}_name`;
+ const emailKey = `recipient_${recipientIndex + 1}_email`;
+
+ const parsed = ZRecipientRowSchema.safeParse({
+ name: row[nameKey],
+ email: row[emailKey],
+ });
+
+ if (!parsed.success) {
+ throw new Error(
+ `Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
+ );
+ }
+ }
+
+ const document = await io.runTask(`create-document-${rowIndex}`, async () => {
+ return await createDocumentFromTemplate({
+ templateId: template.id,
+ userId,
+ teamId,
+ recipients: recipients.map((recipient, index) => {
+ return {
+ id: recipient.id,
+ email: row[`recipient_${index + 1}_email`] || recipient.email,
+ name: row[`recipient_${index + 1}_name`] || recipient.name,
+ role: recipient.role,
+ signingOrder: recipient.signingOrder,
+ };
+ }),
+ requestMetadata: {
+ source: 'app',
+ auth: 'session',
+ requestMetadata: requestMetadata || {},
+ },
+ });
+ });
+
+ if (sendImmediately) {
+ await io.runTask(`send-document-${rowIndex}`, async () => {
+ await sendDocument({
+ documentId: document.id,
+ userId,
+ teamId,
+ requestMetadata: {
+ source: 'app',
+ auth: 'session',
+ requestMetadata: requestMetadata || {},
+ },
+ }).catch((err) => {
+ console.error(err);
+
+ throw new AppError('DOCUMENT_SEND_FAILED');
+ });
+ });
+ }
+
+ results.success += 1;
+ } catch (error) {
+ results.failed += 1;
+
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+
+ results.errors.push(`Row ${rowIndex + 1}: Was unable to be processed - ${errorMessage}`);
+ }
+ }
+
+ await io.runTask('send-completion-email', async () => {
+ const completionTemplate = createElement(BulkSendCompleteEmail, {
+ userName: user.name || user.email,
+ templateName: template.title,
+ totalProcessed: rows.length,
+ successCount: results.success,
+ failedCount: results.failed,
+ errors: results.errors,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ });
+
+ let teamGlobalSettings: TeamGlobalSettings | undefined | null;
+
+ if (template.teamId) {
+ teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({
+ where: {
+ teamId: template.teamId,
+ },
+ });
+ }
+
+ const branding = teamGlobalSettings
+ ? teamGlobalSettingsToBranding(teamGlobalSettings)
+ : undefined;
+
+ const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage);
+
+ const [html, text] = await Promise.all([
+ renderEmailWithI18N(completionTemplate, {
+ lang: teamGlobalSettings?.documentLanguage,
+ branding,
+ }),
+ renderEmailWithI18N(completionTemplate, {
+ lang: teamGlobalSettings?.documentLanguage,
+ branding,
+ plainText: true,
+ }),
+ ]);
+
+ await mailer.sendMail({
+ to: {
+ name: user.name || '',
+ address: user.email,
+ },
+ from: {
+ name: FROM_NAME,
+ address: FROM_ADDRESS,
+ },
+ subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
+ html,
+ text,
+ });
+ });
+};
diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.ts
new file mode 100644
index 000000000..c101e3c40
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/bulk-send-template.ts
@@ -0,0 +1,37 @@
+import { z } from 'zod';
+
+import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
+import { type JobDefinition } from '../../client/_internal/job';
+
+const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
+
+const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
+ userId: z.number(),
+ teamId: z.number().optional(),
+ templateId: z.number(),
+ csvContent: z.string(),
+ sendImmediately: z.boolean(),
+ requestMetadata: ZRequestMetadataSchema.optional(),
+});
+
+export type TBulkSendTemplateJobDefinition = z.infer<
+ typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA
+>;
+
+export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
+ id: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
+ name: 'Bulk Send Template',
+ version: '1.0.0',
+ trigger: {
+ name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
+ schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./bulk-send-template.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
+ TBulkSendTemplateJobDefinition
+>;
diff --git a/packages/lib/package.json b/packages/lib/package.json
index 3ab271e5b..cc74d8621 100644
--- a/packages/lib/package.json
+++ b/packages/lib/package.json
@@ -40,6 +40,7 @@
"@trigger.dev/sdk": "^2.3.18",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
+ "csv-parse": "^5.6.0",
"inngest": "^3.19.13",
"kysely": "^0.26.3",
"luxon": "^3.4.0",
diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts
index 0be0e89a1..56f319634 100644
--- a/packages/trpc/server/template-router/router.ts
+++ b/packages/trpc/server/template-router/router.ts
@@ -1,5 +1,8 @@
+import { TRPCError } from '@trpc/server';
+
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { jobs } from '@documenso/lib/jobs/client';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import {
@@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
import {
+ ZBulkSendTemplateMutationSchema,
ZCreateDocumentFromDirectTemplateRequestSchema,
ZCreateDocumentFromTemplateRequestSchema,
ZCreateDocumentFromTemplateResponseSchema,
@@ -414,4 +418,48 @@ export const templateRouter = router({
userId,
});
}),
+
+ /**
+ * @private
+ */
+ uploadBulkSend: authenticatedProcedure
+ .input(ZBulkSendTemplateMutationSchema)
+ .mutation(async ({ ctx, input }) => {
+ const { templateId, teamId, csv, sendImmediately } = input;
+ const { user } = ctx;
+
+ if (csv.length > 4 * 1024 * 1024) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'File size exceeds 4MB limit',
+ });
+ }
+
+ const template = await getTemplateById({
+ id: templateId,
+ teamId,
+ userId: user.id,
+ });
+
+ if (!template) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Template not found',
+ });
+ }
+
+ await jobs.triggerJob({
+ name: 'internal.bulk-send-template',
+ payload: {
+ userId: user.id,
+ teamId,
+ templateId,
+ csvContent: csv,
+ sendImmediately,
+ requestMetadata: ctx.metadata.requestMetadata,
+ },
+ });
+
+ return { success: true };
+ }),
});
diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts
index ee07946ee..78147fc6d 100644
--- a/packages/trpc/server/template-router/schema.ts
+++ b/packages/trpc/server/template-router/schema.ts
@@ -188,6 +188,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
+export const ZBulkSendTemplateMutationSchema = z.object({
+ templateId: z.number(),
+ teamId: z.number().optional(),
+ csv: z.string().min(1),
+ sendImmediately: z.boolean(),
+});
+
export type TCreateTemplateMutationSchema = z.infer;
export type TDuplicateTemplateMutationSchema = z.infer;
export type TDeleteTemplateMutationSchema = z.infer;
+export type TBulkSendTemplateMutationSchema = z.infer;