diff --git a/packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.test.tsx b/packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.test.tsx
new file mode 100644
index 00000000000..3d4d2efad89
--- /dev/null
+++ b/packages/fxa-admin-panel/src/components/PageEmailBlocklist/index.test.tsx
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { userEvent, UserEvent } from '@testing-library/user-event';
+import PageEmailBlocklist from './index';
+import { adminApi } from '../../lib/api';
+
+jest.mock('../../lib/api', () => ({
+ adminApi: {
+ getEmailBlocklist: jest.fn(),
+ addEmailBlocklistEntries: jest.fn(),
+ removeEmailBlocklistEntry: jest.fn(),
+ deleteAllEmailBlocklistEntries: jest.fn(),
+ },
+}));
+
+const mockEntries = [
+ { regex: '@evil\\.com$', createdAt: 2000 },
+ { regex: '@spam\\.net$', createdAt: 1000 },
+];
+
+describe('PageEmailBlocklist', () => {
+ let user: UserEvent;
+ let confirmSpy: jest.SpyInstance;
+ let alertSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ user = userEvent.setup();
+ confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true);
+ alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => undefined);
+ (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the page heading and empty state', async () => {
+ render();
+
+ expect(screen.getByText('Email Blocklist (regex)')).toBeInTheDocument();
+ expect(await screen.findByText('No entries yet.')).toBeInTheDocument();
+ });
+
+ it('shows loading state then renders entries sorted by createdAt desc', async () => {
+ (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue([
+ { regex: '@spam\\.net$', createdAt: 1000 },
+ { regex: '@evil\\.com$', createdAt: 2000 },
+ ]);
+
+ render();
+
+ expect(screen.getByText('Loading…')).toBeInTheDocument();
+
+ const rows = await screen.findAllByRole('row');
+ expect(rows[1]).toHaveTextContent('@evil\\.com$');
+ expect(rows[2]).toHaveTextContent('@spam\\.net$');
+ expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ });
+
+ it('shows error state when load fails', async () => {
+ (adminApi.getEmailBlocklist as jest.Mock).mockRejectedValue(
+ new Error('network error')
+ );
+
+ render();
+
+ expect(
+ await screen.findByText('Failed to load blocklist.')
+ ).toBeInTheDocument();
+ });
+
+ it('Add Entries button is disabled when textarea is empty', async () => {
+ render();
+
+ const btn = screen.getByTestId('blocklist-add-btn');
+ expect(btn).toBeDisabled();
+ });
+
+ it('enables Add Entries on input, submits trimmed lines, and reloads', async () => {
+ (adminApi.getEmailBlocklist as jest.Mock)
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([{ regex: '@newevil\\.com$', createdAt: 3000 }]);
+ (adminApi.addEmailBlocklistEntries as jest.Mock).mockResolvedValue({
+ ok: true,
+ });
+
+ render();
+ await screen.findByText('No entries yet.');
+
+ const textarea = screen.getByTestId('blocklist-input');
+ const btn = screen.getByTestId('blocklist-add-btn');
+
+ await user.type(textarea, '@newevil\\.com$\n \n@haxor\\.net$');
+ expect(btn).toBeEnabled();
+
+ await user.click(btn);
+
+ await waitFor(() => {
+ expect(adminApi.addEmailBlocklistEntries).toHaveBeenCalledWith([
+ '@newevil\\.com$',
+ '@haxor\\.net$',
+ ]);
+ });
+ expect(await screen.findByText('@newevil\\.com$')).toBeInTheDocument();
+ expect(textarea).toHaveValue('');
+ });
+
+ it('does not submit when textarea contains only whitespace', async () => {
+ render();
+ await screen.findByText('No entries yet.');
+
+ const textarea = screen.getByTestId('blocklist-input');
+ await user.type(textarea, ' ');
+
+ expect(screen.getByTestId('blocklist-add-btn')).toBeDisabled();
+ expect(adminApi.addEmailBlocklistEntries).not.toHaveBeenCalled();
+ });
+
+ it('shows alert when add fails', async () => {
+ (adminApi.addEmailBlocklistEntries as jest.Mock).mockRejectedValue(
+ new Error('API error 400: bad regex')
+ );
+
+ render();
+ await screen.findByText('No entries yet.');
+
+ await user.type(screen.getByTestId('blocklist-input'), '@evil\\.com$');
+ await user.click(screen.getByTestId('blocklist-add-btn'));
+
+ await waitFor(() => {
+ expect(alertSpy).toHaveBeenCalledWith(
+ expect.stringContaining('API error 400: bad regex')
+ );
+ });
+ });
+
+ it('deletes a single entry after successful API call', async () => {
+ (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue(mockEntries);
+ (adminApi.removeEmailBlocklistEntry as jest.Mock).mockResolvedValue({
+ removed: true,
+ });
+
+ render();
+ await screen.findByText('@evil\\.com$');
+
+ await user.click(screen.getByTestId('delete-@evil\\.com$'));
+
+ await waitFor(() => {
+ expect(adminApi.removeEmailBlocklistEntry).toHaveBeenCalledWith(
+ '@evil\\.com$'
+ );
+ expect(screen.queryByText('@evil\\.com$')).not.toBeInTheDocument();
+ });
+ expect(screen.getByText('@spam\\.net$')).toBeInTheDocument();
+ });
+
+ it('shows alert when single delete fails and keeps the entry', async () => {
+ (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue(mockEntries);
+ (adminApi.removeEmailBlocklistEntry as jest.Mock).mockRejectedValue(
+ new Error('boom')
+ );
+
+ render();
+ await screen.findByText('@evil\\.com$');
+
+ await user.click(screen.getByTestId('delete-@evil\\.com$'));
+
+ await waitFor(() => {
+ expect(alertSpy).toHaveBeenCalledWith('Failed to remove entry.');
+ });
+ expect(screen.getByText('@evil\\.com$')).toBeInTheDocument();
+ });
+
+ it('deletes all entries after confirmation', async () => {
+ (adminApi.getEmailBlocklist as jest.Mock)
+ .mockResolvedValueOnce(mockEntries)
+ .mockResolvedValueOnce([]);
+ (adminApi.deleteAllEmailBlocklistEntries as jest.Mock).mockResolvedValue({
+ ok: true,
+ });
+
+ render();
+ await screen.findByText('@evil\\.com$');
+
+ await user.click(screen.getByText('🗑️ Delete All'));
+
+ expect(confirmSpy).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(adminApi.deleteAllEmailBlocklistEntries).toHaveBeenCalled();
+ });
+ expect(await screen.findByText('No entries yet.')).toBeInTheDocument();
+ });
+
+ it('does not delete all when confirmation is cancelled', async () => {
+ confirmSpy.mockReturnValue(false);
+ (adminApi.getEmailBlocklist as jest.Mock).mockResolvedValue(mockEntries);
+
+ render();
+ await screen.findByText('@evil\\.com$');
+
+ await user.click(screen.getByText('🗑️ Delete All'));
+
+ expect(adminApi.deleteAllEmailBlocklistEntries).not.toHaveBeenCalled();
+ expect(screen.getByText('@evil\\.com$')).toBeInTheDocument();
+ });
+
+ it('loads regex patterns from a CSV/TXT file into the textarea', async () => {
+ render();
+ await screen.findByText('No entries yet.');
+
+ const fileContent = '"@evil\\.com$"\n@spam\\.net$\n\n';
+ const file = new File([fileContent], 'blocklist.csv', { type: 'text/csv' });
+
+ // file input is hidden behind a proxy button; query it directly
+ const fileInput = document.querySelector(
+ 'input[type="file"]'
+ ) as HTMLInputElement;
+
+ await user.upload(fileInput, file);
+
+ const textarea = screen.getByTestId(
+ 'blocklist-input'
+ ) as HTMLTextAreaElement;
+ await waitFor(() => {
+ expect(textarea.value).toBe('@evil\\.com$\n@spam\\.net$');
+ });
+ expect(screen.getByTestId('blocklist-add-btn')).toBeEnabled();
+ });
+});
diff --git a/packages/fxa-admin-server/src/rest/email-blocklist/email-blocklist.controller.spec.ts b/packages/fxa-admin-server/src/rest/email-blocklist/email-blocklist.controller.spec.ts
new file mode 100644
index 00000000000..f83e82a9ec6
--- /dev/null
+++ b/packages/fxa-admin-server/src/rest/email-blocklist/email-blocklist.controller.spec.ts
@@ -0,0 +1,217 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { BadRequestException, Provider } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Test, TestingModule } from '@nestjs/testing';
+import { MozLoggerService } from '@fxa/shared/mozlog';
+import { EmailBlocklist } from 'fxa-shared/db/models/auth';
+import { EventLoggingService } from '../../event-logging/event-logging.service';
+import { AuditLogInterceptor } from '../../auth/audit-log.interceptor';
+import { EmailBlocklistController } from './email-blocklist.controller';
+
+jest.mock('fxa-shared/db/models/auth', () => ({
+ EmailBlocklist: {
+ findAll: jest.fn(),
+ addMany: jest.fn(),
+ removeByRegex: jest.fn(),
+ deleteAll: jest.fn(),
+ },
+}));
+
+describe('EmailBlocklistController', () => {
+ let controller: EmailBlocklistController;
+ let logger: { debug: jest.Mock; error: jest.Mock; info: jest.Mock };
+
+ beforeEach(async () => {
+ logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn() };
+
+ const MockMozLoggerService: Provider = {
+ provide: MozLoggerService,
+ useValue: logger,
+ };
+
+ const MockConfig: Provider = {
+ provide: ConfigService,
+ useValue: {
+ get: jest.fn().mockReturnValue({ authHeader: 'test' }),
+ },
+ };
+
+ const MockMetricsFactory: Provider = {
+ provide: 'METRICS',
+ useFactory: () => undefined,
+ };
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ EmailBlocklistController,
+ EventLoggingService,
+ AuditLogInterceptor,
+ MockMozLoggerService,
+ MockConfig,
+ MockMetricsFactory,
+ ],
+ }).compile();
+
+ controller = module.get(EmailBlocklistController);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('list', () => {
+ it('returns all entries', async () => {
+ const entries = [
+ { regex: '@evil\\.com$', createdAt: 1000 },
+ { regex: '@spam\\.net$', createdAt: 900 },
+ ];
+ (EmailBlocklist.findAll as jest.Mock).mockResolvedValue(entries);
+
+ const result = await controller.list();
+
+ expect(result).toEqual(entries);
+ expect(EmailBlocklist.findAll).toHaveBeenCalled();
+ });
+ });
+
+ describe('add', () => {
+ it('adds valid regexes and logs', async () => {
+ (EmailBlocklist.addMany as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await controller.add(
+ ['@evil\\.com$', '@spam\\.net$'],
+ 'admin@example.com'
+ );
+
+ expect(result).toEqual({ ok: true });
+ expect(EmailBlocklist.addMany).toHaveBeenCalledWith([
+ '@evil\\.com$',
+ '@spam\\.net$',
+ ]);
+ expect(logger.info).toHaveBeenCalledWith('emailBlocklist.add', {
+ user: 'admin@example.com',
+ count: 2,
+ });
+ });
+
+ it('trims whitespace and drops empty entries', async () => {
+ (EmailBlocklist.addMany as jest.Mock).mockResolvedValue(undefined);
+
+ await controller.add(
+ [' @evil\\.com$ ', '', ' '],
+ 'admin@example.com'
+ );
+
+ expect(EmailBlocklist.addMany).toHaveBeenCalledWith(['@evil\\.com$']);
+ });
+
+ it('throws if regexes is not an array', async () => {
+ await expect(
+ controller.add('@evil\\.com$' as any, 'admin@example.com')
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('throws if any entry is not a string', async () => {
+ await expect(
+ controller.add(['@evil\\.com$', 123 as any], 'admin@example.com')
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('throws if all entries are blank after trimming', async () => {
+ await expect(
+ controller.add([' ', ''], 'admin@example.com')
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('throws if a regex exceeds 768 characters', async () => {
+ const long = 'a'.repeat(769);
+ await expect(controller.add([long], 'admin@example.com')).rejects.toThrow(
+ BadRequestException
+ );
+ });
+
+ it('throws for invalid regex syntax', async () => {
+ await expect(
+ controller.add(['(unclosed'], 'admin@example.com')
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('does not call addMany when validation fails', async () => {
+ await expect(controller.add([], 'admin@example.com')).rejects.toThrow(
+ BadRequestException
+ );
+ expect(EmailBlocklist.addMany).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('remove', () => {
+ it('removes an existing regex', async () => {
+ (EmailBlocklist.removeByRegex as jest.Mock).mockResolvedValue(true);
+
+ const result = await controller.remove(
+ '@evil\\.com$',
+ 'admin@example.com'
+ );
+
+ expect(result).toEqual({ removed: true });
+ expect(EmailBlocklist.removeByRegex).toHaveBeenCalledWith('@evil\\.com$');
+ expect(logger.info).toHaveBeenCalledWith('emailBlocklist.remove', {
+ user: 'admin@example.com',
+ regex: '@evil\\.com$',
+ removed: true,
+ });
+ });
+
+ it('returns removed: false when regex not found', async () => {
+ (EmailBlocklist.removeByRegex as jest.Mock).mockResolvedValue(false);
+
+ const result = await controller.remove(
+ '@notfound\\.com$',
+ 'admin@example.com'
+ );
+
+ expect(result).toEqual({ removed: false });
+ });
+
+ it('trims whitespace before lookup', async () => {
+ (EmailBlocklist.removeByRegex as jest.Mock).mockResolvedValue(true);
+
+ await controller.remove(' @evil\\.com$ ', 'admin@example.com');
+
+ expect(EmailBlocklist.removeByRegex).toHaveBeenCalledWith('@evil\\.com$');
+ });
+
+ it('throws for empty regex', async () => {
+ await expect(controller.remove('', 'admin@example.com')).rejects.toThrow(
+ BadRequestException
+ );
+
+ await expect(
+ controller.remove(' ', 'admin@example.com')
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('throws when regex is not a string', async () => {
+ await expect(
+ controller.remove(undefined as any, 'admin@example.com')
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+
+ describe('removeAll', () => {
+ it('deletes all entries and logs', async () => {
+ (EmailBlocklist.deleteAll as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await controller.removeAll('admin@example.com');
+
+ expect(result).toEqual({ ok: true });
+ expect(EmailBlocklist.deleteAll).toHaveBeenCalled();
+ expect(logger.info).toHaveBeenCalledWith('emailBlocklist.removeAll', {
+ user: 'admin@example.com',
+ });
+ });
+ });
+});