From 08742cbea8bc05f5961312055c60253b4e10df31 Mon Sep 17 00:00:00 2001
From: Daniel Mallory
Date: Fri, 24 Oct 2025 12:00:20 +0100
Subject: [PATCH 1/6] Updates to allow passing notification communications
email to server.
---
client/data/settings/actions.js | 4 +
client/data/settings/hooks.js | 16 +++
client/data/settings/selectors.js | 4 +
.../__tests__/index.test.tsx | 87 +++++++++++++
.../notifications-email-input.test.tsx | 118 ++++++++++++++++++
.../settings/notification-settings/index.tsx | 41 ++++++
.../notifications-email-input.tsx | 50 ++++++++
client/settings/settings-manager/index.js | 13 ++
client/types/wcpay-data-settings-hooks.ts | 17 +++
...s-wc-rest-payments-settings-controller.php | 30 +++++
includes/class-wc-payment-gateway-wcpay.php | 2 +
includes/class-wc-payments-account.php | 11 ++
.../server/request/class-update-account.php | 11 ++
13 files changed, 404 insertions(+)
create mode 100644 client/settings/notification-settings/__tests__/index.test.tsx
create mode 100644 client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
create mode 100644 client/settings/notification-settings/index.tsx
create mode 100644 client/settings/notification-settings/notifications-email-input.tsx
diff --git a/client/data/settings/actions.js b/client/data/settings/actions.js
index 25cbc0b4fba..df5a4dbe74a 100644
--- a/client/data/settings/actions.js
+++ b/client/data/settings/actions.js
@@ -267,6 +267,10 @@ export function updateIsStripeBillingEnabled( isEnabled ) {
return updateSettingsValues( { is_stripe_billing_enabled: isEnabled } );
}
+export function updateCommunicationsEmail( email ) {
+ return updateSettingsValues( { communications_email: email } );
+}
+
export function* submitStripeBillingSubscriptionMigration() {
try {
yield dispatch( STORE_NAME ).startResolution(
diff --git a/client/data/settings/hooks.js b/client/data/settings/hooks.js
index 8ff69c33da0..fdc7d72d10b 100644
--- a/client/data/settings/hooks.js
+++ b/client/data/settings/hooks.js
@@ -456,6 +456,9 @@ export const usePaymentRequestButtonBorderRadius = () => {
];
};
+/**
+ * @return {import('wcpay/types/wcpay-data-settings-hooks').SavingError | null}
+ */
export const useGetSavingError = () => {
return useSelect( ( select ) => select( STORE_NAME ).getSavingError(), [] );
};
@@ -607,3 +610,16 @@ export const useStripeBillingMigration = () => {
];
}, [] );
};
+
+/**
+ * @return {import('wcpay/types/wcpay-data-settings-hooks').GenericSettingsHook}
+ */
+export const useCommunicationsEmail = () => {
+ const { updateCommunicationsEmail } = useDispatch( STORE_NAME );
+
+ const communicationsEmail = useSelect( ( select ) =>
+ select( STORE_NAME ).getCommunicationsEmail()
+ );
+
+ return [ communicationsEmail, updateCommunicationsEmail ];
+};
diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js
index 5f701b8457b..6d616315fdc 100644
--- a/client/data/settings/selectors.js
+++ b/client/data/settings/selectors.js
@@ -235,3 +235,7 @@ export const getStripeBillingSubscriptionCount = ( state ) => {
export const getStripeBillingMigratedCount = ( state ) => {
return getSettings( state ).stripe_billing_migrated_count || 0;
};
+
+export const getCommunicationsEmail = ( state ) => {
+ return getSettings( state ).communications_email || '';
+};
diff --git a/client/settings/notification-settings/__tests__/index.test.tsx b/client/settings/notification-settings/__tests__/index.test.tsx
new file mode 100644
index 00000000000..3180ea43f12
--- /dev/null
+++ b/client/settings/notification-settings/__tests__/index.test.tsx
@@ -0,0 +1,87 @@
+/** @format */
+
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import NotificationSettings, {
+ NotificationSettingsDescription,
+} from '../index';
+import { useCommunicationsEmail, useGetSavingError } from 'wcpay/data';
+
+jest.mock( 'wcpay/data', () => ( {
+ useCommunicationsEmail: jest.fn(),
+ useGetSavingError: jest.fn(),
+} ) );
+
+const mockUseCommunicationsEmail = useCommunicationsEmail as jest.MockedFunction<
+ typeof useCommunicationsEmail
+>;
+const mockUseGetSavingError = useGetSavingError as jest.MockedFunction<
+ typeof useGetSavingError
+>;
+
+describe( 'NotificationSettings', () => {
+ beforeEach( () => {
+ mockUseCommunicationsEmail.mockReturnValue( [
+ 'test@example.com',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( null );
+ } );
+
+ it( 'renders the notification settings section', () => {
+ render( );
+
+ expect(
+ screen.getByLabelText( 'Communications email' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders with the communications email input', () => {
+ const testEmail = 'communications@example.com';
+ mockUseCommunicationsEmail.mockReturnValue( [ testEmail, jest.fn() ] );
+
+ render( );
+
+ expect( screen.getByDisplayValue( testEmail ) ).toBeInTheDocument();
+ } );
+} );
+
+describe( 'NotificationSettingsDescription', () => {
+ it( 'renders the title', () => {
+ render( );
+
+ expect(
+ screen.getByRole( 'heading', { name: 'Notifications' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders the description text', () => {
+ render( );
+
+ expect(
+ screen.getByText(
+ 'Configure how you receive important alerts about your WooPayments account.'
+ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders the learn more link', () => {
+ render( );
+
+ const link = screen.getByRole( 'link', {
+ name: /Learn more/,
+ } );
+ expect( link ).toBeInTheDocument();
+ expect( link ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/document/woopayments/'
+ );
+ } );
+} );
diff --git a/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
new file mode 100644
index 00000000000..4277f9338d0
--- /dev/null
+++ b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
@@ -0,0 +1,118 @@
+/** @format */
+
+/**
+ * External dependencies
+ */
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import NotificationsEmailInput from '../notifications-email-input';
+import { useGetSavingError, useCommunicationsEmail } from 'wcpay/data';
+
+jest.mock( 'wcpay/data', () => ( {
+ useCommunicationsEmail: jest.fn(),
+ useGetSavingError: jest.fn(),
+} ) );
+
+const mockUseCommunicationsEmail = useCommunicationsEmail as jest.MockedFunction<
+ typeof useCommunicationsEmail
+>;
+const mockUseGetSavingError = useGetSavingError as jest.MockedFunction<
+ typeof useGetSavingError
+>;
+
+describe( 'NotificationsEmailInput', () => {
+ beforeEach( () => {
+ mockUseCommunicationsEmail.mockReturnValue( [
+ 'communications@test.com',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( null );
+ } );
+
+ it( 'displays and updates email address', () => {
+ const oldEmail = 'old.communications@test.com';
+ const setCommunicationsEmail = jest.fn();
+ mockUseCommunicationsEmail.mockReturnValue( [
+ oldEmail,
+ setCommunicationsEmail,
+ ] );
+
+ render( );
+
+ expect( screen.getByDisplayValue( oldEmail ) ).toBeInTheDocument();
+
+ const newEmail = 'new.communications@test.com';
+ fireEvent.change( screen.getByLabelText( 'Communications email' ), {
+ target: { value: newEmail },
+ } );
+
+ expect( setCommunicationsEmail ).toHaveBeenCalledWith( newEmail );
+ } );
+
+ it( 'renders with empty email', () => {
+ mockUseCommunicationsEmail.mockReturnValue( [ '', jest.fn() ] );
+
+ const { container } = render( );
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ).toBeNull();
+ } );
+
+ it( 'displays the error message for invalid email', () => {
+ mockUseCommunicationsEmail.mockReturnValue( [
+ 'invalid.email',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( {
+ code: 'rest_invalid_param',
+ message: 'Invalid parameter(s): communications_email',
+ data: {
+ status: 400,
+ params: {
+ communications_email:
+ 'Error: Invalid email address: invalid.email',
+ },
+ details: {
+ communications_email: {
+ code: 'rest_invalid_pattern',
+ message: 'Error: Invalid email address: invalid.email',
+ data: null,
+ },
+ },
+ },
+ } );
+
+ const { container } = render( );
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ?.textContent
+ ).toMatch( /Error: Invalid email address: / );
+ } );
+
+ it( 'does not display error when saving error is null', () => {
+ mockUseCommunicationsEmail.mockReturnValue( [
+ 'valid@test.com',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( null );
+
+ const { container } = render( );
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ).toBeNull();
+ } );
+
+ it( 'renders help text', () => {
+ render( );
+
+ expect(
+ screen.getByText(
+ 'Email address used for WooPayments communications.'
+ )
+ ).toBeInTheDocument();
+ } );
+} );
diff --git a/client/settings/notification-settings/index.tsx b/client/settings/notification-settings/index.tsx
new file mode 100644
index 00000000000..57be396160c
--- /dev/null
+++ b/client/settings/notification-settings/index.tsx
@@ -0,0 +1,41 @@
+/** @format **/
+
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Card, ExternalLink } from '@wordpress/components';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import CardBody from '../card-body';
+import NotificationsEmailInput from './notifications-email-input';
+
+export const NotificationSettingsDescription: React.FC = () => (
+ <>
+ { __( 'Notifications', 'woocommerce-payments' ) }
+
+ { __(
+ 'Configure how you receive important alerts about your WooPayments account.',
+ 'woocommerce-payments'
+ ) }
+
+
+ { __( 'Learn more', 'woocommerce-payments' ) }
+
+ >
+);
+
+const NotificationSettings: React.FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default NotificationSettings;
diff --git a/client/settings/notification-settings/notifications-email-input.tsx b/client/settings/notification-settings/notifications-email-input.tsx
new file mode 100644
index 00000000000..90f0a7b204e
--- /dev/null
+++ b/client/settings/notification-settings/notifications-email-input.tsx
@@ -0,0 +1,50 @@
+/** @format **/
+
+/**
+ * External dependencies
+ */
+import { TextControl, Notice } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import React from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { useCommunicationsEmail, useGetSavingError } from 'wcpay/data';
+
+const NotificationsEmailInput: React.FC = () => {
+ const [
+ communicationsEmail,
+ setCommunicationsEmail,
+ ] = useCommunicationsEmail();
+
+ const savingError = useGetSavingError();
+ const communicationsEmailError =
+ savingError?.data?.details?.communications_email?.message;
+
+ return (
+ <>
+ { communicationsEmailError && (
+
+ { communicationsEmailError }
+
+ ) }
+
+
+ >
+ );
+};
+
+export default NotificationsEmailInput;
diff --git a/client/settings/settings-manager/index.js b/client/settings/settings-manager/index.js
index 6c05a8b7ceb..f42e2ee43e6 100644
--- a/client/settings/settings-manager/index.js
+++ b/client/settings/settings-manager/index.js
@@ -23,6 +23,9 @@ import LoadableSettingsSection from '../loadable-settings-section';
import PaymentMethodsSection from '../payment-methods-section';
import BuyNowPayLaterSection from '../buy-now-pay-later-section';
import ErrorBoundary from '../../components/error-boundary';
+import NotificationSettings, {
+ NotificationSettingsDescription,
+} from '../notification-settings';
import {
useDepositDelayDays,
useGetDuplicatedPaymentMethodIds,
@@ -300,6 +303,16 @@ const SettingsManager = () => {
+
+
+
+
+
+
+
void
];
+
+export interface SavingError {
+ code?: string;
+ message?: string;
+ data?: {
+ status?: number;
+ params?: Record< string, string >;
+ details?: Record<
+ string,
+ {
+ code?: string;
+ message?: string;
+ data?: unknown;
+ }
+ >;
+ };
+}
diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php
index 08ba7cc3215..5d149c8d55b 100644
--- a/includes/admin/class-wc-rest-payments-settings-controller.php
+++ b/includes/admin/class-wc-rest-payments-settings-controller.php
@@ -304,6 +304,11 @@ public function register_routes() {
'type' => 'int',
'validate_callback' => 'rest_validate_request_arg',
],
+ 'communications_email' => [
+ 'description' => __( 'Email address used for WooPayments communications.', 'woocommerce-payments' ),
+ 'type' => 'string',
+ 'validate_callback' => [ $this, 'validate_communications_email' ],
+ ],
],
]
);
@@ -432,6 +437,30 @@ public function validate_business_support_address( array $value, WP_REST_Request
return true;
}
+ /**
+ * Validate the communications email.
+ *
+ * @param string $value The value being validated.
+ * @param WP_REST_Request $request The request made.
+ * @param string $param The parameter name, used in error messages.
+ * @return true|WP_Error
+ */
+ public function validate_communications_email( string $value, WP_REST_Request $request, string $param ) {
+ $string_validation_result = rest_validate_request_arg( $value, $request, $param );
+ if ( true !== $string_validation_result ) {
+ return $string_validation_result;
+ }
+
+ if ( '' !== $value && ! is_email( $value ) ) {
+ return new WP_Error(
+ 'rest_invalid_pattern',
+ __( 'Error: Invalid email address: ', 'woocommerce-payments' ) . $value
+ );
+ }
+
+ return true;
+ }
+
/**
* Retrieve settings.
*
@@ -526,6 +555,7 @@ public function get_settings(): WP_REST_Response {
'is_migrating_stripe_billing' => $is_migrating_stripe_billing ?? false,
'stripe_billing_subscription_count' => $stripe_billing_subscription_count ?? 0,
'stripe_billing_migrated_count' => $stripe_billing_migrated_count ?? 0,
+ 'communications_email' => $this->account->get_communications_email(),
]
);
}
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index 78780e977c0..910cf79a03a 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -112,6 +112,8 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC {
'deposit_schedule_interval' => 'deposit_schedule_interval',
'deposit_schedule_weekly_anchor' => 'deposit_schedule_weekly_anchor',
'deposit_schedule_monthly_anchor' => 'deposit_schedule_monthly_anchor',
+
+ 'communications_email' => 'communications_email',
];
const UPDATE_SAVED_PAYMENT_METHOD = 'wcpay_update_saved_payment_method';
diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php
index af62d5e7f26..1fcd2ced56b 100644
--- a/includes/class-wc-payments-account.php
+++ b/includes/class-wc-payments-account.php
@@ -371,6 +371,7 @@ public function get_account_status_data(): array {
'accountLink' => empty( $account['is_test_drive'] ) ? $this->get_login_url() : false,
'hasSubmittedVatData' => $account['has_submitted_vat_data'] ?? false,
'isDocumentsEnabled' => $account['is_documents_enabled'] ?? false,
+ 'communicationsEmail' => $account['communications_email'] ?? '',
'requirements' => [
'errors' => $account['requirements']['errors'] ?? [],
],
@@ -484,6 +485,16 @@ public function get_business_support_phone(): string {
return isset( $account['business_profile']['support_phone'] ) ? $account['business_profile']['support_phone'] : '';
}
+ /**
+ * Gets the communications email.
+ *
+ * @return string Communications email.
+ */
+ public function get_communications_email(): string {
+ $account = $this->get_cached_account_data();
+ return isset( $account['communications_email'] ) ? $account['communications_email'] : '';
+ }
+
/**
* Gets the branding logo.
*
diff --git a/includes/core/server/request/class-update-account.php b/includes/core/server/request/class-update-account.php
index 24dc72ae1e5..431cbb69832 100644
--- a/includes/core/server/request/class-update-account.php
+++ b/includes/core/server/request/class-update-account.php
@@ -251,4 +251,15 @@ public function set_deposit_schedule_monthly_anchor( string $deposit_schedule_mo
public function set_locale( string $locale ) {
$this->set_param( 'locale', $locale );
}
+
+ /**
+ * Sets the communications email.
+ *
+ * @param string $communications_email Communications email.
+ *
+ * @return void
+ */
+ public function set_communications_email( string $communications_email ) {
+ $this->set_param( 'communications_email', $communications_email );
+ }
}
From cd7fb5d1ce44a887b1a424e5483b4ce7361b1b5d Mon Sep 17 00:00:00 2001
From: Daniel Mallory
Date: Fri, 24 Oct 2025 12:22:43 +0100
Subject: [PATCH 2/6] Updates to allow passing notification communications
email to server.
---
.../notifications-email-input.test.tsx | 23 +++++++++++++++++--
.../notifications-email-input.tsx | 1 +
...s-wc-rest-payments-settings-controller.php | 9 +++++++-
3 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
index 4277f9338d0..596a867501a 100644
--- a/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
+++ b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
@@ -53,13 +53,32 @@ describe( 'NotificationsEmailInput', () => {
expect( setCommunicationsEmail ).toHaveBeenCalledWith( newEmail );
} );
- it( 'renders with empty email', () => {
+ it( 'displays error message for empty email', () => {
mockUseCommunicationsEmail.mockReturnValue( [ '', jest.fn() ] );
+ mockUseGetSavingError.mockReturnValue( {
+ code: 'rest_invalid_param',
+ message: 'Invalid parameter(s): communications_email',
+ data: {
+ status: 400,
+ params: {
+ communications_email:
+ 'Error: Communications email is required.',
+ },
+ details: {
+ communications_email: {
+ code: 'rest_invalid_pattern',
+ message: 'Error: Communications email is required.',
+ data: null,
+ },
+ },
+ },
+ } );
const { container } = render( );
expect(
container.querySelector( '.components-notice.is-error' )
- ).toBeNull();
+ ?.textContent
+ ).toMatch( /Error: Communications email is required./ );
} );
it( 'displays the error message for invalid email', () => {
diff --git a/client/settings/notification-settings/notifications-email-input.tsx b/client/settings/notification-settings/notifications-email-input.tsx
index 90f0a7b204e..9995f6f4467 100644
--- a/client/settings/notification-settings/notifications-email-input.tsx
+++ b/client/settings/notification-settings/notifications-email-input.tsx
@@ -40,6 +40,7 @@ const NotificationsEmailInput: React.FC = () => {
value={ communicationsEmail }
onChange={ setCommunicationsEmail }
data-testid={ 'notifications-email-input' }
+ required
__nextHasNoMarginBottom
__next40pxDefaultSize
/>
diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php
index 5d149c8d55b..8e93d9c1bd7 100644
--- a/includes/admin/class-wc-rest-payments-settings-controller.php
+++ b/includes/admin/class-wc-rest-payments-settings-controller.php
@@ -451,7 +451,14 @@ public function validate_communications_email( string $value, WP_REST_Request $r
return $string_validation_result;
}
- if ( '' !== $value && ! is_email( $value ) ) {
+ if ( '' === $value ) {
+ return new WP_Error(
+ 'rest_invalid_pattern',
+ __( 'Error: Communications email is required.', 'woocommerce-payments' )
+ );
+ }
+
+ if ( ! is_email( $value ) ) {
return new WP_Error(
'rest_invalid_pattern',
__( 'Error: Invalid email address: ', 'woocommerce-payments' ) . $value
From 3b94b09982e3105cce9fbb6695bc5c54f9fe9ee0 Mon Sep 17 00:00:00 2001
From: Daniel Mallory
Date: Fri, 24 Oct 2025 12:23:37 +0100
Subject: [PATCH 3/6] Update changelog.
---
changelog/dev-specify-notifications-email | 4 ++++
1 file changed, 4 insertions(+)
create mode 100644 changelog/dev-specify-notifications-email
diff --git a/changelog/dev-specify-notifications-email b/changelog/dev-specify-notifications-email
new file mode 100644
index 00000000000..43bf6849664
--- /dev/null
+++ b/changelog/dev-specify-notifications-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add ability to specify preferred communications email.
From a94ce53a55897cea3a948089b0df6bcada6093d4 Mon Sep 17 00:00:00 2001
From: Daniel Mallory
Date: Mon, 27 Oct 2025 16:24:49 +0000
Subject: [PATCH 4/6] fix styling.
---
client/settings/notification-settings/index.tsx | 1 +
client/settings/notification-settings/style.scss | 11 +++++++++++
2 files changed, 12 insertions(+)
create mode 100644 client/settings/notification-settings/style.scss
diff --git a/client/settings/notification-settings/index.tsx b/client/settings/notification-settings/index.tsx
index 57be396160c..8993b5ef7cb 100644
--- a/client/settings/notification-settings/index.tsx
+++ b/client/settings/notification-settings/index.tsx
@@ -12,6 +12,7 @@ import React from 'react';
*/
import CardBody from '../card-body';
import NotificationsEmailInput from './notifications-email-input';
+import './style.scss';
export const NotificationSettingsDescription: React.FC = () => (
<>
diff --git a/client/settings/notification-settings/style.scss b/client/settings/notification-settings/style.scss
new file mode 100644
index 00000000000..eb3d934594f
--- /dev/null
+++ b/client/settings/notification-settings/style.scss
@@ -0,0 +1,11 @@
+.notification-settings {
+ .components-notice {
+ margin-left: 0;
+ margin-right: 0;
+ margin-bottom: 1em;
+ }
+
+ .settings__notifications-email-input {
+ max-width: 500px;
+ }
+}
From 2ff0ca319e980de8e03c3267ceeb23f57600c964 Mon Sep 17 00:00:00 2001
From: Daniel Mallory
Date: Tue, 2 Dec 2025 14:34:26 +0000
Subject: [PATCH 5/6] Improve notifications email feature with validation and
consistency fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rename communications_email to account_communications_email for consistency
with other account settings (account_business_name, account_branding_*, etc.)
- Add client-side email validation with error display on blur
- Add PHP unit tests for validate_account_communications_email
- Add test for get_settings returning account_communications_email
- Update Learn more link to specific documentation URL
- Add get_account_communications_email method to gateway for consistency
- Route account_communications_email through gateway's get_option() method
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
client/data/settings/actions.js | 4 +-
client/data/settings/hooks.js | 10 +-
client/data/settings/selectors.js | 4 +-
.../__tests__/index.test.tsx | 17 +--
.../notifications-email-input.test.tsx | 116 +++++++++++++++---
.../settings/notification-settings/index.tsx | 2 +-
.../notifications-email-input.tsx | 54 ++++++--
...s-wc-rest-payments-settings-controller.php | 16 +--
includes/class-wc-payment-gateway-wcpay.php | 25 +++-
...s-wc-rest-payments-settings-controller.php | 52 ++++++++
10 files changed, 243 insertions(+), 57 deletions(-)
diff --git a/client/data/settings/actions.js b/client/data/settings/actions.js
index df5a4dbe74a..c36ddc37537 100644
--- a/client/data/settings/actions.js
+++ b/client/data/settings/actions.js
@@ -267,8 +267,8 @@ export function updateIsStripeBillingEnabled( isEnabled ) {
return updateSettingsValues( { is_stripe_billing_enabled: isEnabled } );
}
-export function updateCommunicationsEmail( email ) {
- return updateSettingsValues( { communications_email: email } );
+export function updateAccountCommunicationsEmail( email ) {
+ return updateSettingsValues( { account_communications_email: email } );
}
export function* submitStripeBillingSubscriptionMigration() {
diff --git a/client/data/settings/hooks.js b/client/data/settings/hooks.js
index fdc7d72d10b..a43375c31c4 100644
--- a/client/data/settings/hooks.js
+++ b/client/data/settings/hooks.js
@@ -614,12 +614,12 @@ export const useStripeBillingMigration = () => {
/**
* @return {import('wcpay/types/wcpay-data-settings-hooks').GenericSettingsHook}
*/
-export const useCommunicationsEmail = () => {
- const { updateCommunicationsEmail } = useDispatch( STORE_NAME );
+export const useAccountCommunicationsEmail = () => {
+ const { updateAccountCommunicationsEmail } = useDispatch( STORE_NAME );
- const communicationsEmail = useSelect( ( select ) =>
- select( STORE_NAME ).getCommunicationsEmail()
+ const accountCommunicationsEmail = useSelect( ( select ) =>
+ select( STORE_NAME ).getAccountCommunicationsEmail()
);
- return [ communicationsEmail, updateCommunicationsEmail ];
+ return [ accountCommunicationsEmail, updateAccountCommunicationsEmail ];
};
diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js
index 6d616315fdc..15c8e01724a 100644
--- a/client/data/settings/selectors.js
+++ b/client/data/settings/selectors.js
@@ -236,6 +236,6 @@ export const getStripeBillingMigratedCount = ( state ) => {
return getSettings( state ).stripe_billing_migrated_count || 0;
};
-export const getCommunicationsEmail = ( state ) => {
- return getSettings( state ).communications_email || '';
+export const getAccountCommunicationsEmail = ( state ) => {
+ return getSettings( state ).account_communications_email || '';
};
diff --git a/client/settings/notification-settings/__tests__/index.test.tsx b/client/settings/notification-settings/__tests__/index.test.tsx
index 3180ea43f12..6a10532dbed 100644
--- a/client/settings/notification-settings/__tests__/index.test.tsx
+++ b/client/settings/notification-settings/__tests__/index.test.tsx
@@ -12,15 +12,15 @@ import { render, screen } from '@testing-library/react';
import NotificationSettings, {
NotificationSettingsDescription,
} from '../index';
-import { useCommunicationsEmail, useGetSavingError } from 'wcpay/data';
+import { useAccountCommunicationsEmail, useGetSavingError } from 'wcpay/data';
jest.mock( 'wcpay/data', () => ( {
- useCommunicationsEmail: jest.fn(),
+ useAccountCommunicationsEmail: jest.fn(),
useGetSavingError: jest.fn(),
} ) );
-const mockUseCommunicationsEmail = useCommunicationsEmail as jest.MockedFunction<
- typeof useCommunicationsEmail
+const mockUseAccountCommunicationsEmail = useAccountCommunicationsEmail as jest.MockedFunction<
+ typeof useAccountCommunicationsEmail
>;
const mockUseGetSavingError = useGetSavingError as jest.MockedFunction<
typeof useGetSavingError
@@ -28,7 +28,7 @@ const mockUseGetSavingError = useGetSavingError as jest.MockedFunction<
describe( 'NotificationSettings', () => {
beforeEach( () => {
- mockUseCommunicationsEmail.mockReturnValue( [
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
'test@example.com',
jest.fn(),
] );
@@ -45,7 +45,10 @@ describe( 'NotificationSettings', () => {
it( 'renders with the communications email input', () => {
const testEmail = 'communications@example.com';
- mockUseCommunicationsEmail.mockReturnValue( [ testEmail, jest.fn() ] );
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
+ testEmail,
+ jest.fn(),
+ ] );
render( );
@@ -81,7 +84,7 @@ describe( 'NotificationSettingsDescription', () => {
expect( link ).toBeInTheDocument();
expect( link ).toHaveAttribute(
'href',
- 'https://woocommerce.com/document/woopayments/'
+ 'https://woocommerce.com/document/woopayments/account-management/change-email-for-woopayments-alerts/'
);
} );
} );
diff --git a/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
index 596a867501a..0da9a7547b3 100644
--- a/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
+++ b/client/settings/notification-settings/__tests__/notifications-email-input.test.tsx
@@ -10,15 +10,15 @@ import { fireEvent, render, screen } from '@testing-library/react';
* Internal dependencies
*/
import NotificationsEmailInput from '../notifications-email-input';
-import { useGetSavingError, useCommunicationsEmail } from 'wcpay/data';
+import { useGetSavingError, useAccountCommunicationsEmail } from 'wcpay/data';
jest.mock( 'wcpay/data', () => ( {
- useCommunicationsEmail: jest.fn(),
+ useAccountCommunicationsEmail: jest.fn(),
useGetSavingError: jest.fn(),
} ) );
-const mockUseCommunicationsEmail = useCommunicationsEmail as jest.MockedFunction<
- typeof useCommunicationsEmail
+const mockUseAccountCommunicationsEmail = useAccountCommunicationsEmail as jest.MockedFunction<
+ typeof useAccountCommunicationsEmail
>;
const mockUseGetSavingError = useGetSavingError as jest.MockedFunction<
typeof useGetSavingError
@@ -26,7 +26,7 @@ const mockUseGetSavingError = useGetSavingError as jest.MockedFunction<
describe( 'NotificationsEmailInput', () => {
beforeEach( () => {
- mockUseCommunicationsEmail.mockReturnValue( [
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
'communications@test.com',
jest.fn(),
] );
@@ -35,10 +35,10 @@ describe( 'NotificationsEmailInput', () => {
it( 'displays and updates email address', () => {
const oldEmail = 'old.communications@test.com';
- const setCommunicationsEmail = jest.fn();
- mockUseCommunicationsEmail.mockReturnValue( [
+ const setAccountCommunicationsEmail = jest.fn();
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
oldEmail,
- setCommunicationsEmail,
+ setAccountCommunicationsEmail,
] );
render( );
@@ -50,22 +50,24 @@ describe( 'NotificationsEmailInput', () => {
target: { value: newEmail },
} );
- expect( setCommunicationsEmail ).toHaveBeenCalledWith( newEmail );
+ expect( setAccountCommunicationsEmail ).toHaveBeenCalledWith(
+ newEmail
+ );
} );
it( 'displays error message for empty email', () => {
- mockUseCommunicationsEmail.mockReturnValue( [ '', jest.fn() ] );
+ mockUseAccountCommunicationsEmail.mockReturnValue( [ '', jest.fn() ] );
mockUseGetSavingError.mockReturnValue( {
code: 'rest_invalid_param',
- message: 'Invalid parameter(s): communications_email',
+ message: 'Invalid parameter(s): account_communications_email',
data: {
status: 400,
params: {
- communications_email:
+ account_communications_email:
'Error: Communications email is required.',
},
details: {
- communications_email: {
+ account_communications_email: {
code: 'rest_invalid_pattern',
message: 'Error: Communications email is required.',
data: null,
@@ -82,21 +84,21 @@ describe( 'NotificationsEmailInput', () => {
} );
it( 'displays the error message for invalid email', () => {
- mockUseCommunicationsEmail.mockReturnValue( [
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
'invalid.email',
jest.fn(),
] );
mockUseGetSavingError.mockReturnValue( {
code: 'rest_invalid_param',
- message: 'Invalid parameter(s): communications_email',
+ message: 'Invalid parameter(s): account_communications_email',
data: {
status: 400,
params: {
- communications_email:
+ account_communications_email:
'Error: Invalid email address: invalid.email',
},
details: {
- communications_email: {
+ account_communications_email: {
code: 'rest_invalid_pattern',
message: 'Error: Invalid email address: invalid.email',
data: null,
@@ -113,7 +115,7 @@ describe( 'NotificationsEmailInput', () => {
} );
it( 'does not display error when saving error is null', () => {
- mockUseCommunicationsEmail.mockReturnValue( [
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
'valid@test.com',
jest.fn(),
] );
@@ -134,4 +136,82 @@ describe( 'NotificationsEmailInput', () => {
)
).toBeInTheDocument();
} );
+
+ it( 'displays client-side validation error for invalid email after blur', () => {
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
+ 'invalid-email',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( null );
+
+ const { container } = render( );
+
+ // Error should not be shown before blur
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ).toBeNull();
+
+ // Trigger blur event
+ fireEvent.blur( screen.getByLabelText( 'Communications email' ) );
+
+ // Error should be shown after blur
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ?.textContent
+ ).toMatch( /Please enter a valid email address./ );
+ } );
+
+ it( 'does not display client-side validation error for valid email after blur', () => {
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
+ 'valid@test.com',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( null );
+
+ const { container } = render( );
+
+ // Trigger blur event
+ fireEvent.blur( screen.getByLabelText( 'Communications email' ) );
+
+ // No error should be shown for valid email
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ).toBeNull();
+ } );
+
+ it( 'server error takes precedence over client-side validation error', () => {
+ mockUseAccountCommunicationsEmail.mockReturnValue( [
+ 'invalid-email',
+ jest.fn(),
+ ] );
+ mockUseGetSavingError.mockReturnValue( {
+ code: 'rest_invalid_param',
+ message: 'Invalid parameter(s): account_communications_email',
+ data: {
+ status: 400,
+ params: {
+ account_communications_email:
+ 'Error: Invalid email address: invalid-email',
+ },
+ details: {
+ account_communications_email: {
+ code: 'rest_invalid_pattern',
+ message: 'Error: Invalid email address: invalid-email',
+ data: null,
+ },
+ },
+ },
+ } );
+
+ const { container } = render( );
+
+ // Trigger blur to enable client-side validation
+ fireEvent.blur( screen.getByLabelText( 'Communications email' ) );
+
+ // Server error should be shown instead of client-side error
+ expect(
+ container.querySelector( '.components-notice.is-error' )
+ ?.textContent
+ ).toMatch( /Error: Invalid email address: invalid-email/ );
+ } );
} );
diff --git a/client/settings/notification-settings/index.tsx b/client/settings/notification-settings/index.tsx
index 8993b5ef7cb..bee47ddd4dc 100644
--- a/client/settings/notification-settings/index.tsx
+++ b/client/settings/notification-settings/index.tsx
@@ -23,7 +23,7 @@ export const NotificationSettingsDescription: React.FC = () => (
'woocommerce-payments'
) }
-
+
{ __( 'Learn more', 'woocommerce-payments' ) }
>
diff --git a/client/settings/notification-settings/notifications-email-input.tsx b/client/settings/notification-settings/notifications-email-input.tsx
index 9995f6f4467..811b5a578ef 100644
--- a/client/settings/notification-settings/notifications-email-input.tsx
+++ b/client/settings/notification-settings/notifications-email-input.tsx
@@ -5,28 +5,58 @@
*/
import { TextControl, Notice } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
-import React from 'react';
+import React, { useState } from 'react';
/**
* Internal dependencies
*/
-import { useCommunicationsEmail, useGetSavingError } from 'wcpay/data';
+import { useAccountCommunicationsEmail, useGetSavingError } from 'wcpay/data';
+
+/**
+ * Validates an email address format.
+ *
+ * @param email The email address to validate.
+ * @return Whether the email is valid.
+ */
+const isValidEmail = ( email: string ): boolean => {
+ if ( ! email ) {
+ return false;
+ }
+ // Basic email validation regex
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test( email );
+};
const NotificationsEmailInput: React.FC = () => {
const [
- communicationsEmail,
- setCommunicationsEmail,
- ] = useCommunicationsEmail();
+ accountCommunicationsEmail,
+ setAccountCommunicationsEmail,
+ ] = useAccountCommunicationsEmail();
+
+ const [ hasBlurred, setHasBlurred ] = useState( false );
const savingError = useGetSavingError();
- const communicationsEmailError =
- savingError?.data?.details?.communications_email?.message;
+ const serverError =
+ savingError?.data?.details?.account_communications_email?.message;
+
+ // Only show client-side validation error if user has interacted with the field
+ const showClientValidationError =
+ hasBlurred &&
+ accountCommunicationsEmail !== '' &&
+ ! isValidEmail( accountCommunicationsEmail );
+
+ const clientValidationError = showClientValidationError
+ ? __( 'Please enter a valid email address.', 'woocommerce-payments' )
+ : null;
+
+ // Server error takes precedence over client validation error
+ const errorMessage = serverError || clientValidationError;
return (
<>
- { communicationsEmailError && (
+ { errorMessage && (
- { communicationsEmailError }
+ { errorMessage }
) }
@@ -37,9 +67,11 @@ const NotificationsEmailInput: React.FC = () => {
'woocommerce-payments'
) }
label={ __( 'Communications email', 'woocommerce-payments' ) }
- value={ communicationsEmail }
- onChange={ setCommunicationsEmail }
+ value={ accountCommunicationsEmail }
+ onChange={ setAccountCommunicationsEmail }
+ onBlur={ () => setHasBlurred( true ) }
data-testid={ 'notifications-email-input' }
+ type="email"
required
__nextHasNoMarginBottom
__next40pxDefaultSize
diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php
index a2f6b84157d..88e464ac020 100644
--- a/includes/admin/class-wc-rest-payments-settings-controller.php
+++ b/includes/admin/class-wc-rest-payments-settings-controller.php
@@ -191,6 +191,11 @@ public function register_routes() {
'description' => __( 'A CSS hex color value representing the secondary branding color for this account.', 'woocommerce-payments' ),
'type' => 'string',
],
+ 'account_communications_email' => [
+ 'description' => __( 'Email address used for WooPayments communications.', 'woocommerce-payments' ),
+ 'type' => 'string',
+ 'validate_callback' => [ $this, 'validate_account_communications_email' ],
+ ],
'deposit_schedule_interval' => [
'description' => __( 'An interval for deposit scheduling.', 'woocommerce-payments' ),
'type' => 'string',
@@ -304,11 +309,6 @@ public function register_routes() {
'type' => 'int',
'validate_callback' => 'rest_validate_request_arg',
],
- 'communications_email' => [
- 'description' => __( 'Email address used for WooPayments communications.', 'woocommerce-payments' ),
- 'type' => 'string',
- 'validate_callback' => [ $this, 'validate_communications_email' ],
- ],
],
]
);
@@ -438,14 +438,14 @@ public function validate_business_support_address( array $value, WP_REST_Request
}
/**
- * Validate the communications email.
+ * Validate the account communications email.
*
* @param string $value The value being validated.
* @param WP_REST_Request $request The request made.
* @param string $param The parameter name, used in error messages.
* @return true|WP_Error
*/
- public function validate_communications_email( string $value, WP_REST_Request $request, string $param ) {
+ public function validate_account_communications_email( string $value, WP_REST_Request $request, string $param ) {
$string_validation_result = rest_validate_request_arg( $value, $request, $param );
if ( true !== $string_validation_result ) {
return $string_validation_result;
@@ -535,6 +535,7 @@ public function get_settings(): WP_REST_Response {
'account_branding_primary_color' => $this->wcpay_gateway->get_option( 'account_branding_primary_color' ),
'account_branding_secondary_color' => $this->wcpay_gateway->get_option( 'account_branding_secondary_color' ),
'account_domestic_currency' => $this->wcpay_gateway->get_option( 'account_domestic_currency' ),
+ 'account_communications_email' => $this->wcpay_gateway->get_option( 'account_communications_email' ),
'is_payment_request_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'payment_request' ),
'is_apple_google_pay_in_payment_methods_options_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'apple_google_pay_in_payment_methods_options' ),
'is_debug_log_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'enable_logging' ),
@@ -562,7 +563,6 @@ public function get_settings(): WP_REST_Response {
'is_migrating_stripe_billing' => $is_migrating_stripe_billing ?? false,
'stripe_billing_subscription_count' => $stripe_billing_subscription_count ?? 0,
'stripe_billing_migrated_count' => $stripe_billing_migrated_count ?? 0,
- 'communications_email' => $this->account->get_communications_email(),
]
);
}
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index 09a5714e76a..f24f6251c93 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -101,12 +101,11 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC {
'account_branding_icon' => 'branding_icon',
'account_branding_primary_color' => 'branding_primary_color',
'account_branding_secondary_color' => 'branding_secondary_color',
+ 'account_communications_email' => 'communications_email',
'deposit_schedule_interval' => 'deposit_schedule_interval',
'deposit_schedule_weekly_anchor' => 'deposit_schedule_weekly_anchor',
'deposit_schedule_monthly_anchor' => 'deposit_schedule_monthly_anchor',
-
- 'communications_email' => 'communications_email',
];
const UPDATE_SAVED_PAYMENT_METHOD = 'wcpay_update_saved_payment_method';
@@ -116,7 +115,6 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC {
*
* @type int
*/
-
const USER_FORMATTED_TOKENS_LIMIT = 100;
const PROCESS_REDIRECT_ORDER_MISMATCH_ERROR_CODE = 'upe_process_redirect_order_id_mismatched';
@@ -2479,6 +2477,8 @@ public function get_option( $key, $empty_value = null ) {
return $this->get_account_branding_secondary_color();
case 'account_domestic_currency':
return $this->get_account_domestic_currency();
+ case 'account_communications_email':
+ return $this->get_account_communications_email();
case 'deposit_schedule_interval':
return $this->get_deposit_schedule_interval();
case 'deposit_schedule_weekly_anchor':
@@ -2847,6 +2847,25 @@ protected function get_account_branding_secondary_color( $default_value = '' ):
return $default_value;
}
+ /**
+ * Gets connected account communications email.
+ *
+ * @param string $default_value Value to return when not connected or failed to fetch communications email.
+ *
+ * @return string Communications email or default value.
+ */
+ protected function get_account_communications_email( $default_value = '' ): string {
+ try {
+ if ( $this->is_connected() ) {
+ return $this->account->get_communications_email();
+ }
+ } catch ( Exception $e ) {
+ Logger::error( 'Failed to get account\'s communications email.' . $e );
+ }
+
+ return $default_value;
+ }
+
/**
* Retrieves the domestic currency of the current account based on its country.
* It will fallback to the store's currency if the account's country is not supported.
diff --git a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php
index 0dacc70c509..e2045e9fbc2 100644
--- a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php
+++ b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php
@@ -1056,4 +1056,56 @@ public function account_business_support_phone_validation_provider() {
],
];
}
+
+ /**
+ * Tests account communications email validator
+ *
+ * @dataProvider account_communications_email_validation_provider
+ */
+ public function test_validate_account_communications_email( $value, $request, $param, $expected ) {
+ $return = $this->controller->validate_account_communications_email( $value, $request, $param );
+ $this->assertEquals( $return, $expected );
+ }
+
+ /**
+ * Provider for test_validate_account_communications_email.
+ * @return array[] test method params.
+ */
+ public function account_communications_email_validation_provider() {
+ $request = new WP_REST_Request();
+ return [
+ [
+ 'test@test.com',
+ $request,
+ 'account_communications_email',
+ true,
+ ],
+ [
+ '', // Empty value should trigger error.
+ $request,
+ 'account_communications_email',
+ new WP_Error( 'rest_invalid_pattern', 'Error: Communications email is required.' ),
+ ],
+ [
+ 'invalid-email',
+ $request,
+ 'account_communications_email',
+ new WP_Error( 'rest_invalid_pattern', 'Error: Invalid email address: invalid-email' ),
+ ],
+ ];
+ }
+
+ public function test_get_settings_returns_account_communications_email() {
+ $test_email = 'test@example.com';
+ $this->mock_wcpay_account
+ ->method( 'is_stripe_connected' )
+ ->willReturn( true );
+ $this->mock_wcpay_account
+ ->method( 'get_communications_email' )
+ ->willReturn( $test_email );
+
+ $response = $this->controller->get_settings();
+
+ $this->assertEquals( $test_email, $response->get_data()['account_communications_email'] );
+ }
}
From 55048d73c6a8f8692812a845ef4c57f2a76717e8 Mon Sep 17 00:00:00 2001
From: Daniel Mallory
Date: Tue, 2 Dec 2025 14:39:49 +0000
Subject: [PATCH 6/6] Message fix.
---
includes/class-wc-payment-gateway-wcpay.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index f24f6251c93..5f0cb226d0b 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -2860,7 +2860,7 @@ protected function get_account_communications_email( $default_value = '' ): stri
return $this->account->get_communications_email();
}
} catch ( Exception $e ) {
- Logger::error( 'Failed to get account\'s communications email.' . $e );
+ Logger::error( 'Failed to get account\'s communication email.' . $e );
}
return $default_value;