Commit 357bb51d025 for woocommerce

commit 357bb51d0250dcce2d5e4ac2bdb4851d95d923ae
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Mon Nov 2 17:54:53 2026 +0200

    Add a pre-send filter and a sent action to the back-in-stock notification send path (#69879)

    * fix: mark back-in-stock sign-ups as sent only when the email is sent

    CustomerStockNotificationEmail::trigger() ignored the result of send(),
    so the notifications processor marked every sign-up Sent and logged
    "Failed: 0" even when the email was disabled or wp_mail() failed.
    trigger() and EmailManager::send_stock_notification_email() now return
    whether the email went out, and the processor marks the sign-up Sent
    only on success, counting the rest as failed.

    * chore: drop stale PHPStan baseline entry for the stock notification email trigger return type

    * test: report emails as sent in the e2e environment

    wp-env has no mail transport, so wp_mail() returns false after WP Mail Logging
    has captured the message. Now that a failed send cancels a back-in-stock
    sign-up, the e2e specs saw every notification as cancelled. Short-circuit
    wp_mail() with pre_wp_mail so the specs observe the production outcome.

    * test: scope the e2e wp_mail short-circuit to the back-in-stock batch

    The global pre_wp_mail override in the helper plugin broke the email settings
    and email editor specs, which assert the no-mailer failure path. Set the filter
    through the e2e-filters cookie only when a spec drains the notifications queue.

    * style: fix Prettier indentation in back-in-stock e2e utils

    * fix: only mark a sign-up sent from the admin edit page when the email goes out

    The "Send notification" action marked the sign-up as sent regardless of the
    wp_mail() result. Check the return value, keep the status untouched on failure
    so the admin can retry, and show an error notice instead.

    * feat: add pre-send filter and sent action to stock notification send path

    Both the batch processor and the admin "Send now" action now go through
    EmailManager::deliver_stock_notification(), which runs the
    woocommerce_customer_stock_notification_pre_send filter: null sends the
    Core email, true marks the notification Sent without one, and false or a
    WP_Error takes the failure branch. Once the row is saved as Sent, both
    paths fire woocommerce_customer_stock_notification_sent.

    * fix: narrow the product passed to the stock notification sent action

    Notification::get_product() returns WC_Product|false, so PHPStan rejected
    passing it straight to an action documented as taking WC_Product. Resolve
    it once and only fire woocommerce_customer_stock_notification_sent when a
    product exists, matching the guard in deliver_stock_notification().

    * fix: keep a throwing sent-action callback from breaking the send path

    Wrap the woocommerce_customer_stock_notification_sent action in try/catch in
    both the batch processor and the admin "Send now" action, logging the failure
    to wc-customer-stock-notifications. A third-party callback that throws no
    longer aborts the rest of the batch or crashes the edit page.

    Type the processor logger as WC_Logger_Interface so PHPStan resolves it; the
    three stale baseline entries for the unknown Logger class are dropped.

    * fix: contain delivery failures on Send now and fire the sent action only after a successful save

    A throwing woocommerce_customer_stock_notification_pre_send callback on the
    admin "Send now" path is now caught, logged and routed to the existing
    failure notice, matching the batch processor. Both send paths now fire
    woocommerce_customer_stock_notification_sent only when Notification::save()
    did not return a WP_Error, so the action never reports a Sent state that was
    not persisted.

    * test: mark the unused pre-send filter argument as unused

    * docs: bump stock notification pre-send hooks @since to 11.4.0

    * fix: log every failed stock notification delivery, not only WP_Error ones

    The pre-send filter can return false or any unexpected type and the
    customer would be cancelled without a trace. Log one warning for every
    non-true outcome, naming the WP_Error message or the returned type.

    * fix: log delivery exceptions in the stock notification batch path

    The batch catch block dropped the exception, so a throwing pre-send
    callback cancelled the sign-up without a trace. Log it at error level
    like the admin Send now path already does.

diff --git a/plugins/woocommerce/changelog/add-bis-pre-send-filter-sent-action b/plugins/woocommerce/changelog/add-bis-pre-send-filter-sent-action
new file mode 100644
index 00000000000..8662647ac0b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-bis-pre-send-filter-sent-action
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a pre-send filter and a sent action to the stock notification send path so integrations can take over delivery.
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php
index 94317bc00fa..5582335c40e 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php
@@ -112,10 +112,48 @@ class NotificationEditPage {
 				if ( ! $product || ! $product->is_in_stock() ) {
 					$notice_message = __( 'Failed to send notification. Please make sure that the listed product is available.', 'woocommerce' );
 					NotificationsPage::add_notice( $notice_message, 'error' );
-				} elseif ( $this->email_manager->send_stock_notification_email( $notification ) ) {
+					break;
+				}
+
+				try {
+					$is_sent = $this->email_manager->deliver_stock_notification( $notification );
+				} catch ( \Throwable $e ) {
+					$is_sent = false;
+					wc_get_logger()->error(
+						sprintf( 'Delivery of notification %d failed: %s', $notification->get_id(), $e->getMessage() ),
+						array( 'source' => 'wc-customer-stock-notifications' )
+					);
+				}
+
+				if ( $is_sent ) {
 					$notification->set_status( NotificationStatus::SENT );
 					$notification->set_date_notified( time() );
-					$notification->save();
+					$saved = $notification->save();
+
+					if ( is_wp_error( $saved ) ) {
+						NotificationsPage::add_notice( $saved->get_error_message(), 'error' );
+						break;
+					}
+
+					try {
+						/**
+						 * Action: woocommerce_customer_stock_notification_sent
+						 *
+						 * @since 11.4.0
+						 *
+						 * Fires once a stock notification has been delivered and saved as sent.
+						 *
+						 * @param Notification $notification The notification that was sent.
+						 * @param \WC_Product  $product      The product the notification is about.
+						 */
+						do_action( 'woocommerce_customer_stock_notification_sent', $notification, $product );
+					} catch ( \Throwable $e ) {
+						wc_get_logger()->error(
+							sprintf( 'A woocommerce_customer_stock_notification_sent callback failed for notification %d: %s', $notification->get_id(), $e->getMessage() ),
+							array( 'source' => 'wc-customer-stock-notifications' )
+						);
+					}
+
 					// translators: %s user email.
 					$notice_message = sprintf( __( 'Notification sent to "%s".', 'woocommerce' ), $notification->get_user_email() );
 					NotificationsPage::add_notice( $notice_message, 'success' );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessor.php b/plugins/woocommerce/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessor.php
index 6f93d7ec2a4..a088514b552 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessor.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessor.php
@@ -230,9 +230,13 @@ class NotificationsProcessor {
 			}

 			try {
-				$is_sent = $this->email_manager->send_stock_notification_email( $notification );
+				$is_sent = $this->email_manager->deliver_stock_notification( $notification );
 			} catch ( \Throwable $e ) {
 				$is_sent = false;
+				$this->logger->error(
+					sprintf( 'Delivery of notification %d failed: %s', $notification->get_id(), $e->getMessage() ),
+					array( 'source' => 'wc-customer-stock-notifications' )
+				);
 			}

 			if ( $is_sent ) {
@@ -246,7 +250,36 @@ class NotificationsProcessor {
 			}

 			// Always save the notification to reflect last attempt time.
-			$notification->save();
+			$saved = $notification->save();
+			if ( is_wp_error( $saved ) ) {
+				$this->logger->error(
+					sprintf( 'Failed to save notification %d: %s', $notification->get_id(), $saved->get_error_message() ),
+					array( 'source' => 'wc-customer-stock-notifications' )
+				);
+				continue;
+			}
+
+			$notification_product = $notification->get_product();
+			if ( $is_sent && $notification_product instanceof \WC_Product ) {
+				try {
+					/**
+					 * Action: woocommerce_customer_stock_notification_sent
+					 *
+					 * @since 11.4.0
+					 *
+					 * Fires once a stock notification has been delivered and saved as sent.
+					 *
+					 * @param Notification $notification The notification that was sent.
+					 * @param WC_Product   $product      The product the notification is about.
+					 */
+					do_action( 'woocommerce_customer_stock_notification_sent', $notification, $notification_product );
+				} catch ( \Throwable $e ) {
+					$this->logger->error(
+						sprintf( 'A woocommerce_customer_stock_notification_sent callback failed for notification %d: %s', $notification->get_id(), $e->getMessage() ),
+						array( 'source' => 'wc-customer-stock-notifications' )
+					);
+				}
+			}
 		}

 		if ( count( $notifications ) === $this->get_batch_size() ) {
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php
index 68c6dd7b606..2dde1a9840a 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php
@@ -291,6 +291,58 @@ class EmailManager {
 		return $email->trigger( $notification );
 	}

+	/**
+	 * Deliver a stock notification, letting an integration take over delivery.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param Notification $notification The notification object.
+	 * @return bool Whether the notification was delivered.
+	 */
+	public function deliver_stock_notification( Notification $notification ): bool {
+		$product = $notification->get_product();
+		if ( ! $product instanceof \WC_Product ) {
+			return false;
+		}
+
+		/**
+		 * Filter: woocommerce_customer_stock_notification_pre_send
+		 *
+		 * @since 11.4.0
+		 *
+		 * Take over delivery of a stock notification instead of sending the Core email.
+		 *
+		 * @param null|bool|\WP_Error $handled      Null to let Core send its own email. True when the notification was delivered
+		 *                                          externally and should be marked sent. False or a WP_Error when delivery failed.
+		 * @param Notification        $notification The notification being delivered.
+		 * @param \WC_Product         $product      The product the notification is about.
+		 * @return null|bool|\WP_Error
+		 */
+		$handled = apply_filters( 'woocommerce_customer_stock_notification_pre_send', null, $notification, $product );
+
+		if ( null === $handled ) {
+			return $this->send_stock_notification_email( $notification );
+		}
+
+		if ( true === $handled ) {
+			return true;
+		}
+
+		$reason = is_wp_error( $handled )
+			? $handled->get_error_message()
+			: sprintf( 'the pre-send filter returned %s instead of true, false, null or WP_Error', false === $handled ? 'false' : gettype( $handled ) );
+
+		wc_get_logger()->warning(
+			sprintf( 'Delivery of notification %d failed: %s', $notification->get_id(), $reason ),
+			array(
+				'source'          => 'wc-customer-stock-notifications',
+				'notification_id' => $notification->get_id(),
+			)
+		);
+
+		return false;
+	}
+
 	/**
 	 * Send the double opt-in verification email for a stock notification.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationEditPageTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationEditPageTests.php
index a8026e9d180..62c9f4a7847 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationEditPageTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationEditPageTests.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Tests\Internal\StockNotifications\StockNotificationsFeatureTrait;
+use WC_Helper_Product;

 /**
  * Tests for the admin notification edit form handler.
@@ -99,7 +100,7 @@ class NotificationEditPageTests extends \WC_Unit_Test_Case {
 	 * @testdox Sending a notification from the edit page marks it sent when the email goes out.
 	 */
 	public function test_send_notification_marks_sent_when_email_is_sent(): void {
-		$this->email_manager->method( 'send_stock_notification_email' )->willReturn( true );
+		$this->email_manager->method( 'deliver_stock_notification' )->willReturn( true );
 		$notification_id = $this->submit_send_notification();

 		$notification = new Notification( $notification_id );
@@ -112,7 +113,7 @@ class NotificationEditPageTests extends \WC_Unit_Test_Case {
 	 * @testdox Sending a notification from the edit page leaves it active when the email fails.
 	 */
 	public function test_send_notification_keeps_status_when_email_fails(): void {
-		$this->email_manager->method( 'send_stock_notification_email' )->willReturn( false );
+		$this->email_manager->method( 'deliver_stock_notification' )->willReturn( false );
 		$notification_id = $this->submit_send_notification();

 		$notification = new Notification( $notification_id );
@@ -121,13 +122,106 @@ class NotificationEditPageTests extends \WC_Unit_Test_Case {
 		$this->assertSame( 'error', get_option( NotificationsPage::ADMIN_NOTICE_OPTION_NAME )['type'] );
 	}

+	/**
+	 * @testdox "Send now" fires the sent action only when delivery succeeds.
+	 *
+	 * @dataProvider send_now_delivery_provider
+	 *
+	 * @param bool $delivered What the email manager reports back for the delivery.
+	 */
+	public function test_send_notification_fires_sent_action_only_when_delivered( bool $delivered ): void {
+		$this->email_manager->method( 'deliver_stock_notification' )->willReturn( $delivered );
+
+		$sent_action_args = array();
+		add_action(
+			'woocommerce_customer_stock_notification_sent',
+			function ( $notification, $product ) use ( &$sent_action_args ) {
+				$sent_action_args[] = array( $notification->get_id(), $product->get_id() );
+			},
+			10,
+			2
+		);
+
+		$notification_id = $this->submit_send_notification();
+		$product_id      = ( new Notification( $notification_id ) )->get_product_id();
+
+		$this->assertSame( $delivered ? array( array( $notification_id, $product_id ) ) : array(), $sent_action_args );
+	}
+
+	/**
+	 * @testdox A throwing `woocommerce_customer_stock_notification_sent` callback does not break the "Send now" action.
+	 */
+	public function test_send_notification_survives_throwing_sent_action_callback(): void {
+		$this->email_manager->method( 'deliver_stock_notification' )->willReturn( true );
+		add_action(
+			'woocommerce_customer_stock_notification_sent',
+			function () {
+				throw new \RuntimeException( 'Integration exploded.' );
+			}
+		);
+
+		$notification_id = $this->submit_send_notification();
+
+		$this->assertSame( NotificationStatus::SENT, ( new Notification( $notification_id ) )->get_status() );
+		$this->assertSame( 'success', get_option( NotificationsPage::ADMIN_NOTICE_OPTION_NAME )['type'] );
+	}
+
+	/**
+	 * @testdox A throwing `woocommerce_customer_stock_notification_pre_send` callback leaves the sign-up active and shows the failure notice.
+	 */
+	public function test_send_notification_survives_throwing_delivery(): void {
+		$this->email_manager->method( 'deliver_stock_notification' )->willThrowException( new \RuntimeException( 'Provider exploded.' ) );
+
+		$notification_id = $this->submit_send_notification();
+
+		$this->assertSame( NotificationStatus::ACTIVE, ( new Notification( $notification_id ) )->get_status() );
+		$this->assertSame( 'error', get_option( NotificationsPage::ADMIN_NOTICE_OPTION_NAME )['type'] );
+	}
+
+	/**
+	 * @testdox "Send now" does not fire the sent action when the sign-up cannot be saved as sent.
+	 */
+	public function test_send_notification_skips_sent_action_when_save_fails(): void {
+		$this->email_manager->method( 'deliver_stock_notification' )->willReturnCallback(
+			function ( Notification $notification ) {
+				$notification->set_user_email( 'not-an-email' );
+				return true;
+			}
+		);
+		$sent_action_fired = false;
+		add_action(
+			'woocommerce_customer_stock_notification_sent',
+			function () use ( &$sent_action_fired ) {
+				$sent_action_fired = true;
+			}
+		);
+
+		$notification_id = $this->submit_send_notification();
+
+		$this->assertFalse( $sent_action_fired );
+		$this->assertSame( NotificationStatus::ACTIVE, ( new Notification( $notification_id ) )->get_status() );
+		$this->assertSame( 'error', get_option( NotificationsPage::ADMIN_NOTICE_OPTION_NAME )['type'] );
+	}
+
+	/**
+	 * Data provider for test_send_notification_fires_sent_action_only_when_delivered.
+	 *
+	 * @return array[]
+	 */
+	public function send_now_delivery_provider(): array {
+		return array(
+			'delivered' => array( true ),
+			'failed'    => array( false ),
+		);
+	}
+
 	/**
 	 * Submit the "send_notification" action for a new active sign-up on an in-stock product.
 	 *
 	 * @return int The notification ID.
 	 */
 	private function submit_send_notification(): int {
-		$product = \WC_Helper_Product::create_simple_product();
+		$product = WC_Helper_Product::create_simple_product();

 		$notification = new Notification();
 		$notification->set_user_email( 'active@test.com' );
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessorTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessorTests.php
index d655af10cff..d1001c78e28 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessorTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/AsyncTasks/NotificationsProcessorTests.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Utilities\StockManagement
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
@@ -466,6 +467,163 @@ class NotificationsProcessorTests extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Should mark a sign-up Sent and fire the sent action when the pre-send filter takes over delivery, and take the failure branch when it reports a failure.
+	 *
+	 * @dataProvider pre_send_filter_provider
+	 *
+	 * @param mixed  $handled         What the `woocommerce_customer_stock_notification_pre_send` filter returns.
+	 * @param string $expected_status The status the sign-up should end up in.
+	 */
+	public function test_process_batch_honours_pre_send_filter( $handled, string $expected_status ): void {
+		// Batch size 1 keeps the cycle state around after the first run, so the counters can be inspected.
+		add_filter( 'woocommerce_customer_stock_notifications_batch_size', fn() => 1 );
+		add_filter( 'woocommerce_customer_stock_notification_pre_send', fn() => $handled );
+		$sent_action_args = array();
+		add_action(
+			'woocommerce_customer_stock_notification_sent',
+			function ( $notification, $product ) use ( &$sent_action_args ) {
+				$sent_action_args[] = array( $notification->get_id(), $product->get_id() );
+			},
+			10,
+			2
+		);
+
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_email( 'test@test.com' );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+
+		$this->sut->process_batch( $product->get_id() );
+
+		$notification = new Notification( $notification->get_id() );
+		$cycle_state  = get_option( CycleStateService::STATE_OPTION_PREFIX . $product->get_id() );
+		$this->assertEquals( $expected_status, $notification->get_status() );
+		$this->assertEqualsWithDelta( time(), $notification->get_date_last_attempt()->getTimestamp(), 5 );
+		if ( NotificationStatus::SENT === $expected_status ) {
+			$this->assertEqualsWithDelta( time(), $notification->get_date_notified()->getTimestamp(), 5 );
+			$this->assertSame( array( array( $notification->get_id(), $product->get_id() ) ), $sent_action_args );
+			$this->assertSame( 1, $cycle_state['sent_count'] );
+			$this->assertSame( 0, $cycle_state['failed_count'] );
+		} else {
+			$this->assertEmpty( $notification->get_date_notified() );
+			$this->assertSame( NotificationCancellationSource::SYSTEM, $notification->get_cancellation_source() );
+			$this->assertSame( array(), $sent_action_args, 'The sent action must not fire for a failed delivery.' );
+			$this->assertSame( 0, $cycle_state['sent_count'] );
+			$this->assertSame( 1, $cycle_state['failed_count'] );
+		}
+
+		WC()->queue()->cancel_all( JobManager::AS_JOB_SEND_STOCK_NOTIFICATIONS );
+	}
+
+	/**
+	 * Data provider for test_process_batch_honours_pre_send_filter.
+	 *
+	 * @return array[]
+	 */
+	public function pre_send_filter_provider(): array {
+		return array(
+			'true: handled externally' => array( true, NotificationStatus::SENT ),
+			'false: failed'            => array( false, NotificationStatus::CANCELLED ),
+			'WP_Error: failed'         => array( new \WP_Error( 'delivery_failed', 'Provider unreachable.' ), NotificationStatus::CANCELLED ),
+		);
+	}
+
+	/**
+	 * @testdox A throwing `woocommerce_customer_stock_notification_sent` callback is logged and does not stop the batch.
+	 */
+	public function test_process_batch_survives_throwing_sent_action_callback(): void {
+		add_filter( 'woocommerce_customer_stock_notification_pre_send', '__return_true' );
+		add_action(
+			'woocommerce_customer_stock_notification_sent',
+			function () {
+				throw new \RuntimeException( 'Integration exploded.' );
+			}
+		);
+
+		$product = WC_Helper_Product::create_simple_product();
+		$ids     = array();
+		foreach ( array( 'first@test.com', 'second@test.com' ) as $email ) {
+			$notification = new Notification();
+			$notification->set_product_id( $product->get_id() );
+			$notification->set_user_email( $email );
+			$notification->set_status( NotificationStatus::ACTIVE );
+			$ids[] = $notification->save();
+		}
+
+		$this->sut->process_batch( $product->get_id() );
+
+		foreach ( $ids as $id ) {
+			$this->assertSame( NotificationStatus::SENT, ( new Notification( $id ) )->get_status() );
+		}
+	}
+
+	/**
+	 * @testdox A throwing `woocommerce_customer_stock_notification_pre_send` callback is logged, cancels the sign-up and does not stop the batch.
+	 */
+	public function test_process_batch_logs_throwing_pre_send_callback(): void {
+		add_filter(
+			'woocommerce_customer_stock_notification_pre_send',
+			function () {
+				throw new \RuntimeException( 'Provider exploded.' );
+			}
+		);
+
+		$product = WC_Helper_Product::create_simple_product();
+		$ids     = array();
+		foreach ( array( 'first@test.com', 'second@test.com' ) as $email ) {
+			$notification = new Notification();
+			$notification->set_product_id( $product->get_id() );
+			$notification->set_user_email( $email );
+			$notification->set_status( NotificationStatus::ACTIVE );
+			$ids[] = $notification->save();
+		}
+
+		$this->sut->process_batch( $product->get_id() );
+
+		foreach ( $ids as $id ) {
+			$this->assertSame( NotificationStatus::CANCELLED, ( new Notification( $id ) )->get_status() );
+			$this->assertLogged( 'error', sprintf( 'Delivery of notification %d failed: Provider exploded.', $id ) );
+		}
+	}
+
+	/**
+	 * @testdox The sent action does not fire for a sign-up that could not be saved as sent.
+	 */
+	public function test_process_batch_skips_sent_action_when_save_fails(): void {
+		add_filter(
+			'woocommerce_customer_stock_notification_pre_send',
+			function ( $handled, Notification $notification ) {
+				unset( $handled );
+				$notification->set_user_email( 'not-an-email' );
+				return true;
+			},
+			10,
+			2
+		);
+		$sent_action_fired = false;
+		add_action(
+			'woocommerce_customer_stock_notification_sent',
+			function () use ( &$sent_action_fired ) {
+				$sent_action_fired = true;
+			}
+		);
+
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_user_email( 'test@test.com' );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+
+		$this->sut->process_batch( $product->get_id() );
+
+		$this->assertFalse( $sent_action_fired );
+		$this->assertSame( NotificationStatus::ACTIVE, ( new Notification( $notification->get_id() ) )->get_status() );
+	}
+
 	/**
 	 * Test process_batch method on a product with a notification that is throttled.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
index 8f9e0ec4c53..48519d08c84 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
@@ -149,6 +149,100 @@ class EmailManagerTests extends \WC_Unit_Test_Case {
 		$this->assertArrayHasKey( 'WC_Email_Customer_Stock_Notification_Verified', $emails );
 	}

+	/**
+	 * @testdox Should send the Core email when the pre-send filter returns null, and otherwise report what the filter decided.
+	 *
+	 * @dataProvider pre_send_filter_provider
+	 *
+	 * @param mixed $handled        What the `woocommerce_customer_stock_notification_pre_send` filter returns.
+	 * @param bool  $wp_mail_result What `wp_mail()` reports back when Core sends.
+	 * @param bool  $expected       The expected return value of deliver_stock_notification.
+	 */
+	public function test_deliver_stock_notification_honours_pre_send_filter( $handled, bool $wp_mail_result, bool $expected ): void {
+		$this->wp_mail_result = $wp_mail_result;
+		$notification         = $this->build_notification();
+		$received             = array();
+		add_filter(
+			'woocommerce_customer_stock_notification_pre_send',
+			function ( $pre, $filtered_notification, $product ) use ( $handled, &$received ) {
+				$received = array( $pre, $filtered_notification->get_id(), $product->get_id() );
+				return $handled;
+			},
+			10,
+			3
+		);
+
+		$this->assertSame( $expected, $this->sut->deliver_stock_notification( $notification ) );
+		$this->assertSame( array( null, $notification->get_id(), $notification->get_product_id() ), $received );
+		$this->assertSame( null === $handled, isset( $this->sent_to[ $notification->get_user_email() ] ), 'The Core email should only go out when the filter returns null.' );
+	}
+
+	/**
+	 * Data provider for test_deliver_stock_notification_honours_pre_send_filter.
+	 *
+	 * @return array[]
+	 */
+	public function pre_send_filter_provider(): array {
+		return array(
+			'null: Core sends'          => array( null, true, true ),
+			'null: Core email not sent' => array( null, false, false ),
+			'true: handled'             => array( true, true, true ),
+			'false: failed'             => array( false, true, false ),
+			'WP_Error: failed'          => array( new \WP_Error( 'delivery_failed', 'Provider unreachable.' ), true, false ),
+			'string: failed'            => array( 'sent', true, false ),
+		);
+	}
+
+	/**
+	 * @testdox Should log a warning naming the notification whenever the pre-send filter does not report success.
+	 *
+	 * @dataProvider pre_send_filter_failure_provider
+	 *
+	 * @param mixed  $handled  What the `woocommerce_customer_stock_notification_pre_send` filter returns.
+	 * @param string $expected Text the logged warning must contain.
+	 */
+	public function test_deliver_stock_notification_logs_a_warning_when_the_pre_send_filter_fails( $handled, string $expected ): void {
+		$notification = $this->build_notification();
+
+		$logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+		$logger->expects( $this->once() )
+			->method( 'warning' )
+			->with(
+				$this->logicalAnd( $this->stringContains( (string) $notification->get_id() ), $this->stringContains( $expected ) ),
+				$this->callback( fn( $context ) => 'wc-customer-stock-notifications' === $context['source'] && $notification->get_id() === $context['notification_id'] )
+			);
+		$use_logger = function () use ( $logger ) {
+			return $logger;
+		};
+		add_filter( 'woocommerce_logging_class', $use_logger );
+		$take_over = function () use ( $handled ) {
+			return $handled;
+		};
+		add_filter( 'woocommerce_customer_stock_notification_pre_send', $take_over );
+
+		$result = $this->sut->deliver_stock_notification( $notification );
+
+		remove_filter( 'woocommerce_customer_stock_notification_pre_send', $take_over );
+		remove_filter( 'woocommerce_logging_class', $use_logger );
+
+		$this->assertFalse( $result );
+		$this->assertEmpty( $this->sent_to );
+	}
+
+	/**
+	 * Data provider for test_deliver_stock_notification_logs_a_warning_when_the_pre_send_filter_fails.
+	 *
+	 * @return array[]
+	 */
+	public function pre_send_filter_failure_provider(): array {
+		return array(
+			'false'    => array( false, 'returned false' ),
+			'WP_Error' => array( new \WP_Error( 'delivery_failed', 'Provider unreachable.' ), 'Provider unreachable.' ),
+			'string'   => array( 'sent', 'returned string' ),
+			'integer'  => array( 1, 'returned integer' ),
+		);
+	}
+
 	/**
 	 * @testdox Should log a warning instead of failing silently when the email class is missing from the mailer.
 	 */