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>
+ );
+};