feat: add empty emails for envelopes (#2267)

This commit is contained in:
David Nguyen
2025-12-06 13:38:10 +11:00
committed by GitHub
parent f80aa4bf72
commit b51f562224
37 changed files with 1018 additions and 215 deletions
@@ -19,6 +19,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -129,18 +130,43 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo(
() =>
envelope.recipients.filter(
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[envelope.recipients, envelope.fields],
[recipientsWithIndex, envelope.fields],
);
/**
* List of recipients who must have an email due to having auth enabled.
*/
const recipientsMissingRequiredEmail = useMemo(() => {
return recipientsWithIndex.filter((recipient) => {
const auth = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
return (
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
);
});
}, [recipientsWithIndex, envelope.authOptions]);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -150,8 +176,12 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS';
}
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -444,7 +474,22 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
))
.with('MISSING_REQUIRED_EMAIL', () => (
<AlertDescription>
<Trans>The following recipients require an email address:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingRequiredEmail.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
@@ -24,7 +24,10 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z.string().min(1, { message: msg`Email is required`.id }),
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -21,6 +21,7 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
@@ -100,12 +101,29 @@ export function TemplateUseDialog({
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: open,
},
);
const envelopeItems = response?.data ?? [];
const generateDefaultFormValues = () => {
return {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: [],
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -124,7 +142,12 @@ export function TemplateUseDialog({
signingOrder: recipient.signingOrder ?? undefined,
};
}),
},
};
};
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: generateDefaultFormValues(),
});
const { replace, fields: localCustomDocumentData } = useFieldArray({
@@ -132,19 +155,6 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const envelopeItems = response?.data ?? [];
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
@@ -214,8 +224,8 @@ export function TemplateUseDialog({
});
useEffect(() => {
if (!open) {
form.reset();
if (open) {
form.reset(generateDefaultFormValues());
}
}, [open, form]);
@@ -322,7 +332,7 @@ export function TemplateUseDialog({
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
/>
</FormControl>
<FormMessage />
@@ -349,7 +359,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -358,7 +368,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this
@@ -378,7 +388,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -386,7 +396,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Create the document as pending and ready to sign.
@@ -432,7 +442,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -440,7 +450,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default
@@ -470,19 +480,19 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="text-foreground truncate text-sm font-medium">
<h4 className="truncate text-sm font-medium text-foreground">
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value ? (
<div>
<Trans>
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
email: ZRecipientEmailSchema,
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
/>
<div
className="text-muted-foreground text-sm"
className="text-sm text-muted-foreground"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
>
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p>{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -57,12 +57,13 @@ export type DocumentSigningCompleteDialogProps = {
name: string;
email: string;
};
directTemplatePayload?: {
recipientPayload?: {
name: string;
email: string;
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
disableNameInput?: boolean;
};
const ZNextSignerFormSchema = z.object({
@@ -89,10 +90,11 @@ export const DocumentSigningCompleteDialog = ({
recipient,
disabled = false,
allowDictateNextSigner = false,
directTemplatePayload,
recipientPayload,
defaultNextSigner,
buttonSize = 'lg',
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
@@ -113,11 +115,11 @@ export const DocumentSigningCompleteDialog = ({
},
});
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
});
@@ -145,16 +147,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
let directRecipient: { name: string; email: string } | undefined;
let recipientOverridePayload: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (recipientPayload && !recipientPayload.email) {
const isFormValid = await recipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
recipientOverridePayload = recipientForm.getValues();
}
// Check if 2FA is required
@@ -168,7 +170,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) {
const err = AppError.parseError(error);
@@ -222,7 +224,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
@@ -250,19 +252,19 @@ export const DocumentSigningCompleteDialog = ({
</DialogDescription>
</DialogHeader>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
</div>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{directTemplatePayload && !directTemplatePayload.email && (
<Form {...directRecipientForm}>
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
@@ -274,7 +276,7 @@ export const DocumentSigningCompleteDialog = ({
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked}
disabled={isNameLocked || disableNameInput}
/>
</FormControl>
@@ -284,7 +286,7 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
@@ -8,7 +8,6 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
@@ -28,6 +27,7 @@ import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -70,10 +70,7 @@ const ZEnvelopeRecipientsForm = z.object({
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -228,12 +225,13 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId',
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name &&
!signer.email &&
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
);
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
@@ -558,21 +556,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty.
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true;
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: nonEmptyRecipients,
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -736,9 +720,7 @@ export const EnvelopeEditorRecipientForm = () => {
});
}
}}
disabled={
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
@@ -924,7 +906,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -978,7 +960,7 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
@@ -1,5 +1,7 @@
import { useCallback, useState } from 'react';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
@@ -39,8 +41,15 @@ export const EnvelopeRecipientSelector = ({
fields,
align = 'start',
}: EnvelopeRecipientSelectorProps) => {
const { i18n } = useLingui();
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
@@ -49,7 +58,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -59,16 +68,12 @@ export const EnvelopeRecipientSelector = ({
className,
)}
>
{selectedRecipient?.email && (
{selectedRecipient && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
{getRecipientLabel(selectedRecipient)}
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -105,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
@@ -154,6 +159,11 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients],
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -162,21 +172,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -205,18 +215,12 @@ export const EnvelopeRecipientSelectorCommand = ({
}}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
{getRecipientLabel(recipient)}
</span>
<div className="ml-auto flex items-center justify-center">
@@ -234,7 +238,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
@@ -250,3 +254,22 @@ export const EnvelopeRecipientSelectorCommand = ({
</Command>
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name) {
return recipient.name;
}
if (recipient.email) {
return recipient.email;
}
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
const index = recipients.indexOf(recipient);
return i18n._(msg`Recipient ${index + 1}`);
};
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
@@ -205,21 +207,30 @@ export const EnvelopeSignerCompleteDialog = () => {
}
};
const directTemplatePayload = useMemo(() => {
const recipientPayload = useMemo(() => {
if (!isDirectTemplate) {
return;
return {
name:
recipient.name ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};
}
return {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate]);
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
recipientPayload={recipientPayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
@@ -230,6 +241,7 @@ export const EnvelopeSignerCompleteDialog = () => {
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -6,6 +6,7 @@ import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { prisma } from '@documenso/prisma';
import {
DocumentDistributionMethod,
@@ -23,7 +24,9 @@ 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 type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@@ -558,4 +561,543 @@ test.describe('API V2 Envelopes', () => {
userEmail: userA.email,
});
});
test.describe('Empty recipient tests', () => {
test('Create template envelope with empty email recipient', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Empty Email Recipient',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Create recipient with empty email
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: response.id,
data: [
{
email: '',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
expect(createRecipientsRes.status()).toBe(200);
const recipientsResponse = await createRecipientsRes.json();
const recipient = recipientsResponse.data[0];
expect(recipient.email).toBe('');
expect(recipient.name).toBe('Test Recipient');
// Get envelope items to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create field for the recipient with empty email
const createFieldsRequest = {
envelopeId: response.id,
data: [
{
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
expect(createFieldsRes.status()).toBe(200);
});
test('Create document envelope with empty email recipient', async ({ request }) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Empty Email Recipient',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Create recipient with empty email
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: response.id,
data: [
{
email: '',
name: 'Document Recipient No Email',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipient = recipientsResponse.data[0];
expect(recipient.email).toBe('');
expect(recipient.name).toBe('Document Recipient No Email');
});
test('Update recipient to have empty email', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Update Recipient Email Test',
recipients: [
{
email: userA.email,
name: 'Test User',
role: RecipientRole.SIGNER,
},
],
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// Get the envelope to get recipient ID
const getRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getRes.json();
const recipientId = envelope.recipients[0].id;
// Update recipient to have empty email
const updateRequest: TUpdateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
id: recipientId,
email: '',
name: 'Updated Name No Email',
},
],
};
const updateRes = await request.post(`${baseUrl}/envelope/recipient/update-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: updateRequest,
});
expect(updateRes.ok()).toBeTruthy();
const updateResponse = await updateRes.json();
const updatedRecipient = updateResponse.data[0];
expect(updatedRecipient.email).toBe('');
expect(updatedRecipient.name).toBe('Updated Name No Email');
});
test('Mixed recipients with and without emails', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Mixed Recipients Test',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TCreateEnvelopeResponse;
// Create multiple recipients, some with email, some without
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: response.id,
data: [
{
email: userA.email,
name: 'Recipient With Email',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: '',
name: 'Recipient Without Email 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: userB.email,
name: 'Another With Email',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
{
email: '',
name: 'Recipient Without Email 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipients = recipientsResponse.data;
expect(recipients.length).toBe(4);
expect(recipients[0].email).toBe(userA.email.toLowerCase());
expect(recipients[1].email).toBe('');
expect(recipients[2].email).toBe(userB.email.toLowerCase());
expect(recipients[3].email).toBe('');
// Get envelope to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create fields for all recipients including those without emails
const createFieldsRequest = {
envelopeId: response.id,
data: recipients.map((recipient, index) => ({
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 0 + index,
width: 50,
height: 50,
})),
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
});
test('Distribute envelope with empty email recipients', async ({ request }) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document for Distribution with Empty Email',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// Create recipients with empty emails
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: '',
name: 'Recipient One',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: '',
name: 'Recipient Two',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipients = recipientsResponse.data;
// Get envelope to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create fields for recipients
const createFieldsRequest = {
envelopeId: createResponse.id,
data: recipients.map((recipient, index) => ({
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 0 + index,
width: 50,
height: 50,
})),
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
// Distribute the envelope
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: createResponse.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
const distributeResponse = await distributeRes.json();
expect(distributeResponse.success).toBe(true);
expect(distributeResponse.id).toBe(createResponse.id);
expect(distributeResponse.recipients).toHaveLength(2);
// Verify recipients have empty emails and signing URLs
expect(distributeResponse.recipients[0].email).toBe('');
expect(distributeResponse.recipients[0].signingUrl).toBeTruthy();
expect(distributeResponse.recipients[1].email).toBe('');
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
});
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
request,
}) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Auth Requirements',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// Create recipient with empty email and TWO_FACTOR_AUTH action auth
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: '',
name: 'Recipient With Auth',
role: RecipientRole.SIGNER,
accessAuth: [DocumentAccessAuth.TWO_FACTOR_AUTH],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipient = recipientsResponse.data[0];
// Get envelope to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create field for the recipient
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
// Try to distribute the envelope - should fail
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: createResponse.id,
},
});
// Expect distribution to fail
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('requires an email');
});
});
});
@@ -5,6 +5,7 @@ import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/cli
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -77,7 +78,8 @@ export const run = async ({
const recipientsToNotify = envelope.recipients.filter(
(recipient) =>
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
recipient.signingStatus !== SigningStatus.REJECTED,
recipient.signingStatus !== SigningStatus.REJECTED &&
isRecipientEmailValidForSending(recipient),
);
await io.runTask('send-cancellation-emails', async () => {
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
@@ -79,8 +80,8 @@ export const run = async ({
const recipientReference = recipientName || recipientEmail;
// Don't send notification if the owner is the one who signed
if (owner.email === recipientEmail) {
// Don't send notification if the owner is the one who signed.
if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -6,6 +6,7 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -85,36 +86,38 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
if (isRecipientEmailValidForSending(recipient)) {
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
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}" - Rejection Confirmed`),
html,
text,
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
html,
text,
});
});
});
}
// Send notification email to document owner
await io.runTask('send-owner-notification-email', async () => {
@@ -12,6 +12,7 @@ import {
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -177,31 +178,33 @@ export const run = async ({
includeSenderDetails: settings.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
if (isRecipientEmailValidForSending(recipient)) {
await io.runTask('send-signing-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: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
});
});
});
}
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
@@ -5,6 +5,7 @@ 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';
@@ -69,6 +70,12 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
});
}
if (!isRecipientEmailValidForSending(recipient)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Recipient is missing email address',
});
}
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
envelopeId,
email: recipient.email,
@@ -14,6 +14,7 @@ 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';
@@ -64,14 +65,18 @@ export const adminSuperDeleteDocument = async ({
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 &&
envelope.recipients.length > 0 &&
recipientsToNotify.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
envelope.recipients.map(async (recipient) => {
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
@@ -2,6 +2,7 @@ import {
DocumentSigningOrder,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
SendStatus,
SigningStatus,
@@ -43,6 +44,14 @@ export type CompleteDocumentWithTokenOptions = {
email: string;
name: string;
};
/**
* Override the recipient information. This will only work if the recipient
* does not have a name or email set.
*/
recipientOverride?: {
email?: string;
name?: string;
};
};
export const completeDocumentWithToken = async ({
@@ -52,6 +61,7 @@ export const completeDocumentWithToken = async ({
accessAuthOptions,
requestMetadata,
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
@@ -116,6 +126,35 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
}
let recipientName = recipient.name;
let recipientEmail = recipient.email;
// Only trim the name if it's been derived.
if (!recipientName) {
recipientName = (
recipientOverride?.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
''
).trim();
}
// Only trim the email if it's been derived.
if (!recipient.email) {
recipientEmail = (
recipientOverride?.email ||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
''
)
.trim()
.toLowerCase();
}
if (!recipientEmail) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Recipient email is required',
});
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
@@ -129,6 +168,12 @@ export const completeDocumentWithToken = async ({
});
}
if (!recipient.email.trim()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
});
}
const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA',
documentAuthOptions: envelope.authOptions,
@@ -176,9 +221,43 @@ export const completeDocumentWithToken = async ({
data: {
signingStatus: SigningStatus.SIGNED,
signedAt: new Date(),
name: recipientName,
email: recipientEmail,
},
});
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: recipient.name,
to: recipientName,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: recipient.email,
to: recipientEmail,
},
],
},
}),
});
}
const authOptions = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
@@ -189,13 +268,13 @@ export const completeDocumentWithToken = async ({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientEmail: recipientEmail,
recipientName: recipientName,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
@@ -247,8 +326,8 @@ export const completeDocumentWithToken = async ({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
@@ -21,6 +21,7 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
@@ -209,7 +210,7 @@ const handleDocumentOwnerDelete = async ({
// Send cancellation emails to recipients.
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
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';
@@ -118,7 +119,7 @@ export const resendDocument = async ({
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -16,6 +16,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
@@ -176,8 +177,12 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
return;
}
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
await Promise.all(
envelope.recipients.map(async (recipient) => {
recipientsToNotify.map(async (recipient) => {
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
@@ -35,8 +35,10 @@ import {
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -128,6 +130,24 @@ export const sendDocument = async ({
);
}
// Validate that recipients with auth requirements have a valid email.
envelope.recipients.forEach((recipient) => {
const auth = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
if (
recipient.role !== RecipientRole.CC &&
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) &&
!isRecipientEmailValidForSending(recipient)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
});
}
});
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -69,6 +70,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
const { email, name } = recipient;
// Skip sending email if recipient has no email address
if (!isRecipientEmailValidForSending(recipient)) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentPendingEmailTemplate, {
@@ -14,7 +14,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
@@ -142,7 +142,8 @@ export const deleteEnvelopeRecipient = async ({
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@@ -28,7 +28,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { canRecipientBeModified } from '../../utils/recipients';
import { canRecipientBeModified, 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';
@@ -294,10 +294,14 @@ export const setDocumentRecipients = async ({
envelope.documentMeta,
).recipientRemoved;
// Send emails to deleted recipients.
// Send emails to deleted recipients who have emails.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
+11
View File
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
@@ -110,3 +111,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
documentId: true,
templateId: true,
});
export const ZRecipientEmailSchema = z.union([
z.literal(''),
z
.string()
.trim()
.toLowerCase()
.email({ message: msg`Invalid email`.id })
.max(254),
]);
+5
View File
@@ -1,5 +1,6 @@
import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { extractLegacyIds } from '../universal/id';
@@ -58,3 +59,7 @@ export const mapRecipientToLegacyRecipient = (
...legacyId,
};
};
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
return z.string().email().safeParse(recipient.email).success;
};
@@ -22,6 +22,7 @@ import {
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZDocumentTitleSchema } from '../document-router/schema';
@@ -30,7 +31,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
documentDataId: z.string(),
recipients: z.array(
z.object({
email: z.union([z.string().length(0), z.string().email()]),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -1,4 +1,4 @@
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -21,22 +21,11 @@ import {
ZFieldPageYSchema,
ZFieldWidthSchema,
} from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZDocumentTitleSchema } from '../document-router/schema';
const ZFieldSchema = z.object({
id: z.number().optional(),
type: z.nativeEnum(FieldType),
pageNumber: ZFieldPageNumberSchema,
pageX: ZFieldPageXSchema,
pageY: ZFieldPageYSchema,
width: ZFieldWidthSchema,
height: ZFieldHeightSchema,
fieldMeta: ZFieldMetaSchema.optional(),
envelopeItemId: z.string(),
});
export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
templateId: z.number(),
title: ZDocumentTitleSchema.optional(),
@@ -44,7 +33,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.union([z.string().length(0), z.string().email()]),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -24,8 +24,8 @@ import {
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
} from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import { ZCreateEnvelopeRecipientSchema } from './envelope-recipients/create-envelope-recipients.types';
export const createEnvelopeMeta: TrpcRouteMeta = {
openapi: {
@@ -54,7 +54,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
ZCreateEnvelopeRecipientSchema.extend({
fields: ZEnvelopeFieldAndMetaSchema.and(
z.object({
identifier: z
@@ -1,8 +1,15 @@
import { RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
import {
ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import {
ZEnvelopeRecipientLiteSchema,
ZRecipientEmailSchema,
} from '@documenso/lib/types/recipient';
import { ZCreateRecipientSchema } from '../../recipient-router/schema';
import type { TrpcRouteMeta } from '../../trpc';
export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
@@ -15,9 +22,18 @@ export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
},
};
export const ZCreateEnvelopeRecipientSchema = z.object({
email: ZRecipientEmailSchema,
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
});
export const ZCreateEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
data: ZCreateRecipientSchema.array(),
data: ZCreateEnvelopeRecipientSchema.array(),
});
export const ZCreateEnvelopeRecipientsResponseSchema = z.object({
@@ -1,8 +1,12 @@
import { RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import {
ZRecipientAccessAuthTypesSchema,
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import { ZUpdateRecipientSchema } from '../../recipient-router/schema';
import type { TrpcRouteMeta } from '../../trpc';
export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
@@ -15,9 +19,19 @@ export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
},
};
export const ZUpdateEnvelopeRecipientSchema = z.object({
id: z.number().describe('The ID of the recipient to update.'),
email: ZRecipientEmailSchema.optional(),
name: z.string().max(255).optional(),
role: z.nativeEnum(RecipientRole).optional(),
signingOrder: z.number().optional(),
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
});
export const ZUpdateEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
data: ZUpdateRecipientSchema.array(),
data: ZUpdateEnvelopeRecipientSchema.array(),
});
export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({
@@ -2,7 +2,7 @@ import { EnvelopeType, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(),
@@ -10,7 +10,7 @@ export const ZSetEnvelopeRecipientsRequestSchema = z.object({
recipients: z.array(
z.object({
id: z.number().optional(),
email: z.string().toLowerCase().email().min(1).max(254),
email: ZRecipientEmailSchema,
name: z.string().max(255),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -16,6 +16,7 @@ import {
} from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc';
@@ -40,7 +41,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
.array(
z.object({
id: z.number().describe('The ID of the recipient in the template.'),
email: z.string().email().max(254),
email: ZRecipientEmailSchema,
name: z.string().max(255).optional(),
signingOrder: z.number().optional(),
}),
@@ -561,7 +561,7 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => {
const { token, documentId, accessAuthOptions, nextSigner } = input;
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
ctx.logger.info({
input: {
@@ -577,6 +577,7 @@ export const recipientRouter = router({
},
accessAuthOptions,
nextSigner,
recipientOverride,
userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata,
});
@@ -171,6 +171,12 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
name: z.string().min(1).max(255),
})
.optional(),
recipientOverride: z
.object({
email: z.string().trim().toLowerCase().email().max(254).optional(),
name: z.string().max(255).optional(),
})
.optional(),
});
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
@@ -22,6 +22,7 @@ import {
} from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import {
ZTemplateLiteSchema,
@@ -102,7 +103,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
.array(
z.object({
id: z.number().describe('The ID of the recipient in the template.'),
email: z.string().email().max(254),
email: ZRecipientEmailSchema,
name: z.string().max(255).optional(),
}),
)