diff --git a/libs/shared/guards/src/lib/admin-panel-guard.ts b/libs/shared/guards/src/lib/admin-panel-guard.ts index 7f76ab45a04..7de7990c35d 100644 --- a/libs/shared/guards/src/lib/admin-panel-guard.ts +++ b/libs/shared/guards/src/lib/admin-panel-guard.ts @@ -49,6 +49,7 @@ export enum AdminPanelFeature { UnsubscribeFromMailingLists = 'UnsubscribeFromMailingLists', RateLimiting = 'RateLimiting', DeleteRecoveryPhone = 'DeleteRecoveryPhone', + EmailBlocklist = 'EmailBlocklist', } /** Enum of known user groups */ @@ -209,6 +210,10 @@ const defaultAdminPanelPermissions: Permissions = { name: 'Delete Recovery Phone', level: PermissionLevel.Admin, }, + [AdminPanelFeature.EmailBlocklist]: { + name: 'Manage Email Blocklist', + level: PermissionLevel.Admin, + }, }; /** diff --git a/packages/db-migrations/databases/fxa/patches/patch-189-190.sql b/packages/db-migrations/databases/fxa/patches/patch-189-190.sql new file mode 100644 index 00000000000..7e481194987 --- /dev/null +++ b/packages/db-migrations/databases/fxa/patches/patch-189-190.sql @@ -0,0 +1,11 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_bin; + +CALL assertPatchLevel('189'); + +CREATE TABLE IF NOT EXISTS emailBlocklist ( + regex VARCHAR(768) NOT NULL, + createdAt BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uq_emailBlocklist_regex (regex) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +UPDATE dbMetadata SET value = '190' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa/patches/patch-190-189.sql b/packages/db-migrations/databases/fxa/patches/patch-190-189.sql new file mode 100644 index 00000000000..9a0a05a30ad --- /dev/null +++ b/packages/db-migrations/databases/fxa/patches/patch-190-189.sql @@ -0,0 +1,7 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_bin; + +CALL assertPatchLevel('190'); + +DROP TABLE IF EXISTS emailBlocklist; + +UPDATE dbMetadata SET value = '189' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa/target-patch.json b/packages/db-migrations/databases/fxa/target-patch.json index 0c70ea751d3..31fd730cf3d 100644 --- a/packages/db-migrations/databases/fxa/target-patch.json +++ b/packages/db-migrations/databases/fxa/target-patch.json @@ -1,3 +1,3 @@ { - "level": 189 + "level": 190 } diff --git a/packages/fxa-admin-panel/src/App.tsx b/packages/fxa-admin-panel/src/App.tsx index 8cc61bbad04..2c16ed75e27 100644 --- a/packages/fxa-admin-panel/src/App.tsx +++ b/packages/fxa-admin-panel/src/App.tsx @@ -15,6 +15,7 @@ import PageRelyingParties from './components/PageRelyingParties'; import PageAccountDelete from './components/PageAccountDelete'; import PageRateLimiting from './components/PageRateLimiting'; import PageAccountReset from './components/PageAccountReset'; +import PageEmailBlocklist from './components/PageEmailBlocklist'; const App = ({ config }: { config: IClientConfig }) => { const [guard, setGuard] = useState(config.guard); @@ -46,6 +47,12 @@ const App = ({ config }: { config: IClientConfig }) => { {guard.allow(AdminPanelFeature.AccountReset, user.group) && ( } /> )} + {guard.allow(AdminPanelFeature.EmailBlocklist, user.group) && ( + } + /> + )} } /> diff --git a/packages/fxa-admin-panel/src/components/Nav/index.tsx b/packages/fxa-admin-panel/src/components/Nav/index.tsx index 0b6016c66d4..b1cb85b1926 100644 --- a/packages/fxa-admin-panel/src/components/Nav/index.tsx +++ b/packages/fxa-admin-panel/src/components/Nav/index.tsx @@ -7,6 +7,7 @@ import { NavLink } from 'react-router-dom'; import accountIcon from '../../images/icon-account.svg'; import keyIcon from '../../images/icon-key.svg'; import logsIcon from '../../images/icon-logs.svg'; +import emailBlocklistIcon from '../../images/icon-email-blocklist.svg'; import { AdminPanelFeature } from '@fxa/shared/guards'; import Guard from '../Guard'; @@ -67,6 +68,21 @@ export const Nav = () => ( + +
  • + getNavLinkClassName(isActive)} + > + blocklist icon + Email Blocklist + +
  • +
  • { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + + const loadEntries = useCallback(async () => { + setError(null); + try { + const data = await adminApi.getEmailBlocklist(); + setEntries( + [...data].sort( + (a, b) => b.createdAt - a.createdAt || a.regex.localeCompare(b.regex) + ) + ); + } catch (e) { + setError('Failed to load blocklist.'); + } + }, []); + + useEffect(() => { + setLoading(true); + loadEntries().finally(() => setLoading(false)); + }, [loadEntries]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const raw = textareaRef.current?.value ?? ''; + const regexes = raw + .split('\n') + .map((x) => x.trim()) + .filter((x) => x.length > 0); + + if (regexes.length === 0) return; + + setSubmitting(true); + try { + await adminApi.addEmailBlocklistEntries(regexes); + if (textareaRef.current) textareaRef.current.value = ''; + await loadEntries(); + } catch (e) { + window.alert( + `Error: ${e instanceof Error ? e.message : 'Unknown error'}` + ); + } finally { + setSubmitting(false); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const lines = (ev.target?.result as string) + .split(/\r?\n/) + .map((l) => l.trim().replace(/^"|"$/g, '')) + .filter((l) => l.length > 0); + if (textareaRef.current) textareaRef.current.value = lines.join('\n'); + }; + reader.readAsText(file); + e.target.value = ''; + }; + + const handleDelete = async (regex: string) => { + try { + await adminApi.removeEmailBlocklistEntry(regex); + setEntries((prev) => prev.filter((e) => e.regex !== regex)); + } catch { + window.alert('Failed to remove entry.'); + } + }; + + const handleDeleteAll = async () => { + if ( + !window.confirm( + `Delete all ${entries.length} blocklist entries? This cannot be undone.` + ) + ) + return; + try { + await adminApi.deleteAllEmailBlocklistEntries(); + await loadEntries(); + } catch { + window.alert('Failed to delete all entries.'); + } + }; + + return ( + <> +

    Email Blocklist

    +
      +
    • + Blocks registration for emails matching any pattern. Does not affect + existing accounts. +
    • +
    • + Patterns are regexes matched against the full address. Mostly used for + domains (e.g. @evildoge\.example\.com$). +
    • +
    • + Use $ to anchor to the end of the address — without it,{' '} + @mozilla\.com would also match{' '} + @mozilla\.com.haxor.net. +
    • +
    • + Enter one pattern per line, or upload a CSV/TXT file (one entry per + line). Duplicates are silently ignored. +
    • +
    • Blocked attempts are logged and counted in statsd.
    • +
    • + Changes propagate to auth-server within 5 minutes. Keep the list small + (hundreds of entries, not thousands) to avoid slowing registration. +
    • +
    +

    + + ⚠️ Avoid complex patterns with nested quantifiers (e.g.{' '} + {'(a+)+'}). + {' '} + These can cause slow matching on every registration attempt. Stick to + simple domain patterns like @domain\.com$. +

    + +
    +