diff --git a/e2e/tests/notification-channels.spec.ts b/e2e/tests/notification-channels.spec.ts
new file mode 100644
index 000000000..d45d5113f
--- /dev/null
+++ b/e2e/tests/notification-channels.spec.ts
@@ -0,0 +1,70 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {test, expect} from '@playwright/test';
+import {loginAsUser, BASE_URL} from './utils';
+
+test.describe('Notification Channels Page', () => {
+ test('redirects unauthenticated user to home and shows toast', async ({
+ page,
+ }) => {
+ await page.goto(`${BASE_URL}/settings/notification-channels`);
+
+ // Expect to be redirected to the home page.
+ await expect(page).toHaveURL(BASE_URL);
+ // FYI: We do not assert the toast because it flashes on the screen due to the redirect.
+ });
+
+ test('authenticated user sees their email channel and coming soon messages', async ({
+ page,
+ }) => {
+ // Log in as a test user
+ await loginAsUser(page, 'test user 1');
+
+ // Navigate to the notification channels page
+ await page.goto(`${BASE_URL}/settings/notification-channels`);
+
+ // Move the mouse to a neutral position to avoid hover effects on the screenshot
+ await page.mouse.move(0, 0);
+
+ // Expect the URL to be correct
+ await expect(page).toHaveURL(`${BASE_URL}/settings/notification-channels`);
+
+ // Verify Email panel content
+ const emailPanel = page.locator('webstatus-notification-email-channels');
+ await expect(emailPanel).toBeVisible();
+ await expect(emailPanel).toContainText('test.user.1@example.com');
+ await expect(emailPanel).toContainText('Enabled');
+
+ // Verify RSS panel content
+ const rssPanel = page.locator('webstatus-notification-rss-channels');
+ await expect(rssPanel).toBeVisible();
+ await expect(rssPanel).toContainText('Coming soon');
+
+ // Verify Webhook panel content
+ const webhookPanel = page.locator(
+ 'webstatus-notification-webhook-channels',
+ );
+ await expect(webhookPanel).toBeVisible();
+ await expect(webhookPanel).toContainText('Coming soon');
+
+ // Take a screenshot for visual regression
+ const pageContainer = page.locator('.page-container');
+ await expect(pageContainer).toHaveScreenshot(
+ 'notification-channels-authenticated.png',
+ );
+ });
+});
diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png
new file mode 100644
index 000000000..c9922b225
Binary files /dev/null and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-chromium-linux.png differ
diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png
new file mode 100644
index 000000000..a60ecc594
Binary files /dev/null and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-firefox-linux.png differ
diff --git a/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png
new file mode 100644
index 000000000..45af20445
Binary files /dev/null and b/e2e/tests/notification-channels.spec.ts-snapshots/notification-channels-authenticated-webkit-linux.png differ
diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png
index 2dcb90025..3290834fb 100644
Binary files a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png and b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-chromium-linux.png differ
diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png
index 7b3d6e2cb..efbde13eb 100644
Binary files a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png and b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-firefox-linux.png differ
diff --git a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png
index 95dfba4cd..860e2a41b 100644
Binary files a/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png and b/e2e/tests/sidebar.spec.ts-snapshots/sidebar-authenticated-webkit-linux.png differ
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 981aece17..7c88c7fa8 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -44,6 +44,10 @@ http {
try_files $uri $uri/ /index.html;
}
+ location = /settings/notification-channels {
+ try_files $uri $uri/ /index.html;
+ }
+
location = / {
try_files $uri $uri/ =404;
}
diff --git a/frontend/src/static/img/shoelace/assets/icons/envelope.svg b/frontend/src/static/img/shoelace/assets/icons/envelope.svg
new file mode 100644
index 000000000..78bf1ded1
--- /dev/null
+++ b/frontend/src/static/img/shoelace/assets/icons/envelope.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg b/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg
new file mode 100644
index 000000000..8e24db05f
--- /dev/null
+++ b/frontend/src/static/img/shoelace/assets/icons/mailbox-flag.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg b/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg
new file mode 100644
index 000000000..531e86cd0
--- /dev/null
+++ b/frontend/src/static/img/shoelace/assets/icons/plus-lg.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/src/static/img/shoelace/assets/icons/rss.svg b/frontend/src/static/img/shoelace/assets/icons/rss.svg
new file mode 100644
index 000000000..18dc9f1be
--- /dev/null
+++ b/frontend/src/static/img/shoelace/assets/icons/rss.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/frontend/src/static/img/shoelace/assets/icons/webhook.svg b/frontend/src/static/img/shoelace/assets/icons/webhook.svg
new file mode 100644
index 000000000..6fbfb0543
--- /dev/null
+++ b/frontend/src/static/img/shoelace/assets/icons/webhook.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/static/js/api/client.ts b/frontend/src/static/js/api/client.ts
index 07d33430f..c60a39efe 100644
--- a/frontend/src/static/js/api/client.ts
+++ b/frontend/src/static/js/api/client.ts
@@ -54,6 +54,7 @@ export type BrowsersParameter = components['parameters']['browserPathParam'];
type PageablePath =
| '/v1/features'
| '/v1/features/{feature_id}/stats/wpt/browsers/{browser}/channels/{channel}/{metric_view}'
+ | '/v1/users/me/notification-channels'
| '/v1/stats/features/browsers/{browser}/feature_counts'
| '/v1/users/me/saved-searches'
| '/v1/stats/baseline_status/low_date_feature_counts';
@@ -421,6 +422,28 @@ export class APIClient {
});
}
+ public async listNotificationChannels(
+ token: string,
+ ): Promise {
+ type NotificationChannelPage = SuccessResponsePageableData<
+ paths['/v1/users/me/notification-channels']['get'],
+ FetchOptions<
+ FilterKeys
+ >,
+ 'application/json',
+ '/v1/users/me/notification-channels'
+ >;
+
+ return this.getAllPagesOfData<
+ '/v1/users/me/notification-channels',
+ NotificationChannelPage
+ >('/v1/users/me/notification-channels', {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+ }
+
public async pingUser(
token: string,
pingOptions?: {githubToken?: string},
diff --git a/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts b/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts
new file mode 100644
index 000000000..22faf8ac6
--- /dev/null
+++ b/frontend/src/static/js/components/test/webstatus-notification-email-channels.test.ts
@@ -0,0 +1,86 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {assert, fixture, html} from '@open-wc/testing';
+import type {WebstatusNotificationEmailChannels} from '../../components/webstatus-notification-email-channels.js';
+import '../../components/webstatus-notification-email-channels.js';
+import {components} from 'webstatus.dev-backend';
+import {WebstatusNotificationPanel} from '../webstatus-notification-panel.js';
+
+type NotificationChannelResponse =
+ components['schemas']['NotificationChannelResponse'];
+
+describe('webstatus-notification-email-channels', () => {
+ it('renders email channels correctly', async () => {
+ const mockChannels: NotificationChannelResponse[] = [
+ {
+ id: '1',
+ type: 'email',
+ value: 'test1@example.com',
+ name: 'Email 1',
+ status: 'enabled',
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ },
+ {
+ id: '2',
+ type: 'email',
+ value: 'test2@example.com',
+ name: 'Email 2',
+ status: 'disabled',
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ },
+ ];
+
+ const el = await fixture(html`
+
+ `);
+
+ const emailItems = el.shadowRoot!.querySelectorAll('.channel-item');
+ assert.equal(emailItems.length, mockChannels.length);
+
+ // Test first email channel
+ const email1Name = emailItems[0].querySelector('.name');
+ assert.include(email1Name!.textContent, 'test1@example.com');
+ const email1Badge = emailItems[0].querySelector('sl-badge');
+ assert.isNotNull(email1Badge);
+ assert.include(email1Badge!.textContent, 'Enabled');
+
+ // Test second email channel (disabled, so no badge)
+ const email2Name = emailItems[1].querySelector('.name');
+ assert.include(email2Name!.textContent, 'test2@example.com');
+ const email2Badge = emailItems[1].querySelector('sl-badge');
+ assert.isNotNull(email2Badge);
+ assert.include(email2Badge!.textContent, 'Disabled');
+ });
+
+ it('passes loading state to the base panel', async () => {
+ const el = await fixture(html`
+
+ `);
+
+ const basePanel = el.shadowRoot!.querySelector(
+ 'webstatus-notification-panel',
+ );
+ assert.isNotNull(basePanel);
+ assert.isTrue(basePanel!.loading);
+ });
+});
diff --git a/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts b/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts
new file mode 100644
index 000000000..cfda47b22
--- /dev/null
+++ b/frontend/src/static/js/components/test/webstatus-notification-panel.test.ts
@@ -0,0 +1,117 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {assert, fixture, html} from '@open-wc/testing';
+import '../../components/webstatus-notification-panel.js';
+import type {WebstatusNotificationPanel} from '../../components/webstatus-notification-panel.js';
+
+describe('webstatus-notification-panel', () => {
+ it('renders content in the content slot', async () => {
+ const el = await fixture(html`
+
+
Test Content
+
+ `);
+ const contentSlot = el.shadowRoot!.querySelector(
+ 'slot[name="content"]',
+ );
+ assert.isNotNull(contentSlot);
+ const assignedNodes = contentSlot.assignedNodes({
+ flatten: true,
+ }) as HTMLElement[];
+ assert.include(assignedNodes[0].textContent, 'Test Content');
+ });
+
+ it('renders an icon in the icon slot', async () => {
+ const el = await fixture(html`
+
+
+
+ `);
+ const iconSlot =
+ el.shadowRoot!.querySelector('slot[name="icon"]');
+ assert.isNotNull(iconSlot);
+ const assignedNodes = iconSlot.assignedNodes({
+ flatten: true,
+ }) as HTMLElement[];
+ assert.equal(assignedNodes[0].tagName, 'SL-ICON');
+ assert.equal(assignedNodes[0].getAttribute('name'), 'test-icon');
+ });
+
+ it('renders a title in the title slot', async () => {
+ const el = await fixture(html`
+
+ Test Title
+
+ `);
+ const titleSlot =
+ el.shadowRoot!.querySelector('slot[name="title"]');
+ assert.isNotNull(titleSlot);
+ const assignedNodes = titleSlot.assignedNodes({
+ flatten: true,
+ }) as HTMLElement[];
+ assert.include(assignedNodes[0].textContent, 'Test Title');
+ });
+
+ it('renders actions in the actions slot', async () => {
+ const el = await fixture(html`
+
+ Action Button
+
+ `);
+ const actionsSlot = el.shadowRoot!.querySelector(
+ 'slot[name="actions"]',
+ );
+ assert.isNotNull(actionsSlot);
+ const assignedNodes = actionsSlot.assignedNodes({
+ flatten: true,
+ }) as HTMLElement[];
+ assert.equal(assignedNodes[0].tagName, 'SL-BUTTON');
+ assert.include(assignedNodes[0].textContent, 'Action Button');
+ });
+
+ it('displays skeletons when loading is true and hides content', async () => {
+ const el = await fixture(html`
+
+
+
+ `;
+ }
+}
diff --git a/frontend/src/static/js/components/webstatus-notification-panel.ts b/frontend/src/static/js/components/webstatus-notification-panel.ts
new file mode 100644
index 000000000..c1d577000
--- /dev/null
+++ b/frontend/src/static/js/components/webstatus-notification-panel.ts
@@ -0,0 +1,84 @@
+/**
+ * Copyright 2024 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+
+@customElement('webstatus-notification-panel')
+export class WebstatusNotificationPanel extends LitElement {
+ static styles = css`
+ .card {
+ border: 1px solid #e4e4e7;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ }
+
+ .card-header .title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: bold;
+ font-size: 16px;
+ }
+
+ .card-body {
+ padding: 0 20px 20px 20px;
+ }
+
+ .loading-skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px;
+ }
+ `;
+
+ @property({type: Boolean})
+ loading = false;
+
+ render() {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+ ${this.loading
+ ? html`
+
+
+
+
+ `
+ : html``}
+
+
+ `;
+ }
+}
diff --git a/frontend/src/static/js/components/webstatus-notification-rss-channels.ts b/frontend/src/static/js/components/webstatus-notification-rss-channels.ts
new file mode 100644
index 000000000..a7d25d04b
--- /dev/null
+++ b/frontend/src/static/js/components/webstatus-notification-rss-channels.ts
@@ -0,0 +1,47 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {LitElement, css, html} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import './webstatus-notification-panel.js';
+
+@customElement('webstatus-notification-rss-channels')
+export class WebstatusNotificationRssChannels extends LitElement {
+ static styles = css`
+ .card-body {
+ padding: 20px;
+ color: #71717a;
+ }
+ `;
+
+ render() {
+ return html`
+
+
+ RSS
+
+ Create RSS
+ channel
+
+
+
Coming soon
+
+
+ `;
+ }
+}
diff --git a/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts
new file mode 100644
index 000000000..ac02f9b0a
--- /dev/null
+++ b/frontend/src/static/js/components/webstatus-notification-webhook-channels.ts
@@ -0,0 +1,47 @@
+/**
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {LitElement, css, html} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import './webstatus-notification-panel.js';
+
+@customElement('webstatus-notification-webhook-channels')
+export class WebstatusNotificationWebhookChannels extends LitElement {
+ static styles = css`
+ .card-body {
+ padding: 20px;
+ color: #71717a;
+ }
+ `;
+
+ render() {
+ return html`
+
+
+ Webhook
+