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