Commit 90ebffc8d92 for woocommerce
commit 90ebffc8d92a0fb18ef0bc2a7f5fb8a0bf5bcd29
Author: Fernando Espinosa <Ferdev@users.noreply.github.com>
Date: Tue Apr 21 18:24:19 2026 +0200
Add customer note field to fulfillment updated email (#63855)
* [WOOPLUG-6353] add: orphaned fulfillment records when order is deleted
* Delete orphaned fulfillment records when an order is permanently deleted
Add delete_by_entity() to FulfillmentsDataStore for hard-deleting
fulfillment records and metadata within a transaction. Hook into
woocommerce_before_delete_order and before_delete_post via
FulfillmentsManager to trigger cleanup on order deletion.
* Move validation method docblock to appropriate location
* fix: handle errors when deleting fulfillment metadata and records
* fix: add missing throws tag
* Add customer note field to fulfillment updated email
Add an optional customer note text field to the fulfillment editor UI.
When a merchant updates a fulfillment with notifications enabled, they
can include a note that appears in the customer email.
The note is transient — passed through the REST API request body to the
email system without being persisted on the fulfillment entity.
Changes:
- REST API: Add customer_note string param to update endpoint schema
- Email class: Accept and forward customer_note to all template types
- Templates: Render "Note from the store" section (HTML, plain, block)
- Frontend: Add textarea to notification form, wire through context/store
Closes WOOPLUG-6365
* Fix customer_note parameter registration, sanitization, and data flow
- Move customer_note REST schema from delete endpoint to update endpoint
(get_write_args_for_fulfillment, gated behind !$is_create)
- Add sanitize_textarea_field at both schema and extraction point
- Send customer_note as query param instead of spreading into request body
- Reset customerNote state after successful update
- Use consistent !empty() check across all email templates
- Unify @since tags to 10.8.0 and improve PHPDoc blocks
* Add changelog entry for customer note feature
* Fix PHPStan error and remove manual changelog entry
- Fix @param type for $order: WC_Order|WC_Order_Refund|false (matches wc_get_order return type)
- Remove manual changelog file to let CI bot auto-generate from PR body
* Fix PHPStan: use global namespace for WC_Order types in docblock
* Add changelog entry file for CI validation
* Fix update-button test: add customerNote and setCustomerNote mocks
Update test mocks to include the new customerNote/setCustomerNote context
values and update the updateFulfillment call assertion to expect 4 args.
* Fix prettier formatting and remove accidentally committed build assets
- Break long lines in update-button.tsx for prettier compliance
- Remove assets/assets/ dir that was accidentally included in merge
* Fix prettier line length in customer-notification-form
* Fix review issues: move customer_note to request body, remove double sanitization, add isset guard
- Move customer_note from URL query args to the JSON request body to
avoid leaking user content into server logs and URL length limits
- Remove manual sanitize_textarea_field + wp_unslash in the REST
controller since the schema sanitize_callback already handles it
- Add null coalescing guard in shared block email template to prevent
PHP warnings when other fulfillment emails use the same template
- Guard customerNote with notifyCustomer check in update-button so
note is not sent when notifications are disabled
- Replace inline styles with CSS classes for consistency
- Update customer-notification-form tests: add TextareaControl mock,
test textarea conditional rendering, and add notifyCustomer=false test
* Fix CI: lint a11y error in test mock and use fireEvent instead of userEvent.setup
- Fix jsx-a11y/label-has-associated-control in TextareaControl mock by
using htmlFor/id association
- Replace userEvent.setup() with fireEvent.change() since this project
uses an older version of @testing-library/user-event without .setup()
* Address review feedback: sanitize customer_note, normalize template inputs, add PHPUnit tests
- Add sanitize_textarea_field with is_string guard for customer_note in REST controller
- Add @since 10.8.0 tag for $customer_note hook parameter
- Reuse existing $order variable instead of re-fetching with wc_get_order()
- Normalize customer_note to scalar string in all three email templates
- Fix misleading test comment (toggling from true -> false, not true)
- Add 3 PHPUnit tests covering customer_note sanitization, default empty value, and notification suppression
* Address second round of review feedback
- Remove duplicate sanitize_textarea_field call (schema sanitize_callback handles it)
- Restore @since 10.7.0 for $changes/$previous_status params in FulfillmentsDataStore
- Replace remove_all_actions with targeted remove_action in PHPUnit tests
* Restore @since 10.7.0 for delete_order_fulfillments and delete_by_entity docblocks
* Fix PHP lint: align equals signs in variable assignments
* Add missing notifyCustomer and setNotifyCustomer to default test mock
* Add changefile(s) from automation for the following project(s): woocommerce, woocommerce/client/admin
* Add help text for customer note textarea and simplify @throws tag
- Associate a help description with the textarea via TextareaControl's
`help` prop (renders as a `<p>` linked through `aria-describedby`).
- Revert `@throws \Throwable` on delete_by_entity() to "If the deletion fails"
per review feedback — the final-throw description is enough.
* Allow safe HTML in customer_note and fix note alignment in email
- Switch schema `sanitize_callback` from `sanitize_textarea_field` to
`wp_kses_post` so merchants can include basic HTML (bold, italic,
links) in the note, matching the standard customer-note email.
Scripts and unsafe markup are still stripped.
- Drop the `<td class="email-additional-content">` wrapper around the
note and render it in a `<blockquote>`, same pattern as
`customer-note.php`. That wrapper's CSS rule in `email-styles.php`
was forcing `text-align: center` on the note paragraph.
- Add a PHPUnit test that verifies `<strong>` and `<a>` markup is
preserved through the REST update flow.
* Remove duplicate changelog entry
The workflow-generated `63855-add-wooplug-6365-fulfillment-customer-note`
already covers this PR. This manually-named duplicate was flagged in
review and can go.
---------
Co-authored-by: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63855-add-wooplug-6365-fulfillment-customer-note b/plugins/woocommerce/changelog/63855-add-wooplug-6365-fulfillment-customer-note
new file mode 100644
index 00000000000..bbcbbeee4b4
--- /dev/null
+++ b/plugins/woocommerce/changelog/63855-add-wooplug-6365-fulfillment-customer-note
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add optional customer note field to fulfillment updated email notification.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js
index ada1ad37915..68f02d8100c 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/test/update-button.test.js
@@ -56,6 +56,10 @@ describe( 'UpdateButton component', () => {
},
],
},
+ notifyCustomer: true,
+ setNotifyCustomer: jest.fn(),
+ customerNote: '',
+ setCustomerNote: jest.fn(),
} );
} );
@@ -95,6 +99,51 @@ describe( 'UpdateButton component', () => {
order: { id: 123 },
fulfillment: mockFulfillment,
notifyCustomer: true,
+ customerNote: 'Test note',
+ setCustomerNote: jest.fn(),
+ } );
+
+ render( <UpdateButton setError={ setError } /> );
+ fireEvent.click( screen.getByText( 'Update' ) );
+
+ await waitFor( () => {
+ expect( mockUpdateFulfillment ).toHaveBeenCalledWith(
+ 123,
+ mockFulfillment,
+ true,
+ 'Test note'
+ );
+ } );
+ } );
+
+ it( 'should pass empty customer note when notifyCustomer is false', async () => {
+ const mockUpdateFulfillment = jest.fn( () => Promise.resolve() );
+ useDispatch.mockReturnValue( {
+ updateFulfillment: mockUpdateFulfillment,
+ } );
+
+ const mockFulfillment = {
+ id: 456,
+ meta_data: [
+ {
+ id: 1,
+ key: '_items',
+ value: [
+ {
+ id: 1,
+ name: 'Item 1',
+ quantity: 2,
+ },
+ ],
+ },
+ ],
+ };
+ useFulfillmentContext.mockReturnValue( {
+ order: { id: 123 },
+ fulfillment: mockFulfillment,
+ notifyCustomer: false,
+ customerNote: 'This note should not be sent',
+ setCustomerNote: jest.fn(),
} );
render( <UpdateButton setError={ setError } /> );
@@ -104,7 +153,8 @@ describe( 'UpdateButton component', () => {
expect( mockUpdateFulfillment ).toHaveBeenCalledWith(
123,
mockFulfillment,
- true
+ false,
+ ''
);
} );
} );
@@ -118,6 +168,8 @@ describe( 'UpdateButton component', () => {
useFulfillmentContext.mockReturnValue( {
order: { id: 123 },
fulfillment: undefined,
+ customerNote: '',
+ setCustomerNote: jest.fn(),
} );
render( <UpdateButton setError={ setError } /> );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx
index 460781a355d..cbf8b5d923d 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/action-buttons/update-button.tsx
@@ -24,7 +24,13 @@ export default function UpdateButton( {
setError: ( message: string | null ) => void;
} ) {
const { setIsEditing } = useFulfillmentDrawerContext();
- const { order, fulfillment, notifyCustomer } = useFulfillmentContext();
+ const {
+ order,
+ fulfillment,
+ notifyCustomer,
+ customerNote,
+ setCustomerNote,
+ } = useFulfillmentContext();
const { updateFulfillment } = useDispatch( FulfillmentStore );
const [ isExecuting, setIsExecuting ] = useState< boolean >( false );
const descriptionId = useInstanceId(
@@ -49,11 +55,17 @@ export default function UpdateButton( {
setError( null );
setIsExecuting( true );
- await updateFulfillment( order.id, fulfillment, notifyCustomer );
+ await updateFulfillment(
+ order.id,
+ fulfillment,
+ notifyCustomer,
+ notifyCustomer ? customerNote : ''
+ );
const error = select( FulfillmentStore ).getError( order.id );
if ( error ) {
setError( error );
} else {
+ setCustomerNote( '' );
refreshOrderFulfillmentStatus( order.id );
setIsEditing( false );
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx
index 1b1c89aa9fc..4aeeafa3697 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/index.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import { useEffect, useMemo, useRef } from 'react';
-import { ToggleControl } from '@wordpress/components';
+import { TextareaControl, ToggleControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/**
@@ -21,7 +21,8 @@ export default function CustomerNotificationBox( {
}: {
type: 'fulfill' | 'update' | 'remove';
} ) {
- const { notifyCustomer, setNotifyCustomer } = useFulfillmentContext();
+ const { notifyCustomer, setNotifyCustomer, customerNote, setCustomerNote } =
+ useFulfillmentContext();
const toggleRef = useRef< HTMLInputElement >( null );
const headerStrings = useMemo( () => {
@@ -80,12 +81,33 @@ export default function CustomerNotificationBox( {
</>
}
>
- <p
- id={ descriptionId }
- className="woocommerce-fulfillment-description"
- >
- { contentStrings[ type ] || contentStrings.fulfill }
- </p>
+ <div className="woocommerce-fulfillment-notification-content">
+ <p
+ id={ descriptionId }
+ className="woocommerce-fulfillment-description"
+ >
+ { contentStrings[ type ] || contentStrings.fulfill }
+ </p>
+ { type === 'update' && notifyCustomer && (
+ <div className="woocommerce-fulfillment-customer-note">
+ <TextareaControl
+ __nextHasNoMarginBottom
+ label={ __( 'Customer note', 'woocommerce' ) }
+ placeholder={ __(
+ 'Add a note for the customer (optional)',
+ 'woocommerce'
+ ) }
+ help={ __(
+ 'This note will be included in the update notification email sent to the customer.',
+ 'woocommerce'
+ ) }
+ value={ customerNote }
+ onChange={ ( value ) => setCustomerNote( value ) }
+ rows={ 3 }
+ />
+ </div>
+ ) }
+ </div>
</FulfillmentCard>
);
}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js
index ffbcfaac4fa..fd7a8e52343 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/components/customer-notification-form/test/index.test.js
@@ -2,7 +2,7 @@
* External dependencies
*/
import React from 'react';
-import { render, screen } from '@testing-library/react';
+import { render, screen, fireEvent } from '@testing-library/react';
/**
* Internal dependencies
@@ -24,16 +24,21 @@ jest.mock( '../../../utils/icons', () => ( {
EnvelopeIcon: () => <div data-testid="envelope-icon" />,
} ) );
-const setValue = jest.fn();
+const setNotifyCustomer = jest.fn();
+const setCustomerNote = jest.fn();
+
+const mockUseFulfillmentContext = jest.fn( () => ( {
+ notifyCustomer: true,
+ setNotifyCustomer,
+ customerNote: '',
+ setCustomerNote,
+} ) );
jest.mock( '../../../context/fulfillment-context', () => ( {
- useFulfillmentContext: jest.fn( () => ( {
- notifyCustomer: true,
- setNotifyCustomer: setValue,
- } ) ),
+ useFulfillmentContext: ( ...args ) => mockUseFulfillmentContext( ...args ),
} ) );
-// Mock ToggleControl to make testing easier
+// Mock ToggleControl and TextareaControl to make testing easier
jest.mock( '@wordpress/components', () => ( {
ToggleControl: React.forwardRef( ( props, ref ) => (
<div data-testid="toggle-control">
@@ -46,9 +51,33 @@ jest.mock( '@wordpress/components', () => ( {
/>
</div>
) ),
+ TextareaControl: ( props ) => (
+ <div data-testid="textarea-control">
+ { /* eslint-disable-next-line jsx-a11y/label-has-associated-control */ }
+ <label htmlFor="customer-note">{ props.label }</label>
+ <textarea
+ id="customer-note"
+ data-testid="customer-note-input"
+ value={ props.value }
+ onChange={ ( e ) => props.onChange( e.target.value ) }
+ placeholder={ props.placeholder }
+ rows={ props.rows }
+ />
+ </div>
+ ),
} ) );
describe( 'CustomerNotificationBox component', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockUseFulfillmentContext.mockReturnValue( {
+ notifyCustomer: true,
+ setNotifyCustomer,
+ customerNote: '',
+ setCustomerNote,
+ } );
+ } );
+
it( 'should render the component with proper title', () => {
render( <CustomerNotificationBox type="fulfill" /> );
@@ -70,15 +99,15 @@ describe( 'CustomerNotificationBox component', () => {
).toBeInTheDocument();
} );
- it( 'should call setValue with the correct value when toggle is changed', () => {
+ it( 'should call setNotifyCustomer with the correct value when toggle is changed', () => {
render( <CustomerNotificationBox type="fulfill" /> );
// Find and click the toggle input
const toggleInput = screen.getByTestId( 'toggle-input' );
toggleInput.click();
- // Check that setValue was called with true (toggling from true -> false)
- expect( setValue ).toHaveBeenCalledWith( false );
+ // Check that setNotifyCustomer was called with false (toggling from true -> false)
+ expect( setNotifyCustomer ).toHaveBeenCalledWith( false );
} );
it( 'should render with toggle in correct state based on value prop', () => {
@@ -88,4 +117,44 @@ describe( 'CustomerNotificationBox component', () => {
const toggleInput = screen.getByTestId( 'toggle-input' );
expect( toggleInput.checked ).toBe( true );
} );
+
+ it( 'should show textarea when type is update and notifyCustomer is true', () => {
+ render( <CustomerNotificationBox type="update" /> );
+
+ expect(
+ screen.getByTestId( 'customer-note-input' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should hide textarea when type is fulfill', () => {
+ render( <CustomerNotificationBox type="fulfill" /> );
+
+ expect(
+ screen.queryByTestId( 'customer-note-input' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'should hide textarea when type is update but notifyCustomer is false', () => {
+ mockUseFulfillmentContext.mockReturnValue( {
+ notifyCustomer: false,
+ setNotifyCustomer,
+ customerNote: '',
+ setCustomerNote,
+ } );
+
+ render( <CustomerNotificationBox type="update" /> );
+
+ expect(
+ screen.queryByTestId( 'customer-note-input' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'should call setCustomerNote when textarea value changes', () => {
+ render( <CustomerNotificationBox type="update" /> );
+
+ const textarea = screen.getByTestId( 'customer-note-input' );
+ fireEvent.change( textarea, { target: { value: 'Test note' } } );
+
+ expect( setCustomerNote ).toHaveBeenCalledWith( 'Test note' );
+ } );
} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/context/fulfillment-context.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/context/fulfillment-context.tsx
index 161b79e0c65..18b705aa7d8 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/context/fulfillment-context.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/context/fulfillment-context.tsx
@@ -28,6 +28,8 @@ interface FulfillmentContextProps {
setSelectedItems: ( items: ItemSelection[] ) => void;
notifyCustomer: boolean;
setNotifyCustomer: ( notifyCustomer: boolean ) => void;
+ customerNote: string;
+ setCustomerNote: ( customerNote: string ) => void;
}
const defaultContextProps: FulfillmentContextProps = {
@@ -38,6 +40,8 @@ const defaultContextProps: FulfillmentContextProps = {
setSelectedItems: () => {},
notifyCustomer: true,
setNotifyCustomer: () => {},
+ customerNote: '',
+ setCustomerNote: () => {},
};
const FulfillmentContextValue =
@@ -67,6 +71,7 @@ export const FulfillmentProvider = ( {
const [ _fulfillment, _setFulfillment ] =
React.useState< Fulfillment | null >( fulfillment ?? null );
const [ notifyCustomer, setNotifyCustomer ] = React.useState( true );
+ const [ customerNote, setCustomerNote ] = React.useState( '' );
const {
selectedOption,
@@ -171,6 +176,8 @@ export const FulfillmentProvider = ( {
setSelectedItems,
notifyCustomer,
setNotifyCustomer,
+ customerNote,
+ setCustomerNote,
} ),
[
order,
@@ -180,6 +187,8 @@ export const FulfillmentProvider = ( {
setSelectedItems,
notifyCustomer,
setNotifyCustomer,
+ customerNote,
+ setCustomerNote,
]
);
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
index 2dcea287db8..456614ef266 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/store.ts
@@ -139,7 +139,8 @@ const publicActions = {
(
orderId: number,
fulfillment: Fulfillment,
- notifyCustomer: boolean
+ notifyCustomer: boolean,
+ customerNote: string
) =>
async ( { dispatch }: { dispatch: typeof actions } ) => {
dispatch.setLoading( orderId, true );
@@ -156,7 +157,10 @@ const publicActions = {
{ notify_customer: notifyCustomer }
),
method: 'PUT',
- data: fulfillment,
+ data: {
+ ...fulfillment,
+ customer_note: customerNote,
+ },
headers: {
'Content-Type': 'application/json',
'X-WC-Fulfillments-UI': 'true',
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss
index bfb3ebbc272..6b16f28efb3 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/style.scss
@@ -308,6 +308,12 @@
}
}
+.woocommerce-fulfillment-notification-content {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
.woocommerce-fulfillment-description {
font-size: 12px;
line-height: 16px;
@@ -316,6 +322,10 @@
margin: 0;
}
+.woocommerce-fulfillment-customer-note {
+ margin-top: 16px;
+}
+
.woocommerce-fulfillment-description-button {
font-size: 12px !important;
font-weight: 400 !important;
diff --git a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php
index 67a7fc7c9eb..0e56ebda326 100644
--- a/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php
+++ b/plugins/woocommerce/includes/emails/class-wc-email-customer-fulfillment-updated.php
@@ -31,6 +31,13 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
*/
private $fulfillment;
+ /**
+ * Customer note.
+ *
+ * @var string
+ */
+ private $customer_note = '';
+
/**
* Constructor.
*/
@@ -47,7 +54,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
);
// Triggers for this email.
- add_action( 'woocommerce_fulfillment_updated_notification', array( $this, 'trigger' ), 10, 3 );
+ add_action( 'woocommerce_fulfillment_updated_notification', array( $this, 'trigger' ), 10, 4 );
// Call parent constructor.
parent::__construct();
@@ -63,8 +70,9 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
* @param int $order_id The order ID.
* @param Fulfillment $fulfillment The fulfillment.
* @param WC_Order|false $order Order object.
+ * @param string $customer_note Customer note.
*/
- public function trigger( $order_id, $fulfillment, $order = false ) {
+ public function trigger( $order_id, $fulfillment, $order = false, $customer_note = '' ) {
$this->setup_locale();
if ( $order_id && ! is_a( $order, 'WC_Order' ) ) {
@@ -74,6 +82,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
if ( is_a( $order, 'WC_Order' ) ) {
$this->object = $order;
$this->fulfillment = $fulfillment;
+ $this->customer_note = $customer_note;
$this->recipient = $this->object->get_billing_email();
$this->placeholders['{order_date}'] = wc_format_datetime( $this->object->get_date_created() );
$this->placeholders['{order_number}'] = $this->object->get_order_number();
@@ -120,6 +129,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
'fulfillment' => $this->fulfillment,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
+ 'customer_note' => $this->customer_note,
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
@@ -141,6 +151,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
'fulfillment' => $this->fulfillment,
'email_heading' => $this->get_heading(),
'additional_content' => $this->get_additional_content(),
+ 'customer_note' => $this->customer_note,
'sent_to_admin' => false,
'plain_text' => true,
'email' => $this,
@@ -160,6 +171,7 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
array(
'order' => $this->object,
'fulfillment' => $this->fulfillment,
+ 'customer_note' => $this->customer_note,
'sent_to_admin' => false,
'plain_text' => false,
'email' => $this,
@@ -225,6 +237,8 @@ if ( ! class_exists( 'WC_Email_Customer_Fulfillment_Updated', false ) ) :
return $keys;
}
);
+
+ $this->customer_note = __( 'This is a sample note from the store. Your order has been updated with new tracking information.', 'woocommerce' );
}
}
}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
index e93218c38b8..f68ae845917 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsManager.php
@@ -99,6 +99,8 @@ class FulfillmentsManager {
* Initialize order deletion hooks.
*
* Registers hooks to clean up fulfillment records when an order is permanently deleted.
+ * Hooks into both `woocommerce_before_delete_order` (HPOS) and `before_delete_post`
+ * (legacy post storage) to ensure cleanup regardless of storage backend.
*/
private function init_order_deletion_hooks(): void {
add_action( 'woocommerce_before_delete_order', array( $this, 'delete_order_fulfillments' ), 10, 1 );
@@ -108,6 +110,9 @@ class FulfillmentsManager {
/**
* Delete all fulfillment records for an order that is being permanently deleted.
*
+ * Does nothing if the given ID does not correspond to a valid order type.
+ * Exceptions are caught and logged; this method never throws.
+ *
* @since 10.7.0
*
* @param int $order_id The ID of the order being deleted.
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 430c34ffb03..450421301e4 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -361,9 +361,11 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
* @return WP_REST_Response The updated fulfillment, or an error if the request fails.
*/
public function update_fulfillment( WP_REST_Request $request ): WP_REST_Response {
- $order_id = (int) $request->get_param( 'order_id' );
- $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
- $notify_customer = (bool) $request->get_param( 'notify_customer' );
+ $order_id = (int) $request->get_param( 'order_id' );
+ $fulfillment_id = (int) $request->get_param( 'fulfillment_id' );
+ $notify_customer = (bool) $request->get_param( 'notify_customer' );
+ $customer_note_raw = $request->get_param( 'customer_note' );
+ $customer_note = is_string( $customer_note_raw ) ? $customer_note_raw : '';
$order = wc_get_order( $order_id );
if ( ! $order ) {
@@ -431,9 +433,15 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
/**
* Trigger the fulfillment updated notification on updating a fulfillment.
*
+ * @param int $order_id The order ID.
+ * @param Fulfillment $fulfillment The fulfillment object.
+ * @param \WC_Order|\WC_Order_Refund|false $order The order object.
+ * @param string $customer_note Optional customer note from the merchant.
+ *
* @since 10.1.0
+ * @since 10.8.0 Added $customer_note parameter.
*/
- do_action( 'woocommerce_fulfillment_updated_notification', $order_id, $fulfillment, wc_get_order( $order_id ) );
+ do_action( 'woocommerce_fulfillment_updated_notification', $order_id, $fulfillment, $order, $customer_note );
FulfillmentsTracker::track_fulfillment_notification_sent( 'fulfillment_updated', $fulfillment->get_id(), $order_id );
}
}
@@ -1155,7 +1163,17 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
'required' => false,
'context' => array( 'view', 'edit' ),
),
- )
+ ),
+ ! $is_create ? array(
+ 'customer_note' => array(
+ 'description' => __( 'A note from the merchant to include in the customer notification email. Basic HTML (links, bold, italic) is preserved; scripts and unsafe markup are stripped.', 'woocommerce' ),
+ 'type' => 'string',
+ 'default' => '',
+ 'required' => false,
+ 'sanitize_callback' => 'wp_kses_post',
+ 'context' => array( 'edit' ),
+ ),
+ ) : array()
);
}
diff --git a/plugins/woocommerce/templates/emails/block/general-block-content-for-fulfillment-emails.php b/plugins/woocommerce/templates/emails/block/general-block-content-for-fulfillment-emails.php
index 9d0515f44a3..9ab231d19b1 100644
--- a/plugins/woocommerce/templates/emails/block/general-block-content-for-fulfillment-emails.php
+++ b/plugins/woocommerce/templates/emails/block/general-block-content-for-fulfillment-emails.php
@@ -8,7 +8,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates\Emails\Block
- * @version 10.5.0
+ * @version 10.8.0
*/
defined( 'ABSPATH' ) || exit;
@@ -20,6 +20,12 @@ if ( ! isset( $order, $fulfillment ) ) {
return;
}
+$customer_note_text = is_scalar( $customer_note ?? null ) ? trim( (string) $customer_note ) : '';
+if ( '' !== $customer_note_text ) {
+ echo '<p><strong>' . esc_html__( 'Note from the store:', 'woocommerce' ) . '</strong></p>';
+ echo '<blockquote>' . wp_kses_post( wpautop( wptexturize( $customer_note_text ) ) ) . '</blockquote>';
+}
+
/**
* Hook for the woocommerce_email_fulfillment_details.
*
diff --git a/plugins/woocommerce/templates/emails/customer-fulfillment-updated.php b/plugins/woocommerce/templates/emails/customer-fulfillment-updated.php
index 3068e3c3d59..e78d002a4d4 100644
--- a/plugins/woocommerce/templates/emails/customer-fulfillment-updated.php
+++ b/plugins/woocommerce/templates/emails/customer-fulfillment-updated.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates\Emails\HTML
- * @version 10.4.0
+ * @version 10.8.0
*/
defined( 'ABSPATH' ) || exit;
@@ -33,6 +33,14 @@ do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
<p><?php echo esc_html__( 'Here’s the latest info we have:', 'woocommerce' ); ?></p>
</div>
+<?php
+$customer_note_text = is_scalar( $customer_note ?? null ) ? trim( (string) $customer_note ) : '';
+if ( '' !== $customer_note_text ) :
+ ?>
+<p><strong><?php echo esc_html__( 'Note from the store:', 'woocommerce' ); ?></strong></p>
+<blockquote><?php echo wp_kses_post( wpautop( wptexturize( $customer_note_text ) ) ); ?></blockquote>
+<?php endif; ?>
+
<?php
/**
diff --git a/plugins/woocommerce/templates/emails/plain/customer-fulfillment-updated.php b/plugins/woocommerce/templates/emails/plain/customer-fulfillment-updated.php
index f61f6586a12..e5657a7f362 100644
--- a/plugins/woocommerce/templates/emails/plain/customer-fulfillment-updated.php
+++ b/plugins/woocommerce/templates/emails/plain/customer-fulfillment-updated.php
@@ -12,7 +12,7 @@
*
* @see https://woocommerce.com/document/template-structure/
* @package WooCommerce\Templates\Emails\Plain
- * @version 10.1.0
+ * @version 10.8.0
*/
defined( 'ABSPATH' ) || exit;
@@ -25,6 +25,12 @@ echo "\n=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=\n\n";
echo esc_html__( 'Some details of your shipment have recently been updated. This may include tracking information, item contents, or delivery status.', 'woocommerce' ) . "\n\n";
echo esc_html__( 'Here’s the latest info we have:', 'woocommerce' ) . "\n\n";
+$customer_note_text = is_scalar( $customer_note ?? null ) ? trim( (string) $customer_note ) : '';
+if ( '' !== $customer_note_text ) {
+ echo esc_html__( 'Note from the store:', 'woocommerce' ) . "\n";
+ echo esc_html( wp_strip_all_tags( wptexturize( $customer_note_text ) ) ) . "\n\n";
+}
+
/**
* Display fulfillment details.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
index 335a2fc6ae3..dfa6f93ffe6 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
@@ -1903,4 +1903,239 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
wp_set_current_user( 0 );
}
+
+ /**
+ * @testdox Should accept customer_note in update request and forward sanitized value to notification hook.
+ */
+ public function test_update_fulfillment_with_customer_note_fires_notification_with_sanitized_note(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+ $this->assertInstanceOf( WC_Order::class, $order );
+
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ )
+ );
+
+ $captured_note = null;
+ $callback = function ( $order_id, $fulfillment_obj, $order_obj, $customer_note ) use ( &$captured_note ) {
+ unset( $order_id, $fulfillment_obj, $order_obj );
+ $captured_note = $customer_note;
+ };
+ add_action( 'woocommerce_fulfillment_updated_notification', $callback, 10, 4 );
+
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'notify_customer' => true,
+ 'customer_note' => "Hello customer!\n<script>alert('xss')</script>",
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'Update with customer_note should succeed' );
+ $this->assertNotNull( $captured_note, 'Notification hook should have been fired with customer_note' );
+ $this->assertStringNotContainsString( '<script>', $captured_note, 'Script tags should be stripped by wp_kses_post' );
+ $this->assertStringContainsString( 'Hello customer!', $captured_note, 'Legitimate note text should be preserved' );
+
+ remove_action( 'woocommerce_fulfillment_updated_notification', $callback, 10 );
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * @testdox Should preserve safe HTML (links, bold, italic) in customer_note.
+ */
+ public function test_update_fulfillment_preserves_safe_html_in_customer_note(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+ $this->assertInstanceOf( WC_Order::class, $order );
+
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ )
+ );
+
+ $captured_note = null;
+ $callback = function ( $order_id, $fulfillment_obj, $order_obj, $customer_note ) use ( &$captured_note ) {
+ unset( $order_id, $fulfillment_obj, $order_obj );
+ $captured_note = $customer_note;
+ };
+ add_action( 'woocommerce_fulfillment_updated_notification', $callback, 10, 4 );
+
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'notify_customer' => true,
+ 'customer_note' => 'Please <strong>call us</strong> at <a href="https://example.com">our site</a>.',
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'Update with HTML customer_note should succeed' );
+ $this->assertNotNull( $captured_note, 'Notification hook should have been fired with customer_note' );
+ $this->assertStringContainsString( '<strong>call us</strong>', $captured_note, 'Safe bold markup should be preserved' );
+ $this->assertStringContainsString( '<a href="https://example.com">our site</a>', $captured_note, 'Safe link markup should be preserved' );
+
+ remove_action( 'woocommerce_fulfillment_updated_notification', $callback, 10 );
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * @testdox Should fire notification with empty customer_note when parameter is omitted.
+ */
+ public function test_update_fulfillment_without_customer_note_fires_notification_with_empty_note(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+ $this->assertInstanceOf( WC_Order::class, $order );
+
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ )
+ );
+
+ $captured_note = null;
+ $callback = function ( $order_id, $fulfillment_obj, $order_obj, $customer_note ) use ( &$captured_note ) {
+ unset( $order_id, $fulfillment_obj, $order_obj );
+ $captured_note = $customer_note;
+ };
+ add_action( 'woocommerce_fulfillment_updated_notification', $callback, 10, 4 );
+
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'notify_customer' => true,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'Update without customer_note should succeed' );
+ $this->assertNotNull( $captured_note, 'Notification hook should have been fired' );
+ $this->assertSame( '', $captured_note, 'Customer note should be empty when not provided' );
+
+ remove_action( 'woocommerce_fulfillment_updated_notification', $callback, 10 );
+ wp_set_current_user( 0 );
+ }
+
+ /**
+ * @testdox Should not fire notification hook when notify_customer is false even with customer_note.
+ */
+ public function test_update_fulfillment_with_note_but_no_notification_does_not_fire_hook(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+ $this->assertInstanceOf( WC_Order::class, $order );
+
+ $fulfillment = FulfillmentsHelper::create_fulfillment(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => $order->get_id(),
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ )
+ );
+
+ $hook_fired = false;
+ $callback = function () use ( &$hook_fired ) {
+ $hook_fired = true;
+ };
+ add_action( 'woocommerce_fulfillment_updated_notification', $callback );
+
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() . '/fulfillments/' . $fulfillment->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'status' => 'fulfilled',
+ 'is_fulfilled' => true,
+ 'notify_customer' => false,
+ 'customer_note' => 'This should not be sent',
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status(), 'Update should succeed' );
+ $this->assertFalse( $hook_fired, 'Notification hook should not fire when notify_customer is false' );
+
+ remove_action( 'woocommerce_fulfillment_updated_notification', $callback );
+ wp_set_current_user( 0 );
+ }
}