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
+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>;