Commit 4c3267cd336 for woocommerce

commit 4c3267cd3360e648008d7b16bd3530a9d7619126
Author: Ann <annchichi@users.noreply.github.com>
Date:   Wed Apr 22 19:16:11 2026 +0800

    Map email test-send errors to merchant-friendly copy (#64286)

    * Map email test-send errors to merchant-friendly copy

    * Add changelog entry for email test-send error messaging

    * Shorten email test-send error messages

    * Prefer stable error codes in friendlyEmailSendError

    Switch the nonce, invalid-JSON, and preview-render branches from English
    message matches to the stable backend error codes, so the mapping still
    works on localized sites. Flag the two remaining message-match branches
    (WSOD, apiFetch timeout) inline since they have no stable codes.

    Export friendlyEmailSendError and WPError so the mapping can be unit
    tested, and add tests covering each branch plus a localized-message
    regression.

    * Update e2e test for friendlier email test-send error copy

    The 'Send email preview' test was asserting on the old raw error copy
    "Error sending test email. Please try again." The PR now maps that
    error (code: woocommerce_rest_email_preview_not_sent) through the
    generic fallback in friendlyEmailSendError(), so the test needs to
    expect the new merchant-friendly string.

    * Defensively read code/message in friendlyEmailSendError

    The `e as WPError` cast in handleSendEmail is TypeScript-only — apiFetch
    can reject with non-WPError shapes (native TypeError on offline/CORS,
    middleware-wrapped errors). When `message` is undefined,
    `message.includes('critical error')` used to throw inside the catch
    block; that TypeError escaped, skipping setIsSending(false) and leaving
    the modal stuck on "Sending…". Optional-chain the destructure so
    unshaped errors fall through to the generic fallback copy.

diff --git a/plugins/woocommerce/changelog/tweak-email-test-send-friendly-errors b/plugins/woocommerce/changelog/tweak-email-test-send-friendly-errors
new file mode 100644
index 00000000000..fee986be8eb
--- /dev/null
+++ b/plugins/woocommerce/changelog/tweak-email-test-send-friendly-errors
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Show clearer errors when a test email fails to send.
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-preview-send.test.ts b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-preview-send.test.ts
new file mode 100644
index 00000000000..b27787c8f26
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-preview-send.test.ts
@@ -0,0 +1,113 @@
+/**
+ * Internal dependencies
+ */
+import {
+	friendlyEmailSendError,
+	WPError,
+} from '../settings-email-preview-send';
+
+const makeError = ( overrides: Partial< WPError > = {} ): WPError => ( {
+	code: '',
+	message: '',
+	data: { status: 0 },
+	...overrides,
+} );
+
+describe( 'friendlyEmailSendError', () => {
+	it( 'maps the WP core nonce error code to the session-expired message', () => {
+		const result = friendlyEmailSendError(
+			makeError( { code: 'rest_cookie_invalid_nonce' } )
+		);
+
+		expect( result ).toBe(
+			'Your session expired. Refresh the page and try again.'
+		);
+	} );
+
+	it( 'maps the Woo invalid_nonce code to the session-expired message', () => {
+		const result = friendlyEmailSendError(
+			makeError( { code: 'invalid_nonce' } )
+		);
+
+		expect( result ).toBe(
+			'Your session expired. Refresh the page and try again.'
+		);
+	} );
+
+	it( 'maps rest_invalid_json to the unexpected-output message', () => {
+		const result = friendlyEmailSendError(
+			makeError( { code: 'rest_invalid_json' } )
+		);
+
+		expect( result ).toBe(
+			'The server returned unexpected output. Check your error log, or disable recently added plugins.'
+		);
+	} );
+
+	it( 'maps a WSOD "critical error" message to the PHP-error message', () => {
+		const result = friendlyEmailSendError(
+			makeError( {
+				code: 'some_other_code',
+				message: 'There has been a critical error on this website.',
+			} )
+		);
+
+		expect( result ).toBe(
+			'A PHP error stopped the send. Check your error log or contact your host.'
+		);
+	} );
+
+	it( 'maps woocommerce_rest_email_preview_not_rendered to the render-failed message', () => {
+		const result = friendlyEmailSendError(
+			makeError( {
+				code: 'woocommerce_rest_email_preview_not_rendered',
+			} )
+		);
+
+		expect( result ).toBe(
+			"The email couldn't be rendered. Try resetting the template in Settings → Emails."
+		);
+	} );
+
+	it( 'maps the no-valid-response message to the timeout message', () => {
+		const result = friendlyEmailSendError(
+			makeError( {
+				message: 'Could not get a valid response from the server.',
+			} )
+		);
+
+		expect( result ).toBe(
+			'Your server timed out. If it keeps happening, ask your host to check PHP execution limits.'
+		);
+	} );
+
+	it( 'falls back to the generic message for unknown errors', () => {
+		const result = friendlyEmailSendError(
+			makeError( {
+				code: 'something_unexpected',
+				message: 'Something random went wrong.',
+			} )
+		);
+
+		expect( result ).toBe(
+			"Couldn't send the test email. Check your email settings and try again."
+		);
+	} );
+
+	it( 'still resolves by code when the message is localized (regression: locale fragility)', () => {
+		// Simulates a translated site where the backend error message has
+		// been run through __(). The mapping must still work because we
+		// match on the stable code, not the English message.
+		const result = friendlyEmailSendError(
+			makeError( {
+				code: 'woocommerce_rest_email_preview_not_rendered',
+				message:
+					"Une erreur s'est produite lors du rendu de l'aperçu de l'e-mail.",
+			} )
+		);
+
+		expect( result ).toBe(
+			"The email couldn't be rendered. Try resetting the template in Settings → Emails."
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-preview-send.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-preview-send.tsx
index 5f149946f85..cb4b1152c2b 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-preview-send.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-preview-send.tsx
@@ -22,7 +22,7 @@ type EmailPreviewSendResponse = {
 	message: string;
 };

-type WPError = {
+export type WPError = {
 	message: string;
 	code: string;
 	data: {
@@ -30,37 +30,108 @@ type WPError = {
 	};
 };

+/**
+ * Maps an apiFetch error into merchant-friendly copy.
+ *
+ * Where possible we match on stable backend error codes rather than English
+ * message strings, so the mapping still works on localized sites. The two
+ * branches that still rely on message matches are flagged inline — they don't
+ * have stable codes to match against.
+ */
+export function friendlyEmailSendError( wpError: WPError ): string {
+	// apiFetch can reject with non-WPError shapes (native TypeError, wrapped middleware errors); unshaped errors fall through to the generic fallback.
+	const code = wpError?.code ?? '';
+	const message = wpError?.message ?? '';
+
+	// Covers both WP core (rest_cookie_invalid_nonce) and Woo's own
+	// EmailPreviewRestController check (invalid_nonce).
+	if ( code === 'rest_cookie_invalid_nonce' || code === 'invalid_nonce' ) {
+		return __(
+			'Your session expired. Refresh the page and try again.',
+			'woocommerce'
+		);
+	}
+
+	// Stable WP core code for a non-JSON response body.
+	if ( code === 'rest_invalid_json' ) {
+		return __(
+			'The server returned unexpected output. Check your error log, or disable recently added plugins.',
+			'woocommerce'
+		);
+	}
+
+	// Locale-fragile: WSOD responses don't carry a structured error code,
+	// so we fall back to matching the English phrase PHP prints.
+	if ( message.includes( 'critical error' ) ) {
+		return __(
+			'A PHP error stopped the send. Check your error log or contact your host.',
+			'woocommerce'
+		);
+	}
+
+	// Stable Woo code emitted by EmailPreviewRestController when the preview
+	// template fails to render.
+	if ( code === 'woocommerce_rest_email_preview_not_rendered' ) {
+		return __(
+			"The email couldn't be rendered. Try resetting the template in Settings → Emails.",
+			'woocommerce'
+		);
+	}
+
+	// Locale-fragile: this apiFetch client fallback has no stable code, so
+	// we compare against the English message directly.
+	if ( message === 'Could not get a valid response from the server.' ) {
+		return __(
+			'Your server timed out. If it keeps happening, ask your host to check PHP execution limits.',
+			'woocommerce'
+		);
+	}
+
+	return __(
+		"Couldn't send the test email. Check your email settings and try again.",
+		'woocommerce'
+	);
+}
+
 export const EmailPreviewSend = ( { type }: EmailPreviewSendProps ) => {
 	const [ isModalOpen, setIsModalOpen ] = useState( false );
 	const [ email, setEmail ] = useState( '' );
 	const [ isSending, setIsSending ] = useState( false );
 	const [ notice, setNotice ] = useState( '' );
 	const [ noticeType, setNoticeType ] = useState( '' );
+
 	const nonce = emailPreviewNonce();

 	const handleSendEmail = async () => {
 		setIsSending( true );
 		setNotice( '' );
+
 		try {
 			const response: EmailPreviewSendResponse = await apiFetch( {
 				path: `wc-admin-email/settings/email/send-preview?nonce=${ nonce }`,
 				method: 'POST',
 				data: { email, type },
 			} );
+
 			setNotice( response.message );
 			setNoticeType( 'success' );
+
 			recordEvent( 'settings_emails_preview_test_sent_successful', {
 				email_type: type,
 			} );
 		} catch ( e ) {
 			const wpError = e as WPError;
-			setNotice( wpError.message );
+
+			setNotice( friendlyEmailSendError( wpError ) );
 			setNoticeType( 'error' );
+
 			recordEvent( 'settings_emails_preview_test_sent_failed', {
 				email_type: type,
 				error: wpError.message,
+				error_code: wpError.code,
 			} );
 		}
+
 		setIsSending( false );
 	};

@@ -96,6 +167,7 @@ export const EmailPreviewSend = ( { type }: EmailPreviewSendProps ) => {
 						placeholder={ __( 'Enter an email', 'woocommerce' ) }
 						onChange={ setEmail }
 					/>
+
 					{ notice && (
 						<div
 							className={ `wc-settings-email-preview-send-modal-notice wc-settings-email-preview-send-modal-notice-${ noticeType }` }
@@ -116,6 +188,7 @@ export const EmailPreviewSend = ( { type }: EmailPreviewSendProps ) => {
 						>
 							{ __( 'Cancel', 'woocommerce' ) }
 						</Button>
+
 						<Button
 							variant="primary"
 							onClick={ handleSendEmail }
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email/settings-email.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email/settings-email.spec.ts
index f65b5e11cbb..9a572ceae9e 100644
--- a/plugins/woocommerce/tests/e2e-pw/tests/email/settings-email.spec.ts
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email/settings-email.spec.ts
@@ -201,9 +201,10 @@ test.describe( 'WooCommerce Email Settings', () => {
 		await expect( sendButton ).toBeEnabled();
 		await sendButton.click();

-		// Wait for the message, because sending will fail in test environment
+		// Sending fails in the test env (no mail server); the backend returns
+		// `woocommerce_rest_email_preview_not_sent`, which hits the generic fallback in friendlyEmailSendError.
 		const message = modal.locator(
-			'text=Error sending test email. Please try again.'
+			"text=Couldn't send the test email. Check your email settings and try again."
 		);
 		await expect( message ).toBeVisible();
 	} );