Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] e74c2c708c Initial plan 2025-12-02 08:13:23 +00:00
David Nguyen 7849ffbc2d fix: issue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 19:11:13 +11:00
David Nguyen 0490fd78b1 fix: issue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 19:05:34 +11:00
David Nguyen 16481bc34e feat: add empty emails for envelopes 2025-12-02 18:52:02 +11:00
34 changed files with 980 additions and 193 deletions
@@ -19,6 +19,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; 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 { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -129,18 +130,44 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod'); const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo( const recipientsMissingSignatureFields = useMemo(
() => () =>
envelope.recipients.filter( recipientsWithIndex.filter(
(recipient) => (recipient) =>
recipient.role === RecipientRole.SIGNER && recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some( !envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, (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(() => { const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) { if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES'; return 'MISSING_SIGNATURES';
@@ -150,8 +177,12 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS'; return 'MISSING_RECIPIENTS';
} }
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null; return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]); }, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => { const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try { try {
@@ -444,7 +475,22 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc"> <ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => ( {recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li> <li key={recipient.id}>
{recipient.email || recipient.name || `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 || `Recipient ${recipient.index + 1}`}
</li>
))} ))}
</ul> </ul>
</AlertDescription> </AlertDescription>
@@ -21,6 +21,7 @@ import {
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error'; 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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array( recipients: z.array(
z.object({ z.object({
id: z.number(), id: z.number(),
email: z.string().email(), email: ZRecipientEmailSchema,
name: z.string(), name: z.string(),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
}), }),
@@ -349,7 +350,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && ( {documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label <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" htmlFor="distributeDocument"
> >
<Trans>Send document</Trans> <Trans>Send document</Trans>
@@ -358,7 +359,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" /> <InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger> </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> <p>
<Trans> <Trans>
The document will be immediately sent to recipients if this The document will be immediately sent to recipients if this
@@ -378,7 +379,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && ( {documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label <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" htmlFor="distributeDocument"
> >
<Trans>Create as pending</Trans> <Trans>Create as pending</Trans>
@@ -386,7 +387,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button"> <TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" /> <InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger> </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> <p>
<Trans> <Trans>
Create the document as pending and ready to sign. Create the document as pending and ready to sign.
@@ -432,7 +433,7 @@ export function TemplateUseDialog({
}} }}
/> />
<label <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" htmlFor="useCustomDocument"
> >
<Trans>Upload custom document</Trans> <Trans>Upload custom document</Trans>
@@ -440,7 +441,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button"> <TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" /> <InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger> </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> <p>
<Trans> <Trans>
Upload a custom document to use instead of the template's default Upload a custom document to use instead of the template's default
@@ -470,19 +471,19 @@ export function TemplateUseDialog({
<FormControl> <FormControl>
<div <div
key={item.id} 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="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="text-primary h-5 w-5" /> <FileTextIcon className="h-5 w-5 text-primary" />
</div> </div>
</div> </div>
<div className="min-w-0 flex-1"> <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} {item.title}
</h4> </h4>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="mt-0.5 text-xs text-muted-foreground">
{field.value ? ( {field.value ? (
<div> <div>
<Trans> <Trans>
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDateFormatSchema, ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema, ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta'; } from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types'; import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration // Define the schema for configuration
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(), nativeId: z.number().optional(),
formId: z.string(), formId: z.string(),
name: 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']), role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
@@ -57,7 +57,7 @@ export type DocumentSigningCompleteDialogProps = {
name: string; name: string;
email: string; email: string;
}; };
directTemplatePayload?: { recipientPayload?: {
name: string; name: string;
email: string; email: string;
}; };
@@ -89,7 +89,7 @@ export const DocumentSigningCompleteDialog = ({
recipient, recipient,
disabled = false, disabled = false,
allowDictateNextSigner = false, allowDictateNextSigner = false,
directTemplatePayload, recipientPayload,
defaultNextSigner, defaultNextSigner,
buttonSize = 'lg', buttonSize = 'lg',
position, position,
@@ -113,11 +113,11 @@ export const DocumentSigningCompleteDialog = ({
}, },
}); });
const directRecipientForm = useForm<TDirectRecipientFormSchema>({ const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema), resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: { defaultValues: {
name: directTemplatePayload?.name ?? '', name: recipientPayload?.name ?? '',
email: directTemplatePayload?.email ?? '', email: recipientPayload?.email ?? '',
}, },
}); });
@@ -145,16 +145,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => { const onFormSubmit = async (data: TNextSignerFormSchema) => {
try { try {
let directRecipient: { name: string; email: string } | undefined; let recipientOverridePayload: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) { if (recipientPayload && !recipientPayload.email) {
const isFormValid = await directRecipientForm.trigger(); const isFormValid = await recipientForm.trigger();
if (!isFormValid) { if (!isFormValid) {
return; return;
} }
directRecipient = directRecipientForm.getValues(); recipientOverridePayload = recipientForm.getValues();
} }
// Check if 2FA is required // Check if 2FA is required
@@ -168,7 +168,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email } ? { name: data.name, email: data.email }
: undefined; : undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient); await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) { } catch (error) {
const err = AppError.parseError(error); const err = AppError.parseError(error);
@@ -222,7 +222,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans> <Trans>Are you sure?</Trans>
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
<div className="text-muted-foreground max-w-[50ch]"> <div className="max-w-[50ch] text-muted-foreground">
{match(recipient.role) {match(recipient.role)
.with(RecipientRole.VIEWER, () => ( .with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap"> <span className="inline-flex flex-wrap">
@@ -250,19 +250,19 @@ export const DocumentSigningCompleteDialog = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center"> <div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p> <p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
</div> </div>
{!showTwoFactorForm && ( {!showTwoFactorForm && (
<> <>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0"> <fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{directTemplatePayload && !directTemplatePayload.email && ( {recipientPayload && !recipientPayload.email && (
<Form {...directRecipientForm}> <Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4"> <div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<FormField <FormField
control={directRecipientForm.control} control={recipientForm.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
@@ -284,7 +284,7 @@ export const DocumentSigningCompleteDialog = ({
/> />
<FormField <FormField
control={directRecipientForm.control} control={recipientForm.control}
name="email" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
@@ -8,7 +8,6 @@ import {
type SensorAPI, type SensorAPI,
} from '@hello-pangea/dnd'; } from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -26,6 +25,7 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema, ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -65,10 +65,7 @@ const ZEnvelopeRecipientsForm = z.object({
z.object({ z.object({
formId: z.string().min(1), formId: z.string().min(1),
id: z.number().optional(), id: z.number().optional(),
email: z email: ZRecipientEmailSchema,
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
@@ -201,12 +198,13 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId', keyName: 'nativeId',
}); });
const emptySigners = useCallback( const emptySignerIndex = watchedSigners.findIndex(
() => form.getValues('signers').filter((signer) => signer.email === ''), (signer) =>
[form], !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( const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(), (signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
); );
@@ -460,21 +458,7 @@ export const EnvelopeEditorRecipientForm = () => {
return; return;
} }
const formValueSigners = formValues.signers || []; const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
// 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,
});
if (!validatedFormValues.success) { if (!validatedFormValues.success) {
return; return;
@@ -570,7 +554,7 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent> <CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}> <AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}> <Form {...form}>
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4"> <div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && ( {organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center"> <div className="flex flex-row items-center">
<Checkbox <Checkbox
@@ -618,9 +602,7 @@ export const EnvelopeEditorRecipientForm = () => {
}); });
} }
}} }}
disabled={ disabled={isSubmitting || hasDocumentBeenSent}
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
/> />
</FormControl> </FormControl>
@@ -634,7 +616,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help"> <span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" /> <HelpCircleIcon className="h-3.5 w-3.5" />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -679,7 +661,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help"> <span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" /> <HelpCircleIcon className="h-3.5 w-3.5" />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -732,7 +714,7 @@ export const EnvelopeEditorRecipientForm = () => {
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
className={cn('py-1', { className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2': 'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging, snapshot.isDragging,
})} })}
> >
@@ -806,7 +788,7 @@ export const EnvelopeEditorRecipientForm = () => {
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && index === 0 && (
<FormLabel required> <FormLabel>
<Trans>Email</Trans> <Trans>Email</Trans>
</FormLabel> </FormLabel>
)} )}
@@ -41,6 +41,11 @@ export const EnvelopeRecipientSelector = ({
}: EnvelopeRecipientSelectorProps) => { }: EnvelopeRecipientSelectorProps) => {
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
return ( return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}> <Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -49,7 +54,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline" variant="outline"
role="combobox" role="combobox"
className={cn( 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( getRecipientColorStyles(
Math.max( Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id), recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -59,16 +64,12 @@ export const EnvelopeRecipientSelector = ({
className, className,
)} )}
> >
{selectedRecipient?.email && ( {selectedRecipient && (
<span className="flex-1 truncate text-left"> <span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email}) {getRecipientLabel(selectedRecipient)}
</span> </span>
)} )}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" /> <ChevronsUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -154,6 +155,11 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients], [fields, recipients],
); );
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
return ( return (
<Command <Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined} value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -162,21 +168,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} /> <CommandInput placeholder={placeholder} />
<CommandEmpty> <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> <Trans>No recipient matching this description was found.</Trans>
</span> </span>
</CommandEmpty> </CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => ( {recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={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)} {t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div> </div>
{roleRecipients.length === 0 && ( {roleRecipients.length === 0 && (
<div <div
key={`${role}-empty`} 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> <Trans>No recipients with this role</Trans>
</div> </div>
@@ -205,18 +211,12 @@ export const EnvelopeRecipientSelectorCommand = ({
}} }}
> >
<span <span
className={cn('text-foreground/70 truncate', { className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id, 'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id), 'opacity-50': isRecipientDisabled(recipient.id),
})} })}
> >
{recipient.name && ( {getRecipientLabel(recipient)}
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
</span> </span>
<div className="ml-auto flex items-center justify-center"> <div className="ml-auto flex items-center justify-center">
@@ -234,7 +234,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<Info className="z-50 ml-2 h-4 w-4" /> <Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs"> <TooltipContent className="max-w-xs text-muted-foreground">
<Trans> <Trans>
This document has already been sent to this recipient. You can no longer This document has already been sent to this recipient. You can no longer
edit this recipient. edit this recipient.
@@ -250,3 +250,22 @@ export const EnvelopeRecipientSelectorCommand = ({
</Command> </Command>
); );
}; };
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[]) => {
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 `Recipient ${index + 1}`;
};
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
const handleOnCompleteClick = async ( const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string }, nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth, accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => { ) => {
try { try {
await completeDocument({ await completeDocument({
token: recipient.token, token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId), documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions, accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
}); });
@@ -205,9 +207,18 @@ export const EnvelopeSignerCompleteDialog = () => {
} }
}; };
const directTemplatePayload = useMemo(() => { const recipientPayload = useMemo(() => {
if (!isDirectTemplate) { 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 { return {
@@ -219,7 +230,7 @@ export const EnvelopeSignerCompleteDialog = () => {
return ( return (
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isPending} isSubmitting={isPending}
directTemplatePayload={directTemplatePayload} recipientPayload={recipientPayload}
onSignatureComplete={ onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
} }
@@ -6,6 +6,7 @@ import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; 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 { prisma } from '@documenso/prisma';
import { import {
DocumentDistributionMethod, DocumentDistributionMethod,
@@ -23,7 +24,9 @@ import type {
TCreateEnvelopePayload, TCreateEnvelopePayload,
TCreateEnvelopeResponse, TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types'; } 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 { 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 { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types'; import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@@ -557,4 +560,543 @@ test.describe('API V2 Envelopes', () => {
userEmail: userA.email, 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 { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -77,7 +78,8 @@ export const run = async ({
const recipientsToNotify = envelope.recipients.filter( const recipientsToNotify = envelope.recipients.filter(
(recipient) => (recipient) =>
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) && (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 () => { 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 { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope'; import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job'; import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email'; import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
@@ -79,8 +80,8 @@ export const run = async ({
const recipientReference = recipientName || recipientEmail; const recipientReference = recipientName || recipientEmail;
// Don't send notification if the owner is the one who signed // Don't send notification if the owner is the one who signed.
if (owner.email === recipientEmail) { if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
return; return;
} }
@@ -6,6 +6,7 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer'; import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected'; import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed'; import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -85,36 +86,38 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage); const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected // Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => { if (isRecipientEmailValidForSending(recipient)) {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, { await io.runTask('send-rejection-confirmation-email', async () => {
recipientName: recipient.name, const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
documentName: envelope.title, recipientName: recipient.name,
documentOwnerName: envelope.user.name || envelope.user.email, documentName: envelope.title,
reason: recipient.rejectionReason || '', documentOwnerName: envelope.user.name || envelope.user.email,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(), reason: recipient.rejectionReason || '',
}); assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([ const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }), renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, { renderEmailWithI18N(recipientTemplate, {
lang: emailLanguage, lang: emailLanguage,
branding, branding,
plainText: true, plainText: true,
}), }),
]); ]);
await mailer.sendMail({ await mailer.sendMail({
to: { to: {
name: recipient.name, name: recipient.name,
address: recipient.email, address: recipient.email,
}, },
from: senderEmail, from: senderEmail,
replyTo: replyToEmail, replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`), subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
html, html,
text, text,
});
}); });
}); }
// Send notification email to document owner // Send notification email to document owner
await io.runTask('send-owner-notification-email', async () => { await io.runTask('send-owner-notification-email', async () => {
@@ -12,6 +12,7 @@ import {
import { mailer } from '@documenso/email/mailer'; import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite'; import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -177,31 +178,33 @@ export const run = async ({
includeSenderDetails: settings.includeSenderDetails, includeSenderDetails: settings.includeSenderDetails,
}); });
await io.runTask('send-signing-email', async () => { if (isRecipientEmailValidForSending(recipient)) {
const [html, text] = await Promise.all([ await io.runTask('send-signing-email', async () => {
renderEmailWithI18N(template, { lang: emailLanguage, branding }), const [html, text] = await Promise.all([
renderEmailWithI18N(template, { renderEmailWithI18N(template, { lang: emailLanguage, branding }),
lang: emailLanguage, renderEmailWithI18N(template, {
branding, lang: emailLanguage,
plainText: true, branding,
}), plainText: true,
]); }),
]);
await mailer.sendMail({ await mailer.sendMail({
to: { to: {
name: recipient.name, name: recipient.name,
address: recipient.email, address: recipient.email,
}, },
from: senderEmail, from: senderEmail,
replyTo: replyToEmail, replyTo: replyToEmail,
subject: renderCustomEmailTemplate( subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject, documentMeta?.subject || emailSubject,
customEmailTemplate, customEmailTemplate,
), ),
html, html,
text, text,
});
}); });
}); }
await io.runTask('update-recipient', async () => { await io.runTask('update-recipient', async () => {
await prisma.recipient.update({ await prisma.recipient.update({
@@ -5,6 +5,7 @@ import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer'; import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa'; import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server'; 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({ const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
envelopeId, envelopeId,
email: recipient.email, email: recipient.email,
@@ -14,6 +14,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
@@ -64,14 +65,18 @@ export const adminSuperDeleteDocument = async ({
envelope.documentMeta, envelope.documentMeta,
).documentDeleted; ).documentDeleted;
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
// if the document is pending, send cancellation emails to all recipients // if the document is pending, send cancellation emails to all recipients
if ( if (
status === DocumentStatus.PENDING && status === DocumentStatus.PENDING &&
envelope.recipients.length > 0 && recipientsToNotify.length > 0 &&
isDocumentDeletedEmailEnabled isDocumentDeletedEmailEnabled
) { ) {
await Promise.all( await Promise.all(
envelope.recipients.map(async (recipient) => { recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) { if (recipient.sendStatus !== SendStatus.SENT) {
return; return;
} }
@@ -2,6 +2,7 @@ import {
DocumentSigningOrder, DocumentSigningOrder,
DocumentStatus, DocumentStatus,
EnvelopeType, EnvelopeType,
FieldType,
RecipientRole, RecipientRole,
SendStatus, SendStatus,
SigningStatus, SigningStatus,
@@ -43,6 +44,14 @@ export type CompleteDocumentWithTokenOptions = {
email: string; email: string;
name: 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 ({ export const completeDocumentWithToken = async ({
@@ -52,6 +61,7 @@ export const completeDocumentWithToken = async ({
accessAuthOptions, accessAuthOptions,
requestMetadata, requestMetadata,
nextSigner, nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => { }: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({ const envelope = await prisma.envelope.findFirstOrThrow({
where: { where: {
@@ -116,6 +126,35 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`); 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 // Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions, 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({ const isValid = await isRecipientAuthorized({
type: 'ACCESS_2FA', type: 'ACCESS_2FA',
documentAuthOptions: envelope.authOptions, documentAuthOptions: envelope.authOptions,
@@ -176,9 +221,43 @@ export const completeDocumentWithToken = async ({
data: { data: {
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
signedAt: new Date(), 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({ const authOptions = extractDocumentAuthMethods({
documentAuth: envelope.authOptions, documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
@@ -189,13 +268,13 @@ export const completeDocumentWithToken = async ({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
envelopeId: envelope.id, envelopeId: envelope.id,
user: { user: {
name: recipient.name, name: recipientName,
email: recipient.email, email: recipientEmail,
}, },
requestMetadata, requestMetadata,
data: { data: {
recipientEmail: recipient.email, recipientEmail: recipientEmail,
recipientName: recipient.name, recipientName: recipientName,
recipientId: recipient.id, recipientId: recipient.id,
recipientRole: recipient.role, recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth, actionAuth: authOptions.derivedRecipientActionAuth,
@@ -204,13 +283,15 @@ export const completeDocumentWithToken = async ({
}); });
}); });
await jobs.triggerJob({ if (recipientEmail) {
name: 'send.recipient.signed.email', await jobs.triggerJob({
payload: { name: 'send.recipient.signed.email',
documentId: legacyDocumentId, payload: {
recipientId: recipient.id, documentId: legacyDocumentId,
}, recipientId: recipient.id,
}); },
});
}
const pendingRecipients = await prisma.recipient.findMany({ const pendingRecipients = await prisma.recipient.findMany({
select: { select: {
@@ -247,8 +328,8 @@ export const completeDocumentWithToken = async ({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED, type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id, envelopeId: envelope.id,
user: { user: {
name: recipient.name, name: recipientName,
email: recipient.email, email: recipientEmail,
}, },
requestMetadata, requestMetadata,
data: { data: {
@@ -21,6 +21,7 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
import { isDocumentCompleted } from '../../utils/document'; import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles'; import { getMemberRoles } from '../team/get-member-roles';
@@ -209,7 +210,7 @@ const handleDocumentOwnerDelete = async ({
// Send cancellation emails to recipients. // Send cancellation emails to recipients.
await Promise.all( await Promise.all(
envelope.recipients.map(async (recipient) => { envelope.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) { if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
return; return;
} }
@@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document'; import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -118,7 +119,7 @@ export const resendDocument = async ({
await Promise.all( await Promise.all(
recipientsToRemind.map(async (recipient) => { recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) { if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return; return;
} }
@@ -16,6 +16,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams'; import { formatDocumentsPath } from '../../utils/teams';
@@ -176,8 +177,12 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
return; return;
} }
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
await Promise.all( await Promise.all(
envelope.recipients.map(async (recipient) => { recipientsToNotify.map(async (recipient) => {
const customEmailTemplate = { const customEmailTemplate = {
'signer.name': recipient.name, 'signer.name': recipient.name,
'signer.email': recipient.email, 'signer.email': recipient.email,
@@ -35,8 +35,10 @@ import {
import { getFileServerSide } from '../../universal/upload/get-file.server'; import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document'; import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; 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 // 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. // decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({ // const fields = await getFieldsForDocument({
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
@@ -69,6 +70,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
const { email, name } = recipient; 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 assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentPendingEmailTemplate, { const template = createElement(DocumentPendingEmailTemplate, {
@@ -14,7 +14,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; 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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
@@ -142,7 +142,8 @@ export const deleteEnvelopeRecipient = async ({
if ( if (
recipientToDelete.sendStatus === SendStatus.SENT && recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled && isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
) { ) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; 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 { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; 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 { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context'; import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -294,10 +294,14 @@ export const setDocumentRecipients = async ({
envelope.documentMeta, envelope.documentMeta,
).recipientRemoved; ).recipientRemoved;
// Send emails to deleted recipients. // Send emails to deleted recipients who have emails.
await Promise.all( await Promise.all(
removedRecipients.map(async (recipient) => { removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) { if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return; return;
} }
+11
View File
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { z } from 'zod'; import { z } from 'zod';
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema'; import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
@@ -110,3 +111,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
documentId: true, documentId: true,
templateId: 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 { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } 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 { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { extractLegacyIds } from '../universal/id'; import { extractLegacyIds } from '../universal/id';
@@ -58,3 +59,7 @@ export const mapRecipientToLegacyRecipient = (
...legacyId, ...legacyId,
}; };
}; };
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
return z.string().email().safeParse(recipient.email).success;
};
@@ -22,6 +22,7 @@ import {
ZFieldWidthSchema, ZFieldWidthSchema,
} from '@documenso/lib/types/field'; } from '@documenso/lib/types/field';
import { ZFieldAndMetaSchema } 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'; import { ZDocumentTitleSchema } from '../document-router/schema';
@@ -30,7 +31,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
documentDataId: z.string(), documentDataId: z.string(),
recipients: z.array( recipients: z.array(
z.object({ z.object({
email: z.union([z.string().length(0), z.string().email()]), email: ZRecipientEmailSchema,
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), 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 { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -21,22 +21,11 @@ import {
ZFieldPageYSchema, ZFieldPageYSchema,
ZFieldWidthSchema, ZFieldWidthSchema,
} from '@documenso/lib/types/field'; } 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'; 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({ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
templateId: z.number(), templateId: z.number(),
title: ZDocumentTitleSchema.optional(), title: ZDocumentTitleSchema.optional(),
@@ -44,7 +33,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
recipients: z.array( recipients: z.array(
z.object({ z.object({
id: z.number().optional(), id: z.number().optional(),
email: z.union([z.string().length(0), z.string().email()]), email: ZRecipientEmailSchema,
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
@@ -24,8 +24,8 @@ import {
ZDocumentTitleSchema, ZDocumentTitleSchema,
ZDocumentVisibilitySchema, ZDocumentVisibilitySchema,
} from '../document-router/schema'; } from '../document-router/schema';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc'; import type { TrpcRouteMeta } from '../trpc';
import { ZCreateEnvelopeRecipientSchema } from './envelope-recipients/create-envelope-recipients.types';
export const createEnvelopeMeta: TrpcRouteMeta = { export const createEnvelopeMeta: TrpcRouteMeta = {
openapi: { openapi: {
@@ -54,7 +54,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
.optional(), .optional(),
recipients: z recipients: z
.array( .array(
ZCreateRecipientSchema.extend({ ZCreateEnvelopeRecipientSchema.extend({
fields: ZEnvelopeFieldAndMetaSchema.and( fields: ZEnvelopeFieldAndMetaSchema.and(
z.object({ z.object({
identifier: z identifier: z
@@ -1,8 +1,15 @@
import { RecipientRole } from '@prisma/client';
import { z } from 'zod'; 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'; import type { TrpcRouteMeta } from '../../trpc';
export const createEnvelopeRecipientsMeta: TrpcRouteMeta = { 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({ export const ZCreateEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(), envelopeId: z.string(),
data: ZCreateRecipientSchema.array(), data: ZCreateEnvelopeRecipientSchema.array(),
}); });
export const ZCreateEnvelopeRecipientsResponseSchema = z.object({ export const ZCreateEnvelopeRecipientsResponseSchema = z.object({
@@ -1,8 +1,12 @@
import { RecipientRole } from '@prisma/client';
import { z } from 'zod'; 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'; import type { TrpcRouteMeta } from '../../trpc';
export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = { 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,
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({ export const ZUpdateEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(), envelopeId: z.string(),
data: ZUpdateRecipientSchema.array(), data: ZUpdateEnvelopeRecipientSchema.array(),
}); });
export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({ export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({
@@ -2,7 +2,7 @@ import { EnvelopeType, RecipientRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; 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({ export const ZSetEnvelopeRecipientsRequestSchema = z.object({
envelopeId: z.string(), envelopeId: z.string(),
@@ -10,7 +10,7 @@ export const ZSetEnvelopeRecipientsRequestSchema = z.object({
recipients: z.array( recipients: z.array(
z.object({ z.object({
id: z.number().optional(), id: z.number().optional(),
email: z.string().toLowerCase().email().min(1).max(254), email: ZRecipientEmailSchema,
name: z.string().max(255), name: z.string().max(255),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
@@ -16,6 +16,7 @@ import {
} from '@documenso/lib/types/document-meta'; } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zodFormData } from '../../utils/zod-form-data'; import { zodFormData } from '../../utils/zod-form-data';
import type { TrpcRouteMeta } from '../trpc'; import type { TrpcRouteMeta } from '../trpc';
@@ -40,7 +41,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
.array( .array(
z.object({ z.object({
id: z.number().describe('The ID of the recipient in the template.'), 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(), name: z.string().max(255).optional(),
signingOrder: z.number().optional(), signingOrder: z.number().optional(),
}), }),
@@ -561,7 +561,7 @@ export const recipientRouter = router({
completeDocumentWithToken: procedure completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema) .input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { token, documentId, accessAuthOptions, nextSigner } = input; const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
ctx.logger.info({ ctx.logger.info({
input: { input: {
@@ -577,6 +577,7 @@ export const recipientRouter = router({
}, },
accessAuthOptions, accessAuthOptions,
nextSigner, nextSigner,
recipientOverride,
userId: ctx.user?.id, userId: ctx.user?.id,
requestMetadata: ctx.metadata.requestMetadata, requestMetadata: ctx.metadata.requestMetadata,
}); });
@@ -171,6 +171,12 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
}) })
.optional(), .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< export type TCompleteDocumentWithTokenMutationSchema = z.infer<
@@ -22,6 +22,7 @@ import {
} from '@documenso/lib/types/document-meta'; } from '@documenso/lib/types/document-meta';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; 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 { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { import {
ZTemplateLiteSchema, ZTemplateLiteSchema,
@@ -100,7 +101,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
.array( .array(
z.object({ z.object({
id: z.number().describe('The ID of the recipient in the template.'), 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(), name: z.string().max(255).optional(),
}), }),
) )