feat: add email domains (#1895)

Implemented Email Domains which allows Platform/Enterprise customers to
send emails to recipients using their custom emails.
This commit is contained in:
David Nguyen
2025-07-24 16:05:00 +10:00
committed by GitHub
parent 07119f0e8d
commit 3409aae411
157 changed files with 5966 additions and 1090 deletions

View File

@ -105,6 +105,12 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[EE ONLY]]
# OPTIONAL: The AWS SES API KEY to verify email domains with.
NEXT_PRIVATE_SES_ACCESS_KEY_ID=
NEXT_PRIVATE_SES_SECRET_ACCESS_KEY=
NEXT_PRIVATE_SES_REGION=
# [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=

View File

@ -11,6 +11,7 @@
"documents": "Documents",
"templates": "Templates",
"branding": "Branding",
"email-domains": "Email Domains",
"direct-links": "Direct Signing Links",
"-- Legal Overview": {
"type": "separator",

View File

@ -17,7 +17,7 @@ Branding preferences can be set on either the organisation or team level.
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab. This page contains both the preferences for documents and branding, the branding section is located at the bottom of the page.
To access the preferences, navigate to either the organisation or teams settings page and click the **Branding** tab under the **Preferences** section.
![A screenshot of the organisation's document preferences page](/organisations/organisation-branding.webp)

View File

@ -1,5 +1,7 @@
{
"sending-documents": "Sending Documents",
"document-preferences": "Document Preferences",
"document-visibility": "Document Visibility"
"document-visibility": "Document Visibility",
"fields": "Document Fields",
"email-preferences": "Email Preferences"
}

View File

@ -19,12 +19,14 @@ Document preferences can be set on either the organisation or team level.
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
To access the preferences, navigate to either the organisation or teams settings page and click the **Preferences** tab.
To access the preferences, navigate to either the organisation or teams settings page and click the **Document** tab under the **Preferences** section.
![A screenshot of the organisation's document preferences page](/organisations/organisation-document-preferences.webp)
- **Document Visibility** - Set the default visibility of the documents created by team members. Learn more about [document visibility](/users/documents/document-visibility).
- **Default Document Language** - This setting allows you to set the default language for the documents uploaded in the organisation. The default language is used as the default language in the email communications with the document recipients.
- **Default Time Zone** - The timezone to use for date fields and signing the document.
- **Default Date Format** - The date format to use for date fields and signing the document.
- **Signature Settings** - Controls what signatures are allowed to be used when signing the documents.
- **Sender Details** - Set whether the sender's name should be included in the emails sent by the team. See more below [sender details](/users/documents/document-preferences#sender-details).
- **Include the Signing Certificate** - This setting controls whether the signing certificate should be included in the signed documents. If enabled, the signing certificate is included in the signed documents. If disabled, the signing certificate is not included in the signed documents. Regardless of this setting, the signing certificate is always available in the document's audit log page.

View File

@ -0,0 +1,26 @@
---
title: Email Preferences
description: Learn how to set the email preferences for your team account.
---
import Image from 'next/image';
import { Callout, Steps } from 'nextra/components';
# Email Preferences
Email preferences allow you to set the default settings when emailing documents to your recipients.
## Preferences
Email preferences can be set on either the organisation or team level.
By default, teams inherit the preferences from the organisation. You can override these preferences on the team level at any time.
To access the preferences, navigate to either the organisation or teams settings page and click the **Email** tab under the **Preferences** section.
![A screenshot of the organisation's email preferences page](/organisations/organisation-email-preferences.webp)
- **Default Email** - Use a custom email address when sending documents to your recipients. See [email domains](/users/email-domains) for more information.
- **Reply To** - The email address that will be used in the "Reply To" field in emails
- **Email Settings** - Which emails to send to recipients during document signing

View File

@ -0,0 +1,112 @@
import { Callout, Steps } from 'nextra/components';
# Email Domains
Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address.
<Callout type="info">
**Platform and Enterprise Only**: Email Domains is only available to Platform and Enterprise
customers.
</Callout>
## Creating Email Domains
Before setting up email domains, ensure you have:
- A Platform or Enterprise subscription
- Access to your domain's DNS settings
- Access to your Documenso organisation as an admin or manager
<Steps>
### Access Email Domains Settings
Navigate to your Organisation email domains settings page and click the "Add Email Domain" button.
![Email Domains settings page](/email-domains/email-domains-settings-page.webp)
### Configure DNS Records
After adding your domain, Documenso will provide you with the following required DNS records that need to be configured on your domain:
- **SPF Record**: Specifies which servers are authorized to send emails from your domain
- **DKIM Record**: Provides email authentication and prevents tampering
![DNS configuration instructions](/email-domains/email-domains-record.webp)
<Callout type="info">
If you already have an SPF record configured, you will need to update it to include Amazon SES as
an authorized server instead of creating a new record.
</Callout>
Configure these records in your domain's DNS settings according to their specific instructions.
### Verify Domain Configuration
Once you've added the DNS records, return to the Documenso email domains settings page and click the "Verify" button.
This will trigger a verification process which will check if the DNS records are properly configured. If successful, the domain will be marked as "Active".
![Domain verification process](/email-domains/email-domain-sync.webp)
<Callout type="info">
Please note that it may take up to 48 hours for the DNS records to propagate.
</Callout>
</Steps>
## Creating Emails
Once your email domain has been configured, you can create multiple email addresses which your members can use when sending documents to recipients.
<Steps>
### Select the Email Domain You Want to Use
Navigate to the email domains settings page and click "Manage" on the domain you want to use.
![Email Domains settings page](/email-domains/email-domains-manage.webp)
### Add a New Email
Click on the "Add Email" button to begin the setup process.
![Create email](/email-domains/email-domains-manage-create-email.webp)
### Use Email
Once you have added an email, you can configure it to be the default email on either the:
- Organisation email preferences page
- Team email preferences page
When a draft document is created, it will inherit the email configured on the team if set, otherwise it will inherit the email configured in the organisation.
You can also configure the email address directly on the document to override the default email if required.
</Steps>
## Notes
- If you change the default email, it will not retroactively update any existing documents with the old default email.
- If the email domain becomes invalid, all emails using that domain will fail to send.
## Troubleshooting
### Common Issues
**DNS Verification Fails**
- Double-check all DNS record values
- Ensure records are added to the correct domain
- Wait for DNS propagation (up to 48 hours)
**Emails Not Delivering**
- Check domain reputation and blacklist status
- Verify SPF, DKIM, and DMARC records
- Review bounce and spam reports
<Callout type="info">
For additional support with Email Domains configuration, contact our support team at
support@documenso.com.
</Callout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -87,7 +87,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
const { data: plansData } = trpc.billing.plans.get.useQuery(undefined, {
const { data: plansData } = trpc.enterprise.billing.plans.get.useQuery(undefined, {
enabled: IS_BILLING_ENABLED(),
});

View File

@ -0,0 +1,243 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EmailDomain = {
id: string;
domain: string;
status: string;
};
export type OrganisationEmailCreateDialogProps = {
trigger?: React.ReactNode;
emailDomain: EmailDomain;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateOrganisationEmailFormSchema = ZCreateOrganisationEmailRequestSchema.pick({
emailName: true,
email: true,
// replyTo: true,
});
type TCreateOrganisationEmailFormSchema = z.infer<typeof ZCreateOrganisationEmailFormSchema>;
export const OrganisationEmailCreateDialog = ({
trigger,
emailDomain,
...props
}: OrganisationEmailCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationEmailFormSchema),
defaultValues: {
emailName: '',
email: '',
// replyTo: '',
},
});
const { mutateAsync: createOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
try {
await createOrganisationEmail({
emailDomainId: emailDomain.id,
...data,
});
toast({
title: t`Email Created`,
description: t`The organisation email has been created successfully.`,
});
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: t`Email already exists`,
description: t`An email with this address already exists.`,
variant: 'destructive',
});
} else {
toast({
title: t`An error occurred`,
description: t`We encountered an error while creating the email. Please try again later.`,
variant: 'destructive',
});
}
}
};
return (
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Add Email</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>Add Organisation Email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Create a new email address for your organisation using the domain{' '}
<span className="font-bold">{emailDomain.domain}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
<FormField
control={form.control}
name="emailName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Display Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="Support" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>The display name for this email address</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<div className="relative flex items-center gap-2">
<Input
{...field}
value={field.value.split('@')[0]}
onChange={(e) => {
field.onChange(e.target.value + '@' + emailDomain.domain);
}}
placeholder="support"
/>
<div className="bg-muted text-muted-foreground absolute bottom-0 right-0 top-0 flex items-center rounded-r-md border px-3 py-2 text-sm">
@{emailDomain.domain}
</div>
</div>
</FormControl>
<FormMessage />
{!form.formState.errors.email && (
<span className="text-foreground/50 text-xs font-normal">
{field.value ? (
field.value
) : (
<Trans>
The part before the @ symbol (e.g., "support" for support@
{emailDomain.domain})
</Trans>
)}
</span>
)}
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="replyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply-To Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Optional no-reply email address attached to emails. Leave blank to default
to the organisation settings reply-to email.
</Trans>
</FormDescription>
</FormItem>
)}
/> */}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-email-button"
loading={isPending}
>
<Trans>Create Email</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,111 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationEmailDeleteDialogProps = {
emailId: string;
email: string;
trigger?: React.ReactNode;
};
export const OrganisationEmailDeleteDialog = ({
trigger,
emailId,
email,
}: OrganisationEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteEmail, isPending: isDeleting } =
trpc.enterprise.organisation.email.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this email from the organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this email. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete email</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following email from{' '}
<span className="font-semibold">{organisation.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">{email}</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeleting}
onClick={async () =>
deleteEmail({
emailId,
})
}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationEmailDomainRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationEmailDomainRecordContent } from './organisation-email-domain-records-dialog';
export type OrganisationEmailCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZCreateOrganisationEmailDomainFormSchema = ZCreateOrganisationEmailDomainRequestSchema.pick({
domain: true,
});
type TCreateOrganisationEmailDomainFormSchema = z.infer<
typeof ZCreateOrganisationEmailDomainFormSchema
>;
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const OrganisationEmailDomainCreateDialog = ({
trigger,
...props
}: OrganisationEmailCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'domain' | 'verification'>('domain');
const [recordsToAdd, setRecordsToAdd] = useState<DomainRecord[]>([]);
const form = useForm({
resolver: zodResolver(ZCreateOrganisationEmailDomainFormSchema),
defaultValues: {
domain: '',
},
});
const { mutateAsync: createOrganisationEmail } =
trpc.enterprise.organisation.emailDomain.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
setStep('domain');
}
}, [open, form]);
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
try {
const { records } = await createOrganisationEmail({
domain,
organisationId: organisation.id,
});
setRecordsToAdd(records);
setStep('verification');
toast({
title: t`Domain Added`,
description: t`DKIM records generated. Please add the DNS records to verify your domain.`,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
if (error.code === AppErrorCode.ALREADY_EXISTS) {
toast({
title: t`Domain already in use`,
description: t`Please try a different domain.`,
variant: 'destructive',
duration: 10000,
});
} else {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to add your domain. Please try again later.`,
variant: 'destructive',
});
}
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Add Email Domain</Trans>
</Button>
)}
</DialogTrigger>
{step === 'domain' ? (
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>Add Custom Email Domain</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Add a custom domain to send emails on behalf of your organisation. We'll generate
DKIM records that you need to add to your DNS provider.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Domain Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="example.com" className="bg-background" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Enter the domain you want to use for sending emails (without http:// or
www)
</Trans>
</FormDescription>
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-email-button"
loading={form.formState.isSubmitting}
>
<Trans>Generate DKIM Records</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
) : (
<OrganisationEmailDomainRecordContent records={recordsToAdd} />
)}
</Dialog>
);
};

View File

@ -0,0 +1,161 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationEmailDomainDeleteDialogProps = {
emailDomainId: string;
emailDomain: string;
trigger?: React.ReactNode;
};
export const OrganisationEmailDomainDeleteDialog = ({
trigger,
emailDomainId,
emailDomain,
}: OrganisationEmailDomainDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const deleteMessage = t`delete ${emailDomain}`;
const ZDeleteEmailDomainFormSchema = z.object({
confirmText: z.literal(deleteMessage, {
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
}),
});
const form = useForm<z.infer<typeof ZDeleteEmailDomainFormSchema>>({
resolver: zodResolver(ZDeleteEmailDomainFormSchema),
defaultValues: {
confirmText: '',
},
});
const { mutateAsync: deleteEmailDomain, isPending: isDeleting } =
trpc.enterprise.organisation.emailDomain.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this email domain from the organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this email domain. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
const onFormSubmit = async () => {
await deleteEmailDomain({
emailDomainId,
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
<Trans>Delete email domain</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the email domain{' '}
<span className="font-semibold">{emailDomain}</span> from{' '}
<span className="font-semibold">{organisation.name}</span>. All emails associated with
this domain will be deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="confirmText"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
</Trans>
</FormLabel>
<FormControl>
<Input placeholder={deleteMessage} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="destructive"
type="submit"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,139 @@
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationEmailDomainRecordsDialogProps = {
trigger: React.ReactNode;
records: DomainRecord[];
} & Omit<DialogPrimitive.DialogProps, 'children'>;
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const OrganisationEmailDomainRecordsDialog = ({
trigger,
records,
...props
}: OrganisationEmailDomainRecordsDialogProps) => {
return (
<Dialog {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger}
</DialogTrigger>
<OrganisationEmailDomainRecordContent records={records} />
</Dialog>
);
};
export const OrganisationEmailDomainRecordContent = ({ records }: { records: DomainRecord[] }) => {
const { t } = useLingui();
const { toast } = useToast();
return (
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>Verify Domain</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Add these DNS records to verify your domain ownership</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-4">
{records.map((record) => (
<div className="space-y-4 rounded-md border p-4" key={record.name}>
<div className="space-y-2">
<Label>
<Trans>Record Type</Trans>
</Label>
<div className="relative">
<Input className="pr-12" disabled value={record.type} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={record.type}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label>
<Trans>Record Name</Trans>
</Label>
<div className="relative">
<Input className="pr-12" disabled value={record.name} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={record.name}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label>
<Trans>Record Value</Trans>
</Label>
<div className="relative">
<Input className="pr-12" disabled value={record.value} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<CopyTextButton
value={record.value}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
/>
</div>
</div>
</div>
</div>
))}
</div>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
Once the DNS propagation is complete you will need to come back and press the "Sync"
domains button
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</div>
</DialogContent>
);
};

View File

@ -0,0 +1,184 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
import { ZUpdateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-email.types';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationEmailUpdateDialogProps = {
trigger: React.ReactNode;
organisationEmail: TGetOrganisationEmailDomainResponse['emails'][number];
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZUpdateOrganisationEmailFormSchema = ZUpdateOrganisationEmailRequestSchema.pick({
emailName: true,
// replyTo: true,
});
type ZUpdateOrganisationEmailSchema = z.infer<typeof ZUpdateOrganisationEmailFormSchema>;
export const OrganisationEmailUpdateDialog = ({
trigger,
organisationEmail,
...props
}: OrganisationEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
const { toast } = useToast();
const form = useForm<ZUpdateOrganisationEmailSchema>({
resolver: zodResolver(ZUpdateOrganisationEmailFormSchema),
defaultValues: {
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
},
});
const { mutateAsync: updateOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.update.useMutation();
const onFormSubmit = async ({ emailName }: ZUpdateOrganisationEmailSchema) => {
try {
await updateOrganisationEmail({
emailId: organisationEmail.id,
emailName,
// replyTo,
});
toast({
title: t`Success`,
duration: 5000,
});
setOpen(false);
} catch {
toast({
title: t`An unknown error occurred`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!open) {
return;
}
form.reset({
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
});
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Update email</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationEmail.email}</span>
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
<FormField
control={form.control}
name="emailName"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Display Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="Support" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>The display name for this email address</Trans>
</FormDescription>
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="replyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply-To Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Optional no-reply email address attached to emails. Leave blank to default
to the organisation settings reply-to email.
</Trans>
</FormDescription>
</FormItem>
)}
/> */}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isPending}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -32,7 +32,14 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
export type EmbedDocumentFieldsProps = {
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
metadata?: Pick<
DocumentMeta | TemplateMeta,
| 'timezone'
| 'dateFormat'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
> | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};

View File

@ -8,17 +8,24 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaTimezoneSchema,
} from '@documenso/trpc/server/document-router/schema';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
@ -44,6 +51,8 @@ import {
export type TDocumentPreferencesFormSchema = {
documentVisibility: DocumentVisibility | null;
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
documentTimezone: string | null;
documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
signatureTypes: DocumentSignatureType[];
@ -53,6 +62,8 @@ type SettingsSubset = Pick<
TeamGlobalSettings,
| 'documentVisibility'
| 'documentLanguage'
| 'documentTimezone'
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'typedSignatureEnabled'
@ -81,6 +92,8 @@ export const DocumentPreferencesForm = ({
const ZDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
@ -94,6 +107,9 @@ export const DocumentPreferencesForm = ({
documentLanguage: isValidLanguageCode(settings.documentLanguage)
? settings.documentLanguage
: null,
documentTimezone: settings.documentTimezone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
@ -124,7 +140,10 @@ export const DocumentPreferencesForm = ({
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="document-visibility-trigger"
>
<SelectValue />
</SelectTrigger>
@ -171,7 +190,10 @@ export const DocumentPreferencesForm = ({
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="document-language-trigger"
>
<SelectValue />
</SelectTrigger>
@ -199,6 +221,72 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="documentDateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Date Format</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger data-testid="document-date-format-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="documentTimezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Default Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
triggerPlaceholder={
canInherit ? t`Inherit from organisation` : t`Local timezone`
}
placeholder={t`Select a time zone`}
options={TIME_ZONES}
value={field.value}
onChange={(value) => field.onChange(value)}
testId="document-timezone-trigger"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signatureTypes"
@ -222,7 +310,7 @@ export const DocumentPreferencesForm = ({
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
}
testId="signature-types-combobox"
testId="signature-types-trigger"
/>
</FormControl>
@ -257,7 +345,10 @@ export const DocumentPreferencesForm = ({
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-sender-details-trigger"
>
<SelectValue />
</SelectTrigger>
@ -325,7 +416,10 @@ export const DocumentPreferencesForm = ({
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="include-signing-certificate-trigger"
>
<SelectValue />
</SelectTrigger>

View File

@ -0,0 +1,238 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
import {
DEFAULT_DOCUMENT_EMAIL_SETTINGS,
ZDocumentEmailSettingsSchema,
} from '@documenso/lib/types/document-email';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: z.string().email().nullable(),
// emailReplyToName: z.string(),
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
});
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
type SettingsSubset = Pick<
TeamGlobalSettings,
'emailId' | 'emailReplyTo' | 'emailDocumentSettings'
>;
export type EmailPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
onFormSubmit: (data: TEmailPreferencesFormSchema) => Promise<void>;
};
export const EmailPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
}: EmailPreferencesFormProps) => {
const organisation = useCurrentOrganisation();
const form = useForm<TEmailPreferencesFormSchema>({
defaultValues: {
emailId: settings.emailId,
emailReplyTo: settings.emailReplyTo,
// emailReplyToName: settings.emailReplyToName,
emailDocumentSettings: settings.emailDocumentSettings,
},
resolver: zodResolver(ZEmailPreferencesFormSchema),
});
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
const emails = emailData?.data || [];
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full max-w-2xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="emailId"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Email</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger loading={isLoadingEmails}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>
{canInherit ? <Trans>Inherit from organisation</Trans> : FROM_ADDRESS}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>The default email to use when sending emails to recipients</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply to email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ''}
onChange={(value) => field.onChange(value.target.value || null)}
placeholder="noreply@example.com"
type="email"
/>
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
The email address which will show up in the "Reply To" field in emails
</Trans>
{canInherit && (
<span>
{'. '}
<Trans>Leave blank to inherit from the organisation.</Trans>
</span>
)}
</FormDescription>
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="emailReplyToName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply to name</Trans>
</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="emailDocumentSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Email Settings</Trans>
</FormLabel>
{canInherit && (
<Select
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
onValueChange={(value) =>
field.onChange(
value === 'CONTROLLED' ? DEFAULT_DOCUMENT_EMAIL_SETTINGS : null,
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={'INHERIT'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
<SelectItem value={'CONTROLLED'}>
<Trans>Override organisation settings</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
{field.value && (
<div className="space-y-2 rounded-md border p-4">
<DocumentEmailCheckboxes
value={field.value ?? DEFAULT_DOCUMENT_EMAIL_SETTINGS}
onChange={(value) => field.onChange(value)}
/>
</div>
)}
<FormDescription>
<Trans>
Controls the default email settings when new documents or templates are created
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -186,7 +186,7 @@ const BillingDialog = ({
});
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
trpc.billing.subscription.create.useMutation();
trpc.enterprise.billing.subscription.create.useMutation();
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
trpc.organisation.create.useMutation();
@ -346,7 +346,7 @@ export const IndividualPersonalLayoutCheckoutButton = ({
const { organisations } = useSession();
const { mutateAsync: createSubscription, isPending } =
trpc.billing.subscription.create.useMutation();
trpc.enterprise.billing.subscription.create.useMutation();
const onSubscribeClick = async () => {
try {

View File

@ -67,7 +67,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
const { id } = await createDocument({
title: file.name,
documentDataId: response.id,
timezone: userTimezone,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined,
});

View File

@ -278,7 +278,8 @@ export const DocumentEditForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, distributionMethod, emailSettings } = data.meta;
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
data.meta;
try {
await sendDocument({
@ -287,7 +288,9 @@ export const DocumentEditForm = ({
subject,
message,
distributionMethod,
emailSettings,
emailId,
emailReplyTo,
emailSettings: emailSettings,
},
});

View File

@ -78,7 +78,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
const { id } = await createDocument({
title: file.name,
documentDataId: response.id,
timezone: userTimezone,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
folderId: folderId ?? undefined,
});

View File

@ -35,7 +35,7 @@ export const OrganisationBillingBanner = () => {
const organisation = useOptionalCurrentOrganisation();
const { mutateAsync: manageSubscription, isPending } =
trpc.billing.subscription.manage.useMutation();
trpc.enterprise.billing.subscription.manage.useMutation();
const handleCreatePortal = async (organisationId: string) => {
try {

View File

@ -21,7 +21,7 @@ export const OrganisationBillingPortalButton = ({
const { toast } = useToast();
const { mutateAsync: manageSubscription, isPending } =
trpc.billing.subscription.manage.useMutation();
trpc.enterprise.billing.subscription.manage.useMutation();
const canManageBilling = canExecuteOrganisationAction(
'MANAGE_BILLING',

View File

@ -46,16 +46,46 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
{isPersonalLayoutMode && (
<>
<Link to="/settings/preferences">
<Link to="/settings/document">
<Button variant="ghost" className={cn('w-full justify-start')}>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/document">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
pathname?.startsWith('/settings/document') && 'bg-secondary',
)}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
<Trans>Document</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/branding">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/branding') && 'bg-secondary',
)}
>
<Trans>Branding</Trans>
</Button>
</Link>
<Link className="w-full pl-8" to="/settings/email">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/email') && 'bg-secondary',
)}
>
<Trans>Email</Trans>
</Button>
</Link>

View File

@ -6,6 +6,8 @@ import {
CreditCardIcon,
Globe2Icon,
Lock,
MailIcon,
PaletteIcon,
Settings2Icon,
User,
Users,
@ -48,16 +50,42 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
{isPersonalLayoutMode && (
<>
<Link to="/settings/preferences">
<Link to="/settings/document">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
pathname?.startsWith('/settings/document') && 'bg-secondary',
)}
>
<Settings2Icon className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
<Trans>Document Preferences</Trans>
</Button>
</Link>
<Link to="/settings/branding">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/branding') && 'bg-secondary',
)}
>
<PaletteIcon className="mr-2 h-5 w-5" />
<Trans>Branding Preferences</Trans>
</Button>
</Link>
<Link to="/settings/email">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/email') && 'bg-secondary',
)}
>
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Preferences</Trans>
</Button>
</Link>

View File

@ -28,10 +28,6 @@ export const ShareDocumentDownloadButton = ({
try {
setIsDownloading(true);
await new Promise((resolve) => {
setTimeout(resolve, 4000);
});
await downloadPDF({ documentData, fileName: title });
} catch (err) {
toast({

View File

@ -1,112 +0,0 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import { Braces, Globe2Icon, GroupIcon, Settings, Settings2, Users, Webhook } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TeamSettingsNavDesktopProps = HTMLAttributes<HTMLDivElement>;
export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavDesktopProps) => {
const { pathname } = useLocation();
const params = useParams();
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const groupsPath = `/t/${teamUrl}/settings/groups`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
return (
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
<Link to={settingsPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
>
<Settings className="mr-2 h-5 w-5" />
<Trans>General</Trans>
</Button>
</Link>
<Link to={preferencesPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(preferencesPath) && 'bg-secondary',
)}
>
<Settings2 className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link to={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to={membersPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(membersPath) && 'bg-secondary',
)}
>
<Users className="mr-2 h-5 w-5" />
<Trans>Members</Trans>
</Button>
</Link>
<Link to={groupsPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
>
<GroupIcon className="mr-2 h-5 w-5" />
<Trans>Groups</Trans>
</Button>
</Link>
<Link to={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to={webhooksPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(webhooksPath) && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</div>
);
};

View File

@ -1,121 +0,0 @@
import type { HTMLAttributes } from 'react';
import { Trans } from '@lingui/react/macro';
import { Braces, Globe2Icon, GroupIcon, Key, Settings2, User, Webhook } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TeamSettingsNavMobileProps = HTMLAttributes<HTMLDivElement>;
export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMobileProps) => {
const { pathname } = useLocation();
const params = useParams();
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
const settingsPath = `/t/${teamUrl}/settings`;
const preferencesPath = `/t/${teamUrl}/preferences`;
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
const membersPath = `/t/${teamUrl}/settings/members`;
const groupsPath = `/t/${teamUrl}/settings/groups`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
return (
<div
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
{...props}
>
<Link to={settingsPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(settingsPath) &&
pathname.split('/').length === 4 &&
'bg-secondary',
)}
>
<User className="mr-2 h-5 w-5" />
<Trans>General</Trans>
</Button>
</Link>
<Link to={preferencesPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(preferencesPath) &&
pathname.split('/').length === 4 &&
'bg-secondary',
)}
>
<Settings2 className="mr-2 h-5 w-5" />
<Trans>Preferences</Trans>
</Button>
</Link>
<Link to={publicProfilePath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
)}
>
<Globe2Icon className="mr-2 h-5 w-5" />
<Trans>Public Profile</Trans>
</Button>
</Link>
<Link to={membersPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(membersPath) && 'bg-secondary',
)}
>
<Key className="mr-2 h-5 w-5" />
<Trans>Members</Trans>
</Button>
</Link>
<Link to={groupsPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
>
<GroupIcon className="mr-2 h-5 w-5" />
<Trans>Groups</Trans>
</Button>
</Link>
<Link to={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
<Trans>API Tokens</Trans>
</Button>
</Link>
<Link to={webhooksPath}>
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith(webhooksPath) && 'bg-secondary',
)}
>
<Webhook className="mr-2 h-5 w-5" />
<Trans>Webhooks</Trans>
</Button>
</Link>
</div>
);
};

View File

@ -25,7 +25,7 @@ export const OrganisationBillingInvoicesTable = ({
}: OrganisationBillingInvoicesTableProps) => {
const { _ } = useLingui();
const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery(
const { data, isLoading, isLoadingError } = trpc.enterprise.billing.invoices.get.useQuery(
{
organisationId,
},

View File

@ -0,0 +1,205 @@
import { useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { EmailDomainStatus } from '@prisma/client';
import { CheckCircle2Icon, ClockIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationEmailDomainDeleteDialog } from '../dialogs/organisation-email-domain-delete-dialog';
export const OrganisationEmailDomainsDataTable = () => {
const { t } = useLingui();
const { toast } = useToast();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
const { mutate: verifyEmails, isPending: isVerifyingEmails } =
trpc.enterprise.organisation.emailDomain.verify.useMutation({
onSuccess: () => {
toast({
title: t`Email domains synced`,
description: t`All email domains have been synced successfully`,
});
},
});
const { data, isLoading, isLoadingError } =
trpc.enterprise.organisation.emailDomain.find.useQuery(
{
organisationId: organisation.id,
query: parsedSearchParams.query,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: t`Domain`,
accessorKey: 'domain',
},
{
header: t`Status`,
accessorKey: 'status',
cell: ({ row }) =>
match(row.original.status)
.with(EmailDomainStatus.ACTIVE, () => (
<Badge>
<CheckCircle2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</Badge>
))
.with(EmailDomainStatus.PENDING, () => (
<Badge variant="warning">
<ClockIcon className="mr-2 h-4 w-4 text-yellow-500 dark:text-yellow-200" />
<Trans>Pending</Trans>
</Badge>
))
.exhaustive(),
},
{
header: t`Emails`,
accessorKey: 'emailCount',
cell: ({ row }) => row.original.emailCount,
},
{
header: t`Actions`,
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
Manage
</Link>
</Button>
<OrganisationEmailDomainDeleteDialog
emailDomainId={row.original.id}
emailDomain={row.original.domain}
trigger={
<Button variant="destructive" title={t`Remove email domain`}>
<Trans>Delete</Trans>
</Button>
}
/>
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 1,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-20 rounded" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
<Skeleton className="h-10 w-20 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) =>
results.totalPages > 1 && (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)
}
</DataTable>
<AnimateGenericFadeInOut key={results.data.length}>
{results.data.length > 0 && (
<Alert
className="mt-2 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Sync Email Domains</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This will check and sync the status of all email domains for this organisation
</Trans>
</AlertDescription>
</div>
<Button
variant="outline"
loading={isVerifyingEmails}
onClick={() => {
verifyEmails({
organisationId: organisation.id,
});
}}
>
<Trans>Sync</Trans>
</Button>
</Alert>
)}
</AnimateGenericFadeInOut>
</>
);
};

View File

@ -1,6 +1,13 @@
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } from 'lucide-react';
import {
Building2Icon,
CreditCardIcon,
GroupIcon,
MailboxIcon,
Settings2Icon,
Users2Icon,
} from 'lucide-react';
import { FaUsers } from 'react-icons/fa6';
import { Link, NavLink, Outlet } from 'react-router';
@ -30,9 +37,30 @@ export default function SettingsLayout() {
icon: Building2Icon,
},
{
path: `/o/${organisation.url}/settings/preferences`,
path: `/o/${organisation.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
hideHighlight: true,
},
{
path: `/o/${organisation.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/o/${organisation.url}/settings/email-domains`,
label: t`Email Domains`,
icon: MailboxIcon,
},
{
path: `/o/${organisation.url}/settings/teams`,
@ -54,7 +82,20 @@ export default function SettingsLayout() {
label: t`Billing`,
icon: CreditCardIcon,
},
].filter((route) => (isBillingEnabled ? route : !route.path.includes('/billing')));
].filter((route) => {
if (!isBillingEnabled && route.path.includes('/billing')) {
return false;
}
if (
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
route.path.includes('/email-domains')
) {
return false;
}
return true;
});
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
return (
@ -93,12 +134,18 @@ export default function SettingsLayout() {
)}
>
{organisationSettingRoutes.map((route) => (
<NavLink to={route.path} className="group w-full justify-start" key={route.path}>
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className="group-aria-[current]:bg-secondary w-full justify-start"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.hideHighlight,
})}
>
<route.icon className="mr-2 h-5 w-5" />
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>

View File

@ -24,7 +24,7 @@ export default function TeamsSettingBillingPage() {
const organisation = useCurrentOrganisation();
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
trpc.billing.subscription.get.useQuery({
trpc.enterprise.billing.subscription.get.useQuery({
organisationId: organisation.id,
});

View File

@ -5,7 +5,6 @@ import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
@ -17,21 +16,19 @@ import {
BrandingPreferencesForm,
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import {
DocumentPreferencesForm,
type TDocumentPreferencesFormSchema,
} from '~/components/forms/document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Preferences');
return appMetaTags('Branding Preferences');
}
export default function OrganisationSettingsPreferencesPage() {
export default function OrganisationSettingsBrandingPage() {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const team = useOptionalCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
@ -46,51 +43,6 @@ export default function OrganisationSettingsPreferencesPage() {
const { mutateAsync: updateOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const onDocumentPreferencesFormSubmit = async (data: TDocumentPreferencesFormSchema) => {
try {
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
signatureTypes,
} = data;
if (
documentVisibility === null ||
documentLanguage === null ||
includeSenderDetails === null ||
includeSigningCertificate === null
) {
throw new Error('Should not be possible.');
}
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
toast({
title: t`Document preferences updated`,
description: t`Your document preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive',
});
}
};
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
@ -132,32 +84,21 @@ export default function OrganisationSettingsPreferencesPage() {
);
}
const settingsHeaderText = isPersonalLayoutMode ? t`Preferences` : t`Organisation Preferences`;
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general preferences`
: t`Here you can set preferences and defaults for your organisation. Teams will inherit these settings by default.`;
? t`Here you can set your general branding preferences`
: team
? t`Here you can set branding preferences for your team`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div className="max-w-2xl">
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<section>
<DocumentPreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
</section>
{organisationWithSettings.organisationClaim.flags.allowCustomBranding ||
!IS_BILLING_ENABLED() ? (
<section>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
className="mt-8"
/>
<BrandingPreferencesForm
context="Organisation"
settings={organisationWithSettings.organisationGlobalSettings}

View File

@ -0,0 +1,116 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
DocumentPreferencesForm,
type TDocumentPreferencesFormSchema,
} from '~/components/forms/document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Document Preferences');
}
export default function OrganisationSettingsDocumentPage() {
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { toast } = useToast();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } =
trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const { mutateAsync: updateOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const onDocumentPreferencesFormSubmit = async (data: TDocumentPreferencesFormSchema) => {
try {
const {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
signatureTypes,
} = data;
if (
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null
) {
throw new Error('Should not be possible.');
}
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
},
});
toast({
title: t`Document preferences updated`,
description: t`Your document preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive',
});
}
};
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
return (
<div className="max-w-2xl">
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
<section>
<DocumentPreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
</section>
</div>
);
}

View File

@ -0,0 +1,207 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { OrganisationEmailCreateDialog } from '~/components/dialogs/organisation-email-create-dialog';
import { OrganisationEmailDeleteDialog } from '~/components/dialogs/organisation-email-delete-dialog';
import { OrganisationEmailDomainDeleteDialog } from '~/components/dialogs/organisation-email-domain-delete-dialog';
import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organisation-email-domain-records-dialog';
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
const emailDomainId = params.id;
const { data: emailDomain, isLoading: isLoadingEmailDomain } =
trpc.enterprise.organisation.emailDomain.get.useQuery(
{
emailDomainId,
},
{
enabled: !!emailDomainId,
},
);
const emailColumns = useMemo(() => {
return [
{
header: t`Name`,
accessorKey: 'emailName',
},
{
header: t`Email`,
accessorKey: 'email',
},
{
header: t`Actions`,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>
<Trans>Actions</Trans>
</DropdownMenuLabel>
<OrganisationEmailUpdateDialog
organisationEmail={row.original}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<EditIcon className="mr-2 h-4 w-4" />
<Trans>Update</Trans>
</DropdownMenuItem>
}
/>
<OrganisationEmailDeleteDialog
emailId={row.original.id}
email={row.original.email}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
),
},
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
}, [organisation]);
if (!IS_BILLING_ENABLED()) {
return null;
}
if (isLoadingEmailDomain) {
return <SpinnerBox className="py-32" />;
}
// Todo: Update UI, currently out of place.
if (!emailDomain) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Email domain not found`,
subHeading: msg`404 Email domain not found`,
message: msg`The email domain you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to={`/o/${organisation.url}/settings/email-domains`}>
<Trans>Go back</Trans>
</Link>
</Button>
}
secondaryButton={null}
/>
);
}
const records = generateEmailDomainRecords(emailDomain.selector, emailDomain.publicKey);
return (
<div>
<SettingsHeader
title={t`Email Domain Settings`}
subtitle={t`Manage your email domain settings.`}
>
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
</SettingsHeader>
<div className="mt-4">
<label className="text-sm font-medium leading-none">
<Trans>Emails</Trans>
</label>
<div className="my-2">
<DataTable columns={emailColumns} data={emailDomain.emails} />
</div>
</div>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>DNS Records</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View the DNS records for this email domain</Trans>
</AlertDescription>
</div>
<OrganisationEmailDomainRecordsDialog
records={records}
trigger={
<Button variant="secondary">
<Trans>View DNS Records</Trans>
</Button>
}
/>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Delete email domain</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>This will remove all emails associated with this email domain</Trans>
</AlertDescription>
</div>
<OrganisationEmailDomainDeleteDialog
emailDomainId={emailDomainId}
emailDomain={emailDomain.domain}
trigger={
<Button variant="destructive" title={t`Remove email domain`}>
<Trans>Delete Email Domain</Trans>
</Button>
}
/>
</Alert>
</div>
);
}

View File

@ -0,0 +1,81 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { Link } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Email Domains');
}
export default function OrganisationSettingsEmailDomains() {
const { t } = useLingui();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
if (!IS_BILLING_ENABLED()) {
return null;
}
return (
<div>
<SettingsHeader
title={t`Email Domains`}
subtitle={t`Here you can add email domains to your organisation.`}
>
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
</SettingsHeader>
{isEmailDomainsEnabled ? (
<section>
<OrganisationEmailDomainsDataTable />
</section>
) : (
<Alert
className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Email Domains</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Currently email domains can only be configured for Platform and above plans.
</Trans>
</AlertDescription>
</div>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link
to={
isPersonalLayoutMode
? '/settings/billing'
: `/o/${organisation.url}/settings/billing`
}
>
<Trans>Update Billing</Trans>
</Link>
</Button>
)}
</Alert>
)}
</div>
);
}

View File

@ -0,0 +1,80 @@
import { useLingui } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
EmailPreferencesForm,
type TEmailPreferencesFormSchema,
} from '~/components/forms/email-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Email Preferences');
}
export default function OrganisationSettingsGeneral() {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } =
trpc.organisation.get.useQuery({
organisationReference: organisation.url,
});
const { mutateAsync: updateOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
try {
const { emailId, emailReplyTo, emailDocumentSettings } = data;
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
emailId,
emailReplyTo: emailReplyTo || null,
// emailReplyToName,
emailDocumentSettings,
},
});
toast({
title: t`Email preferences updated`,
description: t`Your email preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive',
});
}
};
if (isLoadingOrganisation || !organisationWithSettings) {
return <SpinnerBox />;
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Email Preferences`}
subtitle={t`You can manage your email preferences here`}
/>
<section>
<EmailPreferencesForm
canInherit={false}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onEmailPreferencesSubmit}
/>
</section>
</div>
);
}

View File

@ -0,0 +1,5 @@
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
export { meta };
export default BrandingPage;

View File

@ -0,0 +1,5 @@
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
export { meta };
export default DocumentPage;

View File

@ -0,0 +1,5 @@
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
export { meta };
export default EmailPage;

View File

@ -1,5 +0,0 @@
import PreferencesPage, { meta } from '../../o.$orgUrl.settings.preferences';
export { meta };
export default PreferencesPage;

View File

@ -1,15 +1,23 @@
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Link, Outlet, redirect } from 'react-router';
import { Trans, useLingui } from '@lingui/react/macro';
import {
BracesIcon,
Globe2Icon,
GroupIcon,
Settings2Icon,
SettingsIcon,
Users2Icon,
WebhookIcon,
} from 'lucide-react';
import { Link, NavLink, Outlet, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop';
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@ -37,8 +45,64 @@ export async function clientLoader() {
}
export default function TeamsSettingsLayout() {
const { t } = useLingui();
const team = useCurrentTeam();
const teamSettingRoutes = [
{
path: `/t/${team.url}/settings`,
label: t`General`,
icon: SettingsIcon,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Preferences`,
icon: Settings2Icon,
isSubNavParent: true,
},
{
path: `/t/${team.url}/settings/document`,
label: t`Document`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/branding`,
label: t`Branding`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/email`,
label: t`Email`,
isSubNav: true,
},
{
path: `/t/${team.url}/settings/public-profile`,
label: t`Public Profile`,
icon: Globe2Icon,
},
{
path: `/t/${team.url}/settings/members`,
label: t`Members`,
icon: Users2Icon,
},
{
path: `/t/${team.url}/settings/groups`,
label: t`Groups`,
icon: GroupIcon,
},
{
path: `/t/${team.url}/settings/tokens`,
label: t`API Tokens`,
icon: BracesIcon,
},
{
path: `/t/${team.url}/settings/webhooks`,
label: t`Webhooks`,
icon: WebhookIcon,
},
];
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
return (
<GenericErrorLayout
@ -69,8 +133,29 @@ export default function TeamsSettingsLayout() {
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<TeamSettingsNavDesktop className="hidden md:col-span-3 md:flex" />
<TeamSettingsNavMobile className="col-span-12 mb-8 md:hidden" />
<div
className={cn(
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
)}
>
{teamSettingRoutes.map((route) => (
<NavLink
to={route.path}
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
key={route.path}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
})}
>
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
<Trans>{route.label}</Trans>
</Button>
</NavLink>
))}
</div>
<div className="col-span-12 md:col-span-9">
<Outlet />

View File

@ -0,0 +1,94 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
BrandingPreferencesForm,
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Branding Preferences');
}
export default function TeamsSettingsPage() {
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.id,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo || null,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
},
});
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong`,
description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive',
});
}
};
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
/>
<section>
<BrandingPreferencesForm
canInherit={true}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
</section>
</div>
);
}

View File

@ -2,14 +2,9 @@ import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
BrandingPreferencesForm,
type TBrandingPreferencesFormSchema,
} from '~/components/forms/branding-preferences-form';
import {
DocumentPreferencesForm,
type TDocumentPreferencesFormSchema,
@ -19,7 +14,7 @@ import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Preferences');
return appMetaTags('Document Preferences');
}
export default function TeamsSettingsPage() {
@ -39,6 +34,8 @@ export default function TeamsSettingsPage() {
const {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
signatureTypes,
@ -49,6 +46,8 @@ export default function TeamsSettingsPage() {
data: {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
...(signatureTypes.length === 0
@ -78,43 +77,6 @@ export default function TeamsSettingsPage() {
}
};
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo === null) {
uploadedBrandingLogo = '';
}
await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo || null,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
},
});
toast({
title: t`Branding preferences updated`,
description: t`Your branding preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong`,
description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive',
});
}
};
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
@ -126,7 +88,7 @@ export default function TeamsSettingsPage() {
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Team Preferences`}
title={t`Document Preferences`}
subtitle={t`Here you can set preferences and defaults for your team.`}
/>
@ -137,21 +99,6 @@ export default function TeamsSettingsPage() {
onFormSubmit={onDocumentPreferencesSubmit}
/>
</section>
<SettingsHeader
title={t`Branding Preferences`}
subtitle={t`Here you can set preferences and defaults for branding.`}
className="mt-8"
/>
<section>
<BrandingPreferencesForm
canInherit={true}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
</section>
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useLingui } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
EmailPreferencesForm,
type TEmailPreferencesFormSchema,
} from '~/components/forms/email-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
}
export default function TeamEmailSettingsGeneral() {
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
teamReference: team.url,
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
try {
const { emailId, emailReplyTo, emailDocumentSettings } = data;
await updateTeamSettings({
teamId: team.id,
data: {
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
},
});
toast({
title: t`Email preferences updated`,
description: t`Your email preferences have been updated`,
});
} catch (err) {
toast({
title: t`Something went wrong!`,
description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive',
});
}
};
if (isLoadingTeam || !teamWithSettings) {
return <SpinnerBox />;
}
return (
<div className="max-w-2xl">
<SettingsHeader
title={t`Email Preferences`}
subtitle={t`You can manage your email preferences here`}
/>
<section>
<EmailPreferencesForm
canInherit={true}
settings={teamWithSettings.teamSettings}
onFormSubmit={onEmailPreferencesSubmit}
/>
</section>
</div>
);
}

876
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -244,7 +244,7 @@ test.describe('Signing Certificate Tests', () => {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
await page

View File

@ -8,7 +8,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
@ -16,7 +16,7 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/preferences`,
redirectPath: `/o/${organisation.url}/settings/document`,
});
// Update document preferences.
@ -24,26 +24,25 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Only managers and above can' }).click();
await page.getByRole('combobox').filter({ hasText: 'English' }).click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByTestId('signature-types-combobox').click();
// Set default timezone
await page.getByRole('combobox').filter({ hasText: 'Local timezone' }).click();
await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
await page.getByTestId('signature-types-trigger').click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Upload' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByTestId('include-sender-details-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('combobox').filter({ hasText: 'Yes' }).click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Update branding.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).nth(1).click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
@ -51,34 +50,30 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
// Check that the team settings have inherited these values.
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
expect(teamSettings.documentLanguage).toEqual('de');
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(teamSettings.includeSenderDetails).toEqual(false);
expect(teamSettings.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team settings
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
await page
.getByRole('group')
.locator('div')
.filter({
hasText: 'Default Document Visibility',
})
.getByRole('combobox')
.click();
await page.getByTestId('document-visibility-trigger').click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Default Document Language' })
.getByRole('combobox')
.click();
await page.getByTestId('document-language-trigger').click();
await page.getByRole('option', { name: 'Polish' }).click();
// Override team timezone settings
await page.getByTestId('document-timezone-trigger').click();
await page.getByRole('option', { name: 'Europe/London' }).click();
// Override team date format settings
await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@ -89,6 +84,8 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
// Check that the team settings have inherited/overriden the correct values.
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
@ -110,4 +107,228 @@ test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Update branding preferences.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team branding settings
await page.goto(`/t/${team.url}/settings/branding`);
// Override team settings with different values
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have overridden the organisation values.
expect(updatedTeamSettings.brandingEnabled).toEqual(true);
expect(updatedTeamSettings.brandingUrl).toEqual('https://example.com');
expect(updatedTeamSettings.brandingCompanyDetails).toEqual('UpdatedBrandDetails');
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(2000);
const inheritedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings now inherit from organisation again.
expect(inheritedTeamSettings.brandingEnabled).toEqual(true);
expect(inheritedTeamSettings.brandingUrl).toEqual('https://documenso.com');
expect(inheritedTeamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Verify that a document can be created successfully with the branding settings
const document = await seedTeamDocumentWithMeta(team);
// Confirm the document was created successfully with the team's branding settings
expect(document).toBeDefined();
expect(document.teamId).toEqual(team.id);
});
test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/email`,
});
// Update email preferences at organisation level.
// Set reply to email
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('organisation@documenso.com');
// Update email document settings by enabling/disabling some checkboxes
await page.getByRole('checkbox', { name: 'Send recipient signed email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document pending email' }).uncheck();
await page.getByRole('checkbox', { name: 'Send document deleted email' }).uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.emailReplyTo).toEqual('organisation@documenso.com');
expect(teamSettings.emailDocumentSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false, // unchecked
documentPending: false, // unchecked
documentCompleted: true,
documentDeleted: false, // unchecked
ownerDocumentCompleted: true,
});
// Edit the team email settings
await page.goto(`/t/${team.url}/settings/email`);
// Override team settings with different values
await page.getByRole('textbox', { name: 'Reply to email' }).click();
await page.getByRole('textbox', { name: 'Reply to email' }).fill('team@example.com');
// Change email document settings inheritance to controlled
await page.getByRole('combobox').filter({ hasText: 'Inherit from organisation' }).click();
await page.getByRole('option', { name: 'Override organisation settings' }).click();
// Update some email settings
await page.getByRole('checkbox', { name: 'Send recipient signing request email' }).uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email', exact: true })
.uncheck();
await page
.getByRole('checkbox', { name: 'Send document completed email to the owner' })
.uncheck();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have overridden the organisation values.
expect(updatedTeamSettings.emailReplyTo).toEqual('team@example.com');
expect(updatedTeamSettings.emailDocumentSettings).toEqual({
recipientSigned: true,
recipientSigningRequest: false,
recipientRemoved: true,
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerDocumentCompleted: false,
});
// Verify that a document can be created successfully with the team email settings
const teamOverrideDocument = await seedTeamDocumentWithMeta(team);
const teamOverrideDocumentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: teamOverrideDocument.id,
},
});
expect(teamOverrideDocumentMeta.emailReplyTo).toEqual('team@example.com');
expect(teamOverrideDocumentMeta.emailSettings).toEqual({
recipientSigned: true,
recipientSigningRequest: false,
recipientRemoved: true,
documentPending: true,
documentCompleted: false,
documentDeleted: true,
ownerDocumentCompleted: false,
});
// Test inheritance by setting team back to inherit from organisation
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(1000);
const inheritedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings now inherit from organisation again.
expect(inheritedTeamSettings.emailReplyTo).toEqual('organisation@documenso.com');
expect(inheritedTeamSettings.emailDocumentSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false,
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerDocumentCompleted: true,
});
// Verify that a document can be created successfully with the email settings
const document = await seedTeamDocumentWithMeta(team);
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: document.id,
},
});
expect(documentMeta.emailReplyTo).toEqual('organisation@documenso.com');
expect(documentMeta.emailSettings).toEqual({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: false,
documentPending: false,
documentCompleted: true,
documentDeleted: false,
ownerDocumentCompleted: true,
});
});

View File

@ -15,7 +15,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const document = await seedTeamDocumentWithMeta(team);
@ -45,17 +45,17 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await page.getByTestId('signature-types-trigger').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
@ -112,17 +112,17 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
redirectPath: `/t/${team.url}/settings/document`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
await page.goto(`/t/${team.url}/settings/document`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await page.getByTestId('signature-types-trigger').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();

View File

@ -0,0 +1,154 @@
import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { generateKeyPair } from 'crypto';
import { promisify } from 'util';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
export const getSesClient = () => {
const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY');
const region = env('NEXT_PRIVATE_SES_REGION');
if (!accessKeyId || !secretAccessKey || !region) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Missing AWS SES credentials',
});
}
return new SESv2Client({
region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
};
/**
* Removes first and last line, then removes all newlines
*/
const flattenKey = (key: string) => {
return key.trim().split('\n').slice(1, -1).join('');
};
export async function verifyDomainWithDKIM(domain: string, selector: string, privateKey: string) {
const command = new CreateEmailIdentityCommand({
EmailIdentity: domain,
DkimSigningAttributes: {
DomainSigningSelector: selector,
DomainSigningPrivateKey: privateKey,
},
});
return await getSesClient().send(command);
}
type CreateEmailDomainOptions = {
domain: string;
organisationId: string;
};
type DomainRecord = {
name: string;
value: string;
type: string;
};
export const createEmailDomain = async ({ domain, organisationId }: CreateEmailDomainOptions) => {
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const selector = `documenso-${organisationId}`.replace(/[_.]/g, '-');
const recordName = `${selector}._domainkey.${domain}`;
// Check if domain already exists
const existingDomain = await prisma.emailDomain.findUnique({
where: {
domain,
},
});
if (existingDomain) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in database',
});
}
// Generate DKIM key pair
const generateKeyPairAsync = promisify(generateKeyPair);
const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
// Format public key for DNS record
const publicKeyFlattened = flattenKey(publicKey);
const privateKeyFlattened = flattenKey(privateKey);
// Create DNS records
const records: DomainRecord[] = generateEmailDomainRecords(recordName, publicKeyFlattened);
const encryptedPrivateKey = symmetricEncrypt({
key: encryptionKey,
data: privateKeyFlattened,
});
const emailDomain = await prisma.$transaction(async (tx) => {
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
if (err.name === 'AlreadyExistsException') {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'Domain already exists in SES',
});
}
throw err;
});
// Create email domain record.
return await tx.emailDomain.create({
data: {
id: generateDatabaseId('email_domain'),
domain,
status: EmailDomainStatus.PENDING,
organisationId,
selector: recordName,
publicKey: publicKeyFlattened,
privateKey: encryptedPrivateKey,
},
select: {
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
emails: true,
},
});
});
return {
emailDomain,
records,
};
};

View File

@ -0,0 +1,52 @@
import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getSesClient } from './create-email-domain';
type DeleteEmailDomainOptions = {
emailDomainId: string;
};
/**
* Delete the email domain and SES email identity.
*
* Permission is assumed to be checked in the caller.
*/
export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions) => {
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
await sesClient
.send(
new DeleteEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
)
.catch((err) => {
console.error(err);
// Do nothing if it no longer exists in SES.
if (err.name === 'NotFoundException') {
return;
}
});
await prisma.emailDomain.delete({
where: {
id: emailDomainId,
},
});
};

View File

@ -0,0 +1,45 @@
import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
import { EmailDomainStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getSesClient } from './create-email-domain';
export const verifyEmailDomain = async (emailDomainId: string) => {
const emailDomain = await prisma.emailDomain.findUnique({
where: {
id: emailDomainId,
},
});
if (!emailDomain) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email domain not found',
});
}
const sesClient = getSesClient();
const response = await sesClient.send(
new GetEmailIdentityCommand({
EmailIdentity: emailDomain.domain,
}),
);
const isVerified = response.VerificationStatus === 'SUCCESS';
const updatedEmailDomain = await prisma.emailDomain.update({
where: {
id: emailDomainId,
},
data: {
status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
},
});
return {
emailDomain: updatedEmailDomain,
isVerified,
};
};

View File

@ -3,6 +3,11 @@ import { env } from '../utils/env';
export const FROM_ADDRESS = env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com';
export const FROM_NAME = env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso';
export const DOCUMENSO_INTERNAL_EMAIL = {
name: FROM_NAME,
address: FROM_ADDRESS,
};
export const SERVICE_USER_EMAIL = 'serviceaccount@documenso.com';
export const EMAIL_VERIFICATION_STATE = {

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -43,11 +42,13 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { documentMeta, user: documentOwner } = document;
@ -59,9 +60,7 @@ export const run = async ({
return;
}
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
// Send cancellation emails to all recipients who have been sent the document or viewed it
const recipientsToNotify = document.recipients.filter(
@ -82,9 +81,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -95,10 +94,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" Cancelled`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -56,7 +55,8 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -80,29 +80,24 @@ export const run = async ({
organisationUrl: organisation.url,
});
const lang = settings.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -52,7 +51,8 @@ export const run = async ({
},
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -76,28 +76,23 @@ export const run = async ({
organisationUrl: organisation.url,
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`A member has left your organisation`),
html,
text,

View File

@ -8,7 +8,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -71,17 +70,18 @@ export const run = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const template = createElement(DocumentRecipientSignedEmailTemplate, {
documentName: document.title,
@ -92,9 +92,9 @@ export const run = async ({
await io.runTask('send-recipient-signed-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -105,10 +105,7 @@ export const run = async ({
name: owner.name ?? '',
address: owner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`${recipientReference} has signed "${document.title}"`),
html,
text,

View File

@ -10,7 +10,7 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -52,7 +52,7 @@ export const run = async ({
}),
]);
const { documentMeta, user: documentOwner } = document;
const { user: documentOwner } = document;
const isEmailEnabled = extractDerivedDocumentEmailSettings(
document.documentMeta,
@ -62,16 +62,16 @@ export const run = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
@ -84,9 +84,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang, branding }),
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -97,10 +97,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${document.title}" - Rejection Confirmed`),
html,
text,
@ -120,9 +118,9 @@ export const run = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(ownerTemplate, { lang, branding }),
renderEmailWithI18N(ownerTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(ownerTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -133,10 +131,7 @@ export const run = async ({
name: documentOwner.name || '',
address: documentOwner.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: DOCUMENSO_INTERNAL_EMAIL, // Purposefully using internal email here.
subject: i18n._(msg`Document "${document.title}" - Rejected by ${recipient.name}`),
html,
text,

View File

@ -15,7 +15,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -80,12 +79,15 @@ export const run = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const customEmail = document?.documentMeta;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@ -95,9 +97,7 @@ export const run = async ({
const { email, name } = recipient;
const selfSigner = email === user.email;
const lang = documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const recipientActionVerb = i18n
._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
@ -166,9 +166,9 @@ export const run = async ({
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -179,10 +179,8 @@ export const run = async ({
name: recipient.name,
address: recipient.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,

View File

@ -13,7 +13,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import { AppError } from '../../../errors/app-error';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
@ -162,24 +161,23 @@ export const run = async ({
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
},
});
const lang = template.templateMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const [html, text] = await Promise.all([
renderEmailWithI18N(completionTemplate, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(completionTemplate, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -190,10 +188,7 @@ export const run = async ({
name: user.name || '',
address: user.email,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
html,
text,

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/client-sesv2": "^3.410.0",
"@aws-sdk/cloudfront-signer": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@aws-sdk/signature-v4-crt": "^3.410.0",

View File

@ -23,6 +23,8 @@ export type CreateDocumentMetaOptions = {
password?: string;
dateFormat?: string;
redirectUrl?: string;
emailId?: string | null;
emailReplyTo?: string | null;
emailSettings?: TDocumentEmailSettings;
signingOrder?: DocumentSigningOrder;
allowDictateNextSigner?: boolean;
@ -46,6 +48,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -54,7 +58,7 @@ export const upsertDocumentMeta = async ({
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { documentWhereInput } = await getDocumentWhereInput({
const { documentWhereInput, team } = await getDocumentWhereInput({
documentId,
userId,
teamId,
@ -75,6 +79,22 @@ export const upsertDocumentMeta = async ({
const { documentMeta: originalDocumentMeta } = document;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const upsertedDocumentMeta = await tx.documentMeta.upsert({
where: {
@ -90,6 +110,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,
@ -106,6 +128,8 @@ export const upsertDocumentMeta = async ({
redirectUrl,
signingOrder,
allowDictateNextSigner,
emailId,
emailReplyTo,
emailSettings,
distributionMethod,
typedSignatureEnabled,

View File

@ -24,6 +24,7 @@ import {
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
@ -134,6 +135,24 @@ export const createDocumentV2 = async ({
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -148,15 +167,7 @@ export const createDocumentV2 = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
...meta,
signingOrder: meta?.signingOrder || undefined,
emailSettings: meta?.emailSettings || undefined,
language: meta?.language || settings.documentLanguage,
typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});

View File

@ -15,6 +15,7 @@ import {
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
@ -30,6 +31,7 @@ export type CreateDocumentOptions = {
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
};
@ -44,6 +46,7 @@ export const createDocument = async ({
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
@ -101,6 +104,10 @@ export const createDocument = async ({
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
@ -117,13 +124,9 @@ export const createDocument = async ({
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: {
language: settings.documentLanguage,
timezone: timezone,
typedSignatureEnabled: settings.typedSignatureEnabled,
uploadSignatureEnabled: settings.uploadSignatureEnabled,
drawSignatureEnabled: settings.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
}),
},
},
});

View File

@ -10,7 +10,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -151,11 +150,13 @@ const handleDocumentOwnerDelete = async ({
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
// Soft delete completed documents.
@ -232,28 +233,24 @@ const handleDocumentOwnerDelete = async ({
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -5,7 +5,6 @@ import { DocumentStatus, OrganisationType, RecipientRole, SigningStatus } from '
import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
@ -96,12 +95,15 @@ export const resendDocument = async ({
return;
}
const { branding, settings, organisationType } = await getEmailContext({
source: {
type: 'team',
teamId: document.teamId,
},
});
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
@ -109,8 +111,7 @@ export const resendDocument = async ({
return;
}
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
@ -169,11 +170,11 @@ export const resendDocument = async ({
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
@ -186,10 +187,8 @@ export const resendDocument = async ({
address: email,
name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: customEmail?.subject
? renderCustomEmailTemplate(
i18n._(msg`Reminder: ${customEmail.subject}`),

View File

@ -14,7 +14,6 @@ import { extractDerivedDocumentEmailSettings } from '../../types/document-email'
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { env } from '../../utils/env';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
@ -54,11 +53,13 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { user: owner } = document;
@ -97,18 +98,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
downloadLink: documentOwnerDownloadLink,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -117,10 +116,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: owner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Signing Complete!`),
html,
text,
@ -174,18 +171,16 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
: undefined,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -194,10 +189,8 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
address: recipient.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject:
isDirectTemplate && document.documentMeta?.subject
? renderCustomEmailTemplate(document.documentMeta.subject, customEmailTemplate)

View File

@ -10,7 +10,6 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@ -44,11 +43,13 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
return;
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { email, name } = document.user;
@ -61,28 +62,23 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name: name || '',
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document Deleted!`),
html,
text,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@ -46,11 +45,13 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
throw new Error('Document has no recipients');
}
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
@ -72,28 +73,24 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: email,
name,
},
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,

View File

@ -9,7 +9,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
@ -41,11 +40,13 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
});
}
const { branding, settings } = await getEmailContext({
const { branding, settings, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const { status, user } = document;
@ -92,10 +93,8 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,

View File

@ -1,5 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@ -128,9 +129,11 @@ export const updateDocument = async ({
const isTitleSame = data.title === undefined || data.title === document.title;
const isExternalIdSame = data.externalId === undefined || data.externalId === document.externalId;
const isGlobalAccessSame =
documentGlobalAccessAuth === undefined || documentGlobalAccessAuth === newGlobalAccessAuth;
documentGlobalAccessAuth === undefined ||
isDeepEqual(documentGlobalAccessAuth, newGlobalAccessAuth);
const isGlobalActionSame =
documentGlobalActionAuth === undefined || documentGlobalActionAuth === newGlobalActionAuth;
documentGlobalActionAuth === undefined ||
isDeepEqual(documentGlobalActionAuth, newGlobalActionAuth);
const isDocumentVisibilitySame =
data.visibility === undefined || data.visibility === document.visibility;

View File

@ -1,16 +1,34 @@
import type { BrandingSettings } from '@documenso/email/providers/branding';
import { prisma } from '@documenso/prisma';
import type { OrganisationType } from '@documenso/prisma/client';
import { type OrganisationClaim, type OrganisationGlobalSettings } from '@documenso/prisma/client';
import type {
DocumentMeta,
EmailDomain,
Organisation,
OrganisationEmail,
OrganisationType,
} from '@documenso/prisma/client';
import {
EmailDomainStatus,
type OrganisationClaim,
type OrganisationGlobalSettings,
} from '@documenso/prisma/client';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
organisationGlobalSettingsToBranding,
teamGlobalSettingsToBranding,
} from '../../utils/team-global-settings-to-branding';
import { getTeamSettings } from '../team/get-team-settings';
import { extractDerivedTeamSettings } from '../../utils/teams';
type GetEmailContextOptions = {
type EmailMetaOption = Partial<Pick<DocumentMeta, 'emailId' | 'emailReplyTo' | 'language'>>;
type BaseGetEmailContextOptions = {
/**
* The source to extract the email context from.
* - "Team" will use the team settings followed by the inherited organisation settings
* - "Organisation" will use the organisation settings
*/
source:
| {
type: 'team';
@ -20,37 +38,112 @@ type GetEmailContextOptions = {
type: 'organisation';
organisationId: string;
};
/**
* The email type being sent, used to determine what email sender and language to use.
* - INTERNAL: Emails to users, such as team invites, etc.
* - RECIPIENT: Emails to recipients, such as document sent, document signed, etc.
*/
emailType: 'INTERNAL' | 'RECIPIENT';
};
type InternalGetEmailContextOptions = BaseGetEmailContextOptions & {
emailType: 'INTERNAL';
meta?: EmailMetaOption | null;
};
type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
emailType: 'RECIPIENT';
/**
* Force meta options as a typesafe way to ensure developers don't forget to
* pass it in if it is available.
*/
meta: EmailMetaOption | null;
};
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
type EmailContextResponse = {
allowedEmails: OrganisationEmail[];
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
organisationType: OrganisationType;
senderEmail: {
name: string;
address: string;
};
replyToEmail: string | undefined;
emailLanguage: string;
};
export const getEmailContext = async (
options: GetEmailContextOptions,
): Promise<EmailContextResponse> => {
const { source } = options;
const { source, meta } = options;
let emailContext: Omit<EmailContextResponse, 'senderEmail' | 'replyToEmail' | 'emailLanguage'>;
if (source.type === 'organisation') {
emailContext = await handleOrganisationEmailContext(source.organisationId);
} else {
emailContext = await handleTeamEmailContext(source.teamId);
}
const emailLanguage = meta?.language || emailContext.settings.documentLanguage;
// Immediate return for internal emails.
if (options.emailType === 'INTERNAL') {
return {
...emailContext,
senderEmail: DOCUMENSO_INTERNAL_EMAIL,
replyToEmail: undefined,
emailLanguage, // Not sure if we want to use this for internal emails.
};
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId || emailContext.settings.emailId;
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);
// Reset the emailId to null if not found.
if (!foundSenderEmail) {
emailContext.settings.emailId = null;
}
const senderEmail = foundSenderEmail
? {
name: foundSenderEmail.emailName,
address: foundSenderEmail.email,
}
: DOCUMENSO_INTERNAL_EMAIL;
return {
...emailContext,
senderEmail,
replyToEmail,
emailLanguage,
};
};
const handleOrganisationEmailContext = async (organisationId: string) => {
const organisation = await prisma.organisation.findFirst({
where:
source.type === 'organisation'
? {
id: source.organisationId,
}
: {
teams: {
some: {
id: source.teamId,
},
},
},
where: {
id: organisationId,
},
include: {
subscription: true,
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
omit: {
privateKey: true,
},
include: {
emails: true,
},
},
},
});
@ -60,27 +153,64 @@ export const getEmailContext = async (
const claims = organisation.organisationClaim;
if (source.type === 'organisation') {
return {
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
settings: organisation.organisationGlobalSettings,
claims,
organisationType: organisation.type,
};
}
const teamSettings = await getTeamSettings({
teamId: source.teamId,
});
const allowedEmails = getAllowedEmails(organisation);
return {
allowedEmails,
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
settings: organisation.organisationGlobalSettings,
claims,
organisationType: organisation.type,
};
};
const handleTeamEmailContext = async (teamId: number) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
},
include: {
teamGlobalSettings: true,
organisation: {
include: {
organisationClaim: true,
organisationGlobalSettings: true,
emailDomains: {
omit: {
privateKey: true,
},
include: {
emails: true,
},
},
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const organisation = team.organisation;
const claims = organisation.organisationClaim;
const allowedEmails = getAllowedEmails(organisation);
const teamSettings = extractDerivedTeamSettings(
organisation.organisationGlobalSettings,
team.teamGlobalSettings,
);
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(
teamSettings,
source.teamId,
teamId,
claims.flags.hidePoweredBy ?? false,
),
settings: teamSettings,
@ -88,3 +218,18 @@ export const getEmailContext = async (
organisationType: organisation.type,
};
};
const getAllowedEmails = (
organisation: Organisation & {
emailDomains: (Pick<EmailDomain, 'status'> & { emails: OrganisationEmail[] })[];
organisationClaim: OrganisationClaim;
},
) => {
if (!organisation.organisationClaim.flags.emailDomains) {
return [];
}
return organisation.emailDomains
.filter((emailDomain) => emailDomain.status === EmailDomainStatus.ACTIVE)
.flatMap((emailDomain) => emailDomain.emails);
};

View File

@ -9,7 +9,6 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/str
import { mailer } from '@documenso/email/mailer';
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
@ -190,7 +189,8 @@ export const sendOrganisationMemberInviteEmail = async ({
organisationName: organisation.name,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
@ -199,24 +199,21 @@ export const sendOrganisationMemberInviteEmail = async ({
const [html, text] = await Promise.all([
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
lang: emailLanguage,
branding,
}),
renderEmailWithI18N(template, {
lang: settings.documentLanguage,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(settings.documentLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
html,
text,

View File

@ -11,7 +11,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
@ -125,31 +124,29 @@ export const deleteDocumentRecipient = async ({
assetBaseUrl,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

@ -25,7 +25,6 @@ import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { canRecipientBeModified } from '../../utils/recipients';
@ -71,13 +70,6 @@ export const setDocumentRecipients = async ({
},
});
const { branding, settings } = await getEmailContext({
source: {
type: 'team',
teamId,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@ -97,6 +89,15 @@ export const setDocumentRecipients = async ({
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: document.documentMeta || null,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@ -302,24 +303,20 @@ export const setDocumentRecipients = async ({
assetBaseUrl,
});
const lang = document.documentMeta?.language ?? settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,

View File

@ -8,14 +8,12 @@ import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import type { SupportedLanguageCodes } from '../../constants/i18n';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
@ -122,33 +120,28 @@ export const sendTeamEmailVerificationEmail = async (email: string, token: strin
token,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: team.id,
},
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const lang = settings.documentLanguage as SupportedLanguageCodes;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang,
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(
msg`A request to use your email has been initiated by ${team.name} on Documenso`,
),

View File

@ -5,7 +5,6 @@ import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
@ -27,7 +26,8 @@ export type DeleteTeamEmailOptions = {
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId,
@ -82,24 +82,19 @@ export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamE
teamUrl: team.url,
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: {
address: team.organisation.owner.email,
name: team.organisation.owner.name ?? '',
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Team email has been revoked for ${team.name}`),
html,
text,

View File

@ -7,7 +7,6 @@ import { uniqueBy } from 'remeda';
import { mailer } from '@documenso/email/mailer';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
@ -130,28 +129,24 @@ export const sendTeamDeleteEmail = async ({
teamUrl: team.url,
});
const { branding, settings } = await getEmailContext({
const { branding, emailLanguage, senderEmail } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId,
},
});
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang, branding }),
renderEmailWithI18N(template, { lang, branding, plainText: true }),
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(lang);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
from: senderEmail,
subject: i18n._(msg`Team "${team.name}" has been deleted on Documenso`),
html,
text,

View File

@ -33,5 +33,13 @@ export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions
const organisationSettings = team.organisation.organisationGlobalSettings;
const teamSettings = team.teamGlobalSettings;
// Override branding settings if inherit is enabled.
if (teamSettings.brandingEnabled === null) {
teamSettings.brandingEnabled = organisationSettings.brandingEnabled;
teamSettings.brandingLogo = organisationSettings.brandingLogo;
teamSettings.brandingUrl = organisationSettings.brandingUrl;
teamSettings.brandingCompanyDetails = organisationSettings.brandingCompanyDetails;
}
return extractDerivedTeamSettings(organisationSettings, teamSettings);
};

View File

@ -3,7 +3,6 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
DocumentStatus,
FieldType,
@ -25,8 +24,6 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth';
@ -38,6 +35,7 @@ import {
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { isRequiredField } from '../../utils/advanced-fields-helpers';
import { extractDerivedDocumentMeta } from '../../utils/document';
import type { CreateDocumentAuditLogDataResponse } from '../../utils/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
@ -45,7 +43,6 @@ import {
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { sendDocument } from '../document/send-document';
@ -116,7 +113,8 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
}
const { branding, settings } = await getEmailContext({
const { branding, settings, senderEmail, emailLanguage } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'team',
teamId: template.teamId,
@ -169,13 +167,7 @@ export const createDocumentFromDirectTemplate = async ({
const nonDirectTemplateRecipients = template.recipients.filter(
(recipient) => recipient.id !== directTemplateRecipient.id,
);
const metaTimezone = template.templateMeta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE;
const metaDateFormat = template.templateMeta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT;
const metaEmailMessage = template.templateMeta?.message || '';
const metaEmailSubject = template.templateMeta?.subject || '';
const metaLanguage = template.templateMeta?.language ?? settings.documentLanguage;
const metaSigningOrder = template.templateMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, template.templateMeta);
// Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
@ -234,7 +226,9 @@ export const createDocumentFromDirectTemplate = async ({
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (templateField.type === FieldType.DATE) {
customText = DateTime.now().setZone(metaTimezone).toFormat(metaDateFormat);
customText = DateTime.now()
.setZone(derivedDocumentMeta.timezone)
.toFormat(derivedDocumentMeta.dateFormat);
}
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
@ -318,18 +312,7 @@ export const createDocumentFromDirectTemplate = async ({
},
},
documentMeta: {
create: {
timezone: metaTimezone,
dateFormat: metaDateFormat,
message: metaEmailMessage,
subject: metaEmailSubject,
language: metaLanguage,
signingOrder: metaSigningOrder,
distributionMethod: template.templateMeta?.distributionMethod,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
create: derivedDocumentMeta,
},
},
include: {
@ -589,11 +572,11 @@ export const createDocumentFromDirectTemplate = async ({
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: metaLanguage, branding, plainText: true }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailTemplate, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(metaLanguage);
const i18n = await getI18nInstance(emailLanguage);
await mailer.sendMail({
to: [
@ -602,10 +585,7 @@ export const createDocumentFromDirectTemplate = async ({
address: templateOwner.email,
},
],
from: {
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
from: senderEmail,
subject: i18n._(msg`Document created from direct template`),
html,
text,

View File

@ -3,6 +3,7 @@ import { DocumentSource, type RecipientRole } from '@prisma/client';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
@ -78,18 +79,7 @@ export const createDocumentFromTemplateLegacy = async ({
})),
},
documentMeta: {
create: {
subject: template.templateMeta?.subject,
message: template.templateMeta?.message,
timezone: template.templateMeta?.timezone,
dateFormat: template.templateMeta?.dateFormat,
redirectUrl: template.templateMeta?.redirectUrl,
signingOrder: template.templateMeta?.signingOrder ?? undefined,
language: template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled: template.templateMeta?.typedSignatureEnabled,
uploadSignatureEnabled: template.templateMeta?.uploadSignatureEnabled,
drawSignatureEnabled: template.templateMeta?.drawSignatureEnabled,
},
create: extractDerivedDocumentMeta(settings, template.templateMeta),
},
},

View File

@ -1,6 +1,5 @@
import type { DocumentDistributionMethod } from '@prisma/client';
import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentSource,
type Field,
type Recipient,
@ -40,6 +39,7 @@ import {
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
@ -378,7 +378,7 @@ export const createDocumentFromTemplate = async ({
visibility: template.visibility || settings.documentVisibility,
useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false,
documentMeta: {
create: {
create: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.templateMeta?.subject,
message: override?.message || template.templateMeta?.message,
timezone: override?.timezone || template.templateMeta?.timezone,
@ -387,13 +387,8 @@ export const createDocumentFromTemplate = async ({
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
distributionMethod:
override?.distributionMethod || template.templateMeta?.distributionMethod,
// last `undefined` is due to JsonValue's
emailSettings:
override?.emailSettings || template.templateMeta?.emailSettings || undefined,
signingOrder:
override?.signingOrder ||
template.templateMeta?.signingOrder ||
DocumentSigningOrder.PARALLEL,
emailSettings: override?.emailSettings || template.templateMeta?.emailSettings,
signingOrder: override?.signingOrder || template.templateMeta?.signingOrder,
language:
override?.language || template.templateMeta?.language || settings.documentLanguage,
typedSignatureEnabled:
@ -403,10 +398,8 @@ export const createDocumentFromTemplate = async ({
drawSignatureEnabled:
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
allowDictateNextSigner:
override?.allowDictateNextSigner ??
template.templateMeta?.allowDictateNextSigner ??
false,
},
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
}),
},
recipients: {
createMany: {

View File

@ -6,6 +6,7 @@ import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema//Tem
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
@ -69,6 +70,24 @@ export const createTemplate = async ({
teamId,
});
const emailId = meta.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.template.create({
data: {
title,
@ -86,14 +105,7 @@ export const createTemplate = async ({
publicDescription: data.publicDescription,
type: data.type,
templateMeta: {
create: {
...meta,
language: meta?.language ?? settings.documentLanguage,
typedSignatureEnabled: meta?.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta?.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta?.drawSignatureEnabled ?? settings.drawSignatureEnabled,
emailSettings: meta?.emailSettings || undefined,
},
create: extractDerivedDocumentMeta(settings, meta),
},
},
});

View File

@ -41,6 +41,7 @@ export const updateTemplate = async ({
templateMeta: true,
team: {
select: {
organisationId: true,
organisation: {
select: {
organisationClaim: true,
@ -86,6 +87,24 @@ export const updateTemplate = async ({
globalActionAuth: newGlobalActionAuth,
});
const emailId = meta.emailId;
// Validate the emailId belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: template.team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.template.update({
where: {
id: templateId,

View File

@ -58,6 +58,9 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
'REDIRECT_URL',
'SUBJECT',
'TIMEZONE',
'EMAIL_ID',
'EMAIL_REPLY_TO',
'EMAIL_SETTINGS',
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
@ -109,6 +112,9 @@ export const ZDocumentAuditLogDocumentMetaSchema = z.union([
z.literal(DOCUMENT_META_DIFF_TYPE.REDIRECT_URL),
z.literal(DOCUMENT_META_DIFF_TYPE.SUBJECT),
z.literal(DOCUMENT_META_DIFF_TYPE.TIMEZONE),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_ID),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_REPLY_TO),
z.literal(DOCUMENT_META_DIFF_TYPE.EMAIL_SETTINGS),
]),
from: z.string().nullable(),
to: z.string().nullable(),

View File

@ -54,15 +54,7 @@ export const ZDocumentEmailSettingsSchema = z
.default(true),
})
.strip()
.catch(() => ({
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
}));
.catch(() => ({ ...DEFAULT_DOCUMENT_EMAIL_SETTINGS }));
export type TDocumentEmailSettings = z.infer<typeof ZDocumentEmailSettingsSchema>;
@ -88,3 +80,13 @@ export const extractDerivedDocumentEmailSettings = (
ownerDocumentCompleted: emailSettings.ownerDocumentCompleted,
};
};
export const DEFAULT_DOCUMENT_EMAIL_SETTINGS: TDocumentEmailSettings = {
recipientSigningRequest: true,
recipientRemoved: true,
recipientSigned: true,
documentPending: true,
documentCompleted: true,
documentDeleted: true,
ownerDocumentCompleted: true,
};

View File

@ -58,6 +58,8 @@ export const ZDocumentSchema = DocumentSchema.pick({
allowDictateNextSigner: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
folder: FolderSchema.pick({
id: true,

View File

@ -0,0 +1,40 @@
import type { z } from 'zod';
import { EmailDomainSchema } from '@documenso/prisma/generated/zod/modelSchema/EmailDomainSchema';
import { ZOrganisationEmailLiteSchema } from './organisation-email';
/**
* The full email domain response schema.
*
* Mainly used for returning a single email domain from the API.
*/
export const ZEmailDomainSchema = EmailDomainSchema.pick({
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
publicKey: true,
createdAt: true,
updatedAt: true,
}).extend({
emails: ZOrganisationEmailLiteSchema.array(),
});
export type TEmailDomain = z.infer<typeof ZEmailDomainSchema>;
/**
* A version of the email domain response schema when returning multiple email domains at once from a single API endpoint.
*/
export const ZEmailDomainManySchema = EmailDomainSchema.pick({
id: true,
status: true,
organisationId: true,
domain: true,
selector: true,
createdAt: true,
updatedAt: true,
});
export type TEmailDomainMany = z.infer<typeof ZEmailDomainManySchema>;

View File

@ -0,0 +1,42 @@
import { EmailDomainStatus } from '@prisma/client';
import { z } from 'zod';
import { OrganisationEmailSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationEmailSchema';
export const ZOrganisationEmailSchema = OrganisationEmailSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
}).extend({
emailDomain: z.object({
id: z.string(),
status: z.nativeEnum(EmailDomainStatus),
}),
});
export type TOrganisationEmail = z.infer<typeof ZOrganisationEmailSchema>;
/**
* A lite version of the organisation email response schema without relations.
*/
export const ZOrganisationEmailLiteSchema = OrganisationEmailSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
email: true,
emailName: true,
// replyTo: true,
emailDomainId: true,
organisationId: true,
});
export const ZOrganisationEmailManySchema = ZOrganisationEmailLiteSchema.extend({
// Put anything extra here.
});
export type TOrganisationEmailMany = z.infer<typeof ZOrganisationEmailManySchema>;

View File

@ -19,6 +19,8 @@ export const ZClaimFlagsSchema = z.object({
unlimitedDocuments: z.boolean().optional(),
emailDomains: z.boolean().optional(),
embedAuthoring: z.boolean().optional(),
embedAuthoringWhiteLabel: z.boolean().optional(),
@ -50,6 +52,10 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'hidePoweredBy',
label: 'Hide Documenso branding by',
},
emailDomains: {
key: 'emailDomains',
label: 'Email domains',
},
embedAuthoring: {
key: 'embedAuthoring',
label: 'Embed authoring',
@ -128,6 +134,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
embedAuthoring: false,
embedAuthoringWhiteLabel: true,
embedSigning: false,
@ -144,6 +151,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
embedSigning: true,

View File

@ -55,6 +55,8 @@ export const ZTemplateSchema = TemplateSchema.pick({
redirectUrl: true,
language: true,
emailSettings: true,
emailId: true,
emailReplyTo: true,
}).nullable(),
directLink: TemplateDirectLinkSchema.nullable(),
user: UserSchema.pick({

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