Commit 9085c419b01 for woocommerce

commit 9085c419b015b11079f0229dce6df116322c797c
Author: Ján Mikláš <neosinner@gmail.com>
Date:   Thu Jul 9 12:16:09 2026 +0200

    Add "Send test email" action to the emails listing page (#66405)

    * Extract reusable send-test-email hook and form from EmailPreviewSend

    Move the send logic (endpoint call, error mapping, Tracks events) into a
    useSendTestEmail hook and the modal body into a controlled
    SendTestEmailForm component so the emails list can reuse the same flow.

    The hook supports two backends: the legacy wc-admin-email send-preview
    endpoint (driven by the WC_Email class name, used by the email preview
    page) and the block email editor's send_preview_email endpoint (driven
    by the woo_email post ID — the same pipeline as the editor's own
    "Send a test email" modal, including block template rendering and the
    Woo dummy-content integration hooks).

    EmailPreviewSend behavior is unchanged: it keeps the settings endpoint,
    state still lives in the parent so the typed address and notice persist
    across modal close/reopen, and the same CSS classes are kept.
    friendlyEmailSendError, isValidEmail and WPError are re-exported from
    their old module path so existing imports keep working. Tracks payloads
    gain an additive `source` property to distinguish preview-page sends
    from list-page sends.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Wire up Send test email action on the emails listing page

    Replace the hard-coded disabled placeholder in the emails list's
    three-dot menu with a working action that opens the shared
    send-test-email modal (DataViews RenderModal + useSendTestEmail/
    SendTestEmailForm).

    The action posts the row's woo_email post ID to the block email
    editor's send_preview_email REST endpoint, so the test email is
    rendered through exactly the same pipeline as sending a test from the
    email editor itself (block template, personalization, Woo dummy
    content). The endpoint is protected by the REST cookie nonce plus
    edit_posts/edit_post capability checks. Rows without a post (legacy
    rows before the post is recreated) are not eligible — they offer the
    existing "Recreate email post" action instead.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Add changelog entry

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Address code review feedback

    - Require a numeric post_id in the Send test email action's eligibility
      check so a malformed ID can never reach the endpoint.
    - Guard the failure Tracks event against non-WPError rejections so
      error/error_code are always strings.
    - Announce the send result to screen readers (role=alert for errors,
      role=status for success).
    - Reset isSending on Cancel in EmailPreviewSend, matching the modal's
      onRequestClose behavior.
    - Add test coverage for the in-flight "Sending…" busy state.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Wrap send test email fields in a form so Enter submits

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/65128-add-send-test-email-to-emails-listing b/plugins/woocommerce/changelog/65128-add-send-test-email-to-emails-listing
new file mode 100644
index 00000000000..c9e994d930a
--- /dev/null
+++ b/plugins/woocommerce/changelog/65128-add-send-test-email-to-emails-listing
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a "Send test email" action to the emails listing page (block email editor).
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx
new file mode 100644
index 00000000000..086b4cef3ae
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-send-test.test.tsx
@@ -0,0 +1,260 @@
+/**
+ * Tests for the shared "Send a test email" flow (useSendTestEmail +
+ * SendTestEmailForm) used by both the email preview page and the emails list.
+ */
+
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import {
+	SendTestEmailForm,
+	useSendTestEmail,
+	type SendTestEmailSource,
+	type SendTestEmailTarget,
+} from '../settings-email-send-test';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+const recordEventMock = jest.fn();
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: ( name: string, payload: Record< string, unknown > ) =>
+		recordEventMock( name, payload ),
+} ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+	getAdminSetting: () => 'test-nonce',
+} ) );
+
+const apiFetchMock = apiFetch as unknown as jest.Mock;
+
+const Harness = ( {
+	target,
+	source,
+	onCancel = () => {},
+}: {
+	target: SendTestEmailTarget;
+	source: SendTestEmailSource;
+	onCancel?: () => void;
+} ) => {
+	const { email, setEmail, isSending, notice, noticeType, sendEmail } =
+		useSendTestEmail( target, source );
+
+	return (
+		<SendTestEmailForm
+			email={ email }
+			onEmailChange={ setEmail }
+			isSending={ isSending }
+			notice={ notice }
+			noticeType={ noticeType }
+			onSend={ sendEmail }
+			onCancel={ onCancel }
+		/>
+	);
+};
+
+const editorTarget: SendTestEmailTarget = {
+	endpoint: 'editor',
+	postId: 123,
+	emailType: 'new_order',
+};
+
+const settingsTarget: SendTestEmailTarget = {
+	endpoint: 'settings',
+	emailType: 'WC_Email_New_Order',
+};
+
+const enterEmailAndSend = ( address: string ) => {
+	fireEvent.change( screen.getByLabelText( 'Send to' ), {
+		target: { value: address },
+	} );
+	fireEvent.click(
+		screen.getByRole( 'button', { name: 'Send test email' } )
+	);
+};
+
+describe( 'useSendTestEmail + SendTestEmailForm', () => {
+	beforeEach( () => {
+		apiFetchMock.mockReset();
+		recordEventMock.mockClear();
+	} );
+
+	it( 'disables the send button until a valid email is entered', () => {
+		render( <Harness target={ editorTarget } source="email_listing" /> );
+
+		const sendButton = screen.getByRole( 'button', {
+			name: 'Send test email',
+		} );
+		expect( sendButton ).toBeDisabled();
+
+		fireEvent.change( screen.getByLabelText( 'Send to' ), {
+			target: { value: 'not-an-email' },
+		} );
+		expect( sendButton ).toBeDisabled();
+
+		fireEvent.change( screen.getByLabelText( 'Send to' ), {
+			target: { value: 'merchant@example.com' },
+		} );
+		expect( sendButton ).toBeEnabled();
+	} );
+
+	it( 'editor target: posts the post ID to the email editor endpoint and shows the success notice', async () => {
+		apiFetchMock.mockResolvedValue( { success: true, result: true } );
+
+		render( <Harness target={ editorTarget } source="email_listing" /> );
+
+		enterEmailAndSend( 'merchant@example.com' );
+
+		await waitFor( () =>
+			expect(
+				screen.getByText( 'Test email sent successfully!' )
+			).toBeInTheDocument()
+		);
+
+		expect( apiFetchMock ).toHaveBeenCalledWith( {
+			path: '/woocommerce-email-editor/v1/send_preview_email',
+			method: 'POST',
+			data: {
+				email: 'merchant@example.com',
+				postId: 123,
+			},
+		} );
+		expect( recordEventMock ).toHaveBeenCalledWith(
+			'settings_emails_preview_test_sent_successful',
+			{
+				email_type: 'new_order',
+				source: 'email_listing',
+			}
+		);
+	} );
+
+	it( 'settings target: posts the email type class name to the send-preview endpoint and shows the response message', async () => {
+		apiFetchMock.mockResolvedValue( { message: 'Test email sent.' } );
+
+		render( <Harness target={ settingsTarget } source="email_preview" /> );
+
+		enterEmailAndSend( 'merchant@example.com' );
+
+		await waitFor( () =>
+			expect( screen.getByText( 'Test email sent.' ) ).toBeInTheDocument()
+		);
+
+		expect( apiFetchMock ).toHaveBeenCalledWith( {
+			path: 'wc-admin-email/settings/email/send-preview?nonce=test-nonce',
+			method: 'POST',
+			data: {
+				email: 'merchant@example.com',
+				type: 'WC_Email_New_Order',
+			},
+		} );
+		expect( recordEventMock ).toHaveBeenCalledWith(
+			'settings_emails_preview_test_sent_successful',
+			{
+				email_type: 'WC_Email_New_Order',
+				source: 'email_preview',
+			}
+		);
+	} );
+
+	it( 'shows the friendly error message and records the failure with its source', async () => {
+		apiFetchMock.mockRejectedValue( {
+			code: 'rest_cookie_invalid_nonce',
+			message: 'Cookie check failed',
+			data: { status: 403 },
+		} );
+
+		render( <Harness target={ editorTarget } source="email_listing" /> );
+
+		enterEmailAndSend( 'merchant@example.com' );
+
+		await waitFor( () =>
+			expect(
+				screen.getByText(
+					'Your session expired. Refresh the page and try again.'
+				)
+			).toBeInTheDocument()
+		);
+
+		expect( recordEventMock ).toHaveBeenCalledWith(
+			'settings_emails_preview_test_sent_failed',
+			{
+				email_type: 'new_order',
+				error: 'Cookie check failed',
+				error_code: 'rest_cookie_invalid_nonce',
+				source: 'email_listing',
+			}
+		);
+	} );
+
+	it( 'shows the busy "Sending…" state while the request is in flight', async () => {
+		let resolveRequest: ( value: unknown ) => void = () => {};
+		apiFetchMock.mockImplementation(
+			() =>
+				new Promise( ( resolve ) => {
+					resolveRequest = resolve;
+				} )
+		);
+
+		render( <Harness target={ editorTarget } source="email_listing" /> );
+
+		enterEmailAndSend( 'merchant@example.com' );
+
+		const sendingButton = await screen.findByRole( 'button', {
+			name: 'Sending…',
+		} );
+		expect( sendingButton ).toBeDisabled();
+
+		resolveRequest( { success: true, result: true } );
+
+		await waitFor( () =>
+			expect(
+				screen.getByRole( 'button', { name: 'Send test email' } )
+			).toBeEnabled()
+		);
+	} );
+
+	it( 'submits the form when Enter is pressed in the email field, but not while the email is invalid', async () => {
+		apiFetchMock.mockResolvedValue( { success: true, result: true } );
+
+		render( <Harness target={ editorTarget } source="email_listing" /> );
+
+		const emailField = screen.getByLabelText( 'Send to' );
+
+		fireEvent.change( emailField, {
+			target: { value: 'not-an-email' },
+		} );
+		fireEvent.submit( emailField );
+		expect( apiFetchMock ).not.toHaveBeenCalled();
+
+		fireEvent.change( emailField, {
+			target: { value: 'merchant@example.com' },
+		} );
+		fireEvent.submit( emailField );
+
+		await waitFor( () =>
+			expect(
+				screen.getByText( 'Test email sent successfully!' )
+			).toBeInTheDocument()
+		);
+	} );
+
+	it( 'invokes onCancel when the cancel button is clicked', () => {
+		const onCancel = jest.fn();
+		render(
+			<Harness
+				target={ editorTarget }
+				source="email_listing"
+				onCancel={ onCancel }
+			/>
+		);
+
+		fireEvent.click( screen.getByRole( 'button', { name: 'Cancel' } ) );
+
+		expect( onCancel ).toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
index 645057b2218..1baba445f23 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-listview.tsx
@@ -18,6 +18,38 @@ import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
 import { Status, EMAIL_STATUSES } from './settings-email-listing-status';
 import { RecipientsList } from './settings-email-listing-recipients';
 import { UpdatesCell } from './settings-email-listing-update-cell';
+import {
+	SendTestEmailForm,
+	useSendTestEmail,
+} from './settings-email-send-test';
+
+const SendTestEmailModalContent = ( {
+	postId,
+	emailId,
+	onClose,
+}: {
+	postId: number;
+	emailId: string;
+	onClose: () => void;
+} ) => {
+	const { email, setEmail, isSending, notice, noticeType, sendEmail } =
+		useSendTestEmail(
+			{ endpoint: 'editor', postId, emailType: emailId },
+			'email_listing'
+		);
+
+	return (
+		<SendTestEmailForm
+			email={ email }
+			onEmailChange={ setEmail }
+			isSending={ isSending }
+			notice={ notice }
+			noticeType={ noticeType }
+			onSend={ sendEmail }
+			onCancel={ onClose }
+		/>
+	);
+};

 export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 	const [ view, setView ] = useState< View >( {
@@ -172,11 +204,26 @@ export const ListView = ( { emailTypes }: { emailTypes: EmailType[] } ) => {
 			{
 				id: 'test',
 				label: __( 'Send test email', 'woocommerce' ),
-				disabled: true,
 				supportsBulk: false,
-				callback: () => {
-					return true; // TODO: Implement send test email
-				},
+				// The editor's send_preview_email endpoint renders the
+				// woo_email post, so a numeric post ID is required — rows
+				// without one offer the "Recreate email post" action instead.
+				isEligible: ( item: EmailType ) =>
+					Number.isFinite( parseInt( item.post_id, 10 ) ),
+				modalHeader: __( 'Send a test email', 'woocommerce' ),
+				RenderModal: ( {
+					items,
+					closeModal,
+				}: {
+					items: EmailType[];
+					closeModal?: () => void;
+				} ) => (
+					<SendTestEmailModalContent
+						postId={ parseInt( items[ 0 ].post_id, 10 ) }
+						emailId={ items[ 0 ].id }
+						onClose={ closeModal ?? ( () => {} ) }
+					/>
+				),
 			},
 			{
 				id: 'change-status',
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 e5182d58e6e..4c005112b27 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
@@ -1,142 +1,47 @@
 /**
  * External dependencies
  */
-import { Button, Modal, TextControl } from '@wordpress/components';
-import { Icon, check, cautionFilled } from '@wordpress/icons';
-import apiFetch from '@wordpress/api-fetch';
+import { Button, Modal } from '@wordpress/components';
 import { useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
-import { recordEvent } from '@woocommerce/tracks';

 /**
  * Internal dependencies
  */
-import { emailPreviewNonce } from './settings-email-preview-nonce';
-
-const isValidEmail = ( email: string ) => {
-	const re =
-		/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-	return re.test( String( email ).toLowerCase() );
-};
+import {
+	SendTestEmailForm,
+	useSendTestEmail,
+} from './settings-email-send-test';
+
+// Re-exported so existing consumers (and tests) keep working after the send
+// logic moved to settings-email-send-test.tsx.
+export {
+	friendlyEmailSendError,
+	isValidEmail,
+	type WPError,
+} from './settings-email-send-test';

 type EmailPreviewSendProps = {
 	type: string;
 };

-type EmailPreviewSendResponse = {
-	message: string;
-};
-
-export type WPError = {
-	message: string;
-	code: string;
-	data: {
-		status: number;
-	};
-};
-
-/**
- * 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( friendlyEmailSendError( wpError ) );
-			setNoticeType( 'error' );
-
-			recordEvent( 'settings_emails_preview_test_sent_failed', {
-				email_type: type,
-				error: wpError.message,
-				error_code: wpError.code,
-			} );
-		}
+	const {
+		email,
+		setEmail,
+		isSending,
+		setIsSending,
+		notice,
+		noticeType,
+		sendEmail,
+	} = useSendTestEmail(
+		{ endpoint: 'settings', emailType: type },
+		'email_preview'
+	);

+	const closeModal = () => {
+		setIsModalOpen( false );
 		setIsSending( false );
 	};

@@ -152,61 +57,18 @@ export const EmailPreviewSend = ( { type }: EmailPreviewSendProps ) => {
 			{ isModalOpen && (
 				<Modal
 					title={ __( 'Send a test email', 'woocommerce' ) }
-					onRequestClose={ () => {
-						setIsModalOpen( false );
-						setIsSending( false );
-					} }
+					onRequestClose={ closeModal }
 					className="wc-settings-email-preview-send-modal"
 				>
-					<p>
-						{ __(
-							'Send yourself a test email to check how your email looks in different email apps.',
-							'woocommerce'
-						) }
-					</p>
-
-					<TextControl
-						label={ __( 'Send to', 'woocommerce' ) }
-						type="email"
-						value={ email }
-						placeholder={ __( 'Enter an email', 'woocommerce' ) }
-						onChange={ setEmail }
+					<SendTestEmailForm
+						email={ email }
+						onEmailChange={ setEmail }
+						isSending={ isSending }
+						notice={ notice }
+						noticeType={ noticeType }
+						onSend={ sendEmail }
+						onCancel={ closeModal }
 					/>
-
-					{ notice && (
-						<div
-							className={ `wc-settings-email-preview-send-modal-notice wc-settings-email-preview-send-modal-notice-${ noticeType }` }
-						>
-							<Icon
-								icon={
-									noticeType === 'success'
-										? check
-										: cautionFilled
-								}
-							/>
-							<span>{ notice }</span>
-						</div>
-					) }
-
-					<div className="wc-settings-email-preview-send-modal-buttons">
-						<Button
-							variant="tertiary"
-							onClick={ () => setIsModalOpen( false ) }
-						>
-							{ __( 'Cancel', 'woocommerce' ) }
-						</Button>
-
-						<Button
-							variant="primary"
-							onClick={ handleSendEmail }
-							isBusy={ isSending }
-							disabled={ ! isValidEmail( email ) || isSending }
-						>
-							{ isSending
-								? __( 'Sending…', 'woocommerce' )
-								: __( 'Send test email', 'woocommerce' ) }
-						</Button>
-					</div>
 				</Modal>
 			) }
 		</div>
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx
new file mode 100644
index 00000000000..ebb9009577e
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-send-test.tsx
@@ -0,0 +1,272 @@
+/**
+ * External dependencies
+ */
+import { Button, TextControl } from '@wordpress/components';
+import { Icon, check, cautionFilled } from '@wordpress/icons';
+import apiFetch from '@wordpress/api-fetch';
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { emailPreviewNonce } from './settings-email-preview-nonce';
+
+export const isValidEmail = ( email: string ) => {
+	const re =
+		/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+	return re.test( String( email ).toLowerCase() );
+};
+
+type SendTestEmailResponse = {
+	message: string;
+};
+
+export type WPError = {
+	message: string;
+	code: string;
+	data: {
+		status: number;
+	};
+};
+
+/**
+ * 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'
+	);
+}
+
+/**
+ * Where the send-test-email UI was opened from. Added to the Tracks payload so
+ * sends from the email preview and from the emails list can be told apart.
+ */
+export type SendTestEmailSource = 'email_preview' | 'email_listing';
+
+/**
+ * Which backend renders and sends the test email.
+ *
+ * - `settings`: the wc-admin-email send-preview endpoint, driven by the exact
+ *   WC_Email class name (`emailType`). Used by the legacy email preview page.
+ * - `editor`: the email editor's send_preview_email endpoint, driven by the
+ *   `woo_email` post ID — the exact pipeline the block email editor's own
+ *   "Send a test email" modal uses. `emailType` is only used for Tracks.
+ */
+export type SendTestEmailTarget =
+	| { endpoint: 'settings'; emailType: string }
+	| { endpoint: 'editor'; postId: number; emailType: string };
+
+/**
+ * State and send logic for the "Send a test email" flow, shared between the
+ * email preview page and the emails list.
+ *
+ * @param target Backend to send through, see SendTestEmailTarget.
+ * @param source Where the UI was opened from, for Tracks.
+ */
+export const useSendTestEmail = (
+	target: SendTestEmailTarget,
+	source: SendTestEmailSource
+) => {
+	const [ email, setEmail ] = useState( '' );
+	const [ isSending, setIsSending ] = useState( false );
+	const [ notice, setNotice ] = useState( '' );
+	const [ noticeType, setNoticeType ] = useState( '' );
+
+	const sendEmail = async () => {
+		setIsSending( true );
+		setNotice( '' );
+
+		try {
+			if ( target.endpoint === 'editor' ) {
+				await apiFetch( {
+					path: '/woocommerce-email-editor/v1/send_preview_email',
+					method: 'POST',
+					data: { email, postId: target.postId },
+				} );
+
+				setNotice(
+					__( 'Test email sent successfully!', 'woocommerce' )
+				);
+			} else {
+				const response: SendTestEmailResponse = await apiFetch( {
+					path: `wc-admin-email/settings/email/send-preview?nonce=${ emailPreviewNonce() }`,
+					method: 'POST',
+					data: { email, type: target.emailType },
+				} );
+
+				setNotice( response.message );
+			}
+			setNoticeType( 'success' );
+
+			recordEvent( 'settings_emails_preview_test_sent_successful', {
+				email_type: target.emailType,
+				source,
+			} );
+		} catch ( e ) {
+			const wpError = e as WPError;
+
+			setNotice( friendlyEmailSendError( wpError ) );
+			setNoticeType( 'error' );
+
+			recordEvent( 'settings_emails_preview_test_sent_failed', {
+				email_type: target.emailType,
+				// apiFetch can reject with non-WPError shapes (e.g. a native
+				// TypeError), so guard against missing fields.
+				error: wpError?.message ?? '',
+				error_code: wpError?.code ?? '',
+				source,
+			} );
+		}
+
+		setIsSending( false );
+	};
+
+	return {
+		email,
+		setEmail,
+		isSending,
+		setIsSending,
+		notice,
+		noticeType,
+		sendEmail,
+	};
+};
+
+type SendTestEmailFormProps = {
+	email: string;
+	onEmailChange: ( email: string ) => void;
+	isSending: boolean;
+	notice: string;
+	noticeType: string;
+	onSend: () => void;
+	onCancel: () => void;
+};
+
+/**
+ * Modal body of the "Send a test email" flow: recipient field, result notice,
+ * and Cancel/Send buttons. Controlled — pair with `useSendTestEmail`.
+ */
+export const SendTestEmailForm = ( {
+	email,
+	onEmailChange,
+	isSending,
+	notice,
+	noticeType,
+	onSend,
+	onCancel,
+}: SendTestEmailFormProps ) => {
+	return (
+		<form
+			onSubmit={ ( e ) => {
+				e.preventDefault();
+				if ( ! isValidEmail( email ) || isSending ) {
+					return;
+				}
+				onSend();
+			} }
+		>
+			<p>
+				{ __(
+					'Send yourself a test email to check how your email looks in different email apps.',
+					'woocommerce'
+				) }
+			</p>
+
+			<TextControl
+				label={ __( 'Send to', 'woocommerce' ) }
+				type="email"
+				value={ email }
+				placeholder={ __( 'Enter an email', 'woocommerce' ) }
+				onChange={ onEmailChange }
+			/>
+
+			{ notice && (
+				<div
+					role={ noticeType === 'error' ? 'alert' : 'status' }
+					className={ `wc-settings-email-preview-send-modal-notice wc-settings-email-preview-send-modal-notice-${ noticeType }` }
+				>
+					<Icon
+						icon={
+							noticeType === 'success' ? check : cautionFilled
+						}
+					/>
+					<span>{ notice }</span>
+				</div>
+			) }
+
+			<div className="wc-settings-email-preview-send-modal-buttons">
+				<Button variant="tertiary" onClick={ onCancel }>
+					{ __( 'Cancel', 'woocommerce' ) }
+				</Button>
+
+				<Button
+					type="submit"
+					variant="primary"
+					isBusy={ isSending }
+					disabled={ ! isValidEmail( email ) || isSending }
+				>
+					{ isSending
+						? __( 'Sending…', 'woocommerce' )
+						: __( 'Send test email', 'woocommerce' ) }
+				</Button>
+			</div>
+		</form>
+	);
+};