feat: admin-configurable email blocklist (#2884)

This commit is contained in:
Lucas Smith
2026-05-29 00:12:55 +09:00
committed by GitHub
parent a84da2f2c7
commit 22ceff43e3
10 changed files with 468 additions and 194 deletions
@@ -0,0 +1,171 @@
import {
SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
type TSiteSettingsEmailBlocklistSchema,
} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
const ZEmailBlocklistFormSchema = z.object({
enabled: z.boolean(),
domains: z.string(),
});
type TEmailBlocklistFormSchema = z.infer<typeof ZEmailBlocklistFormSchema>;
/**
* Splits a comma-separated string into a normalised list of domains.
* Normalisation (trim, lowercase, strip leading "@", dedupe) is applied
* server-side by the schema as well — this is for display consistency.
*/
const parseDomainsInput = (value: string): string[] => {
return Array.from(
new Set(
value
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter((entry) => entry.length > 0),
),
);
};
type AdminEmailBlocklistSectionProps = {
emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined;
};
export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TEmailBlocklistFormSchema>({
resolver: zodResolver(ZEmailBlocklistFormSchema),
defaultValues: {
enabled: emailBlocklist?.enabled ?? false,
domains: (emailBlocklist?.data?.domains ?? []).join(', '),
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => {
try {
const parsedDomains = parseDomainsInput(domains);
await updateSiteSetting({
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
enabled,
data: {
domains: parsedDomains,
},
});
// Reflect the normalised value back in the form.
form.reset({
enabled,
domains: parsedDomains.join(', '),
});
toast({
title: _(msg`Email Blocklist Updated`),
description: _(msg`The email blocklist has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Email Blocklist</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Block signups from additional email domains on top of the bundled disposable email list. Subdomains are
matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com").
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBlocklistUpdate)}>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="mt-4" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Blocked Domains</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" placeholder="bad.com, spam.net, throwaway.io" {...field} />
</FormControl>
<FormDescription>
<Trans>Comma-separated list of email domains to block from signing up.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Blocklist</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,197 @@
import {
SITE_SETTINGS_BANNER_ID,
type TSiteSettingsBannerSchema,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { useCspNonce } from '~/utils/nonce';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
type AdminSiteBannerSectionProps = {
banner: TSiteSettingsBannerSchema | undefined;
};
export const AdminSiteBannerSection = ({ banner }: AdminSiteBannerSectionProps) => {
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -1,210 +1,36 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
const settings = await getSiteSettings();
return { banner };
const banner = settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID);
const emailBlocklist = settings.find((setting) => setting.id === SITE_SETTINGS_EMAIL_BLOCKLIST_ID);
return { banner, emailBlocklist };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentProps) {
const { banner, emailBlocklist } = loaderData;
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader title={_(msg`Site Settings`)} subtitle={_(msg`Manage your site settings here`)} />
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<div className="mt-8 space-y-12">
<AdminSiteBannerSection banner={banner} />
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
</div>
</div>
);
@@ -5,6 +5,7 @@ import {
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
@@ -137,7 +138,9 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
}
// Reject disposable / throwaway email providers for new SSO users.
if (isDisposableEmail(email)) {
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
@@ -1,6 +1,7 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
@@ -75,7 +76,9 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
}
// Reject disposable / throwaway email providers for new SSO users.
if (isDisposableEmail(email)) {
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
@@ -22,6 +22,7 @@ import {
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
@@ -214,7 +215,9 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
if (isDisposableEmail(email)) {
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
throw new AppError(AuthenticationErrorCode.SignupDisposableEmail, {
statusCode: 400,
});
+8 -2
View File
@@ -132,11 +132,16 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
* Matching also covers subdomains (e.g. `foo.mailinator.com` resolves to
* `mailinator.com`).
*
* An optional `additionalBlockedDomains` list can be supplied to layer
* admin-configured custom domains on top of the bundled list. These are
* matched with the same subdomain-walking behaviour and are expected to be
* pre-normalised (trimmed + lowercased) by the caller.
*
* Returns `true` when the email is disposable and should be rejected.
* Email format validation is intentionally NOT performed here — that is
* handled by Zod upstream.
*/
export const isDisposableEmail = (email: string): boolean => {
export const isDisposableEmail = (email: string, additionalBlockedDomains: string[] = []): boolean => {
const domain = email.toLowerCase().split('@').pop();
if (!domain) {
@@ -144,11 +149,12 @@ export const isDisposableEmail = (email: string): boolean => {
}
const blacklist = MailChecker.blacklist();
const blocklist = new Set(additionalBlockedDomains);
let currentDomain: string | undefined = domain;
while (currentDomain) {
if (blacklist.has(currentDomain)) {
if (blacklist.has(currentDomain) || blocklist.has(currentDomain)) {
return true;
}
@@ -0,0 +1,31 @@
import { prisma } from '@documenso/prisma';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID, ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
/**
* Returns the list of admin-configured email domains that should be treated as
* disposable / blocked, in addition to the bundled `mailchecker` list.
*
* Returns an empty array when the setting has not been configured, is
* disabled, or fails to parse — so a misconfigured setting can never block
* signups outright.
*/
export const getEmailBlocklistDomains = async (): Promise<string[]> => {
const setting = await prisma.siteSettings.findFirst({
where: {
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
},
});
if (!setting || !setting.enabled) {
return [];
}
const parsed = ZSiteSettingsEmailBlocklistSchema.safeParse(setting);
if (!parsed.success) {
return [];
}
return parsed.data.data.domains;
};
@@ -1,9 +1,14 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
export const ZSiteSettingSchema = z.union([ZSiteSettingsBannerSchema, ZSiteSettingsTelemetrySchema]);
export const ZSiteSettingSchema = z.union([
ZSiteSettingsBannerSchema,
ZSiteSettingsEmailBlocklistSchema,
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_EMAIL_BLOCKLIST_ID = 'email.blocklist-domains';
/**
* Normalises a single domain entry: trims whitespace, lowercases, strips
* a leading "@" if present (so users can paste either "bad.com" or "@bad.com").
*/
const normaliseDomain = (value: string): string => value.trim().toLowerCase().replace(/^@/, '');
const ZBlocklistDomainsSchema = z
.array(z.string())
.transform((values) => Array.from(new Set(values.map(normaliseDomain).filter((value) => value.length > 0))));
export const ZSiteSettingsEmailBlocklistSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_EMAIL_BLOCKLIST_ID),
data: z
.object({
domains: ZBlocklistDomainsSchema.default([]),
})
.optional()
.default({
domains: [],
}),
});
export type TSiteSettingsEmailBlocklistSchema = z.infer<typeof ZSiteSettingsEmailBlocklistSchema>;