Commit e9ec926a017 for woocommerce

commit e9ec926a017dcec3d4b2e0a61e90bb95d88e24a9
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Fri Sep 11 13:41:31 2026 +0300

    Normalize Back in Stock customer emails to a canonical form (#68386)

    * fix: normalize Back in Stock customer emails to a canonical form

    * fix: reject non-string Back in Stock emails before sanitizing them

    * fix: look up Back in Stock customer accounts with the email case as entered

    * fix: backfill stored Back in Stock emails into canonical form on update

    * test: drop collation-dependent legacy email lookup test

    * fix: run the Back in Stock email backfill in resumable batches

    * chore: drop resolved get_user_email entries from the PHPStan baseline

    * fix: stop the Back in Stock email backfill on the first database error

    * fix: register the Back in Stock email backfill under the plain 11.2.0 key

diff --git a/plugins/woocommerce/changelog/wooplug-7663-bis-normalize-customer-emails b/plugins/woocommerce/changelog/wooplug-7663-bis-normalize-customer-emails
new file mode 100644
index 00000000000..8fa211343c4
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7663-bis-normalize-customer-emails
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Normalize customer emails in Back in Stock Notifications so signups and lookups match regardless of letter case, rewrite existing rows in the same form, and fix lookups for emails containing a single quote.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index c621ba870c0..282df2aff49 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -358,6 +358,7 @@ class WC_Install {
 			'wc_update_11201_migrate_tax_lookup_order_items',
 			'wc_update_11201_invalidate_analytics_reports_cache',
 			'wc_update_11202_reset_refund_returning_customer_markers',
+			'wc_update_11203_normalize_stock_notification_emails',
 		),
 	);

diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index bc2886f0e5b..5c087077f17 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -39,6 +39,7 @@ use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize as Download_Directories_Sync;
 use Automattic\WooCommerce\Internal\StockNotifications\StockNotifications;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
 use Automattic\WooCommerce\Internal\VariationGallery\Telemetry as VariationGalleryTelemetry;
 use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
@@ -3916,3 +3917,79 @@ function wc_update_11202_reset_refund_returning_customer_markers() {

 	return false;
 }
+
+/**
+ * Rewrite stored Back in Stock customer emails in canonical form (trimmed, lowercased).
+ *
+ * Lookups on `user_email` use plain SQL equality, so rows written before emails were
+ * normalized would not match on a case-sensitive collation. Processes one batch per
+ * call and requeues itself while rows remain. A database error stops the migration
+ * and is logged instead of retried: an unnormalized row only keeps the pre-migration
+ * lookup behaviour, and the log names it for manual repair.
+ *
+ * @since 11.2.0
+ *
+ * @return bool True when another batch remains, false when done.
+ */
+function wc_update_11203_normalize_stock_notification_emails() {
+	global $wpdb;
+
+	$last_id_option = 'woocommerce_update_11203_last_stock_notification_id';
+	$table          = $wpdb->prefix . 'wc_stock_notifications';
+	$batch_size     = 500;
+
+	$rows = $wpdb->get_results(
+		$wpdb->prepare(
+			"SELECT id, user_email FROM {$table} WHERE id > %d ORDER BY id ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared.
+			(int) get_option( $last_id_option, 0 ),
+			$batch_size
+		)
+	);
+
+	if ( '' !== $wpdb->last_error ) {
+		wc_get_logger()->error(
+			sprintf( 'Stopped normalizing stock notification emails: %s', $wpdb->last_error ),
+			array( 'source' => 'wc-updater' )
+		);
+		delete_option( $last_id_option );
+		return false;
+	}
+
+	// Normalize in PHP rather than with SQL LOWER()/TRIM() so stored values match exactly
+	// what EmailNormalizer produces at lookup time.
+	foreach ( $rows as $row ) {
+		$normalized = EmailNormalizer::normalize( (string) $row->user_email );
+		if ( $normalized === $row->user_email ) {
+			continue;
+		}
+
+		// Matching on the value read keeps a concurrent save (e.g. the privacy eraser) from being overwritten.
+		$updated = $wpdb->update(
+			$table,
+			array( 'user_email' => $normalized ),
+			array(
+				'id'         => (int) $row->id,
+				'user_email' => $row->user_email,
+			),
+			array( '%s' ),
+			array( '%d', '%s' )
+		);
+		if ( false === $updated ) {
+			wc_get_logger()->error(
+				sprintf( 'Stopped normalizing stock notification emails at notification #%d: %s', (int) $row->id, $wpdb->last_error ),
+				array( 'source' => 'wc-updater' )
+			);
+			delete_option( $last_id_option );
+			return false;
+		}
+	}
+
+	if ( count( $rows ) === $batch_size ) {
+		update_option( $last_id_option, (int) end( $rows )->id, false );
+		return true;
+	}
+
+	delete_option( $last_id_option );
+
+	return false;
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 8cc2d6da439..cae612f3c43 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66252,12 +66252,6 @@ parameters:
 			count: 1
 			path: src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php

-		-
-			message: '#^Cannot call method get_user_email\(\) on bool\|object\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails\\CustomerStockNotificationEmail\:\:maybe_restore_notification_locale\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -66306,12 +66300,6 @@ parameters:
 			count: 1
 			path: src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php

-		-
-			message: '#^Cannot call method get_user_email\(\) on bool\|object\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails\\CustomerStockNotificationVerifiedEmail\:\:maybe_restore_notification_locale\(\) has no return type specified\.$#'
 			identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index 22ba7c06345..d92c0c3c272 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\DataStores\StockNotifications;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 defined( 'ABSPATH' ) || exit;

@@ -461,7 +462,7 @@ CREATE TABLE $meta_table_name (

 		if ( $args['user_email'] ) {
 			$where[]        = 'user_email = %s';
-			$where_values[] = esc_sql( $args['user_email'] );
+			$where_values[] = EmailNormalizer::normalize( (string) $args['user_email'] );
 		}

 		if ( $args['last_attempt_limit'] > 0 ) {
@@ -561,6 +562,7 @@ CREATE TABLE $meta_table_name (
 	 */
 	public function notification_exists_by_email( int $product_id, string $email ): bool {

+		$email = EmailNormalizer::normalize( $email );
 		if ( ! is_email( $email ) ) {
 			return false;
 		}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/ListTable.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/ListTable.php
index 64fb8148886..1c6fdfb8ec1 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/ListTable.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/ListTable.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
 use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
 use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 /**
  * Notifications list table for Customer Stock Notifications.
@@ -345,7 +346,7 @@ class ListTable extends \WP_List_Table {

 		// Search.
 		if ( isset( $_REQUEST['s'] ) && ! empty( $_REQUEST['s'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-			$query_args['user_email'] = wc_clean( wp_unslash( $_REQUEST['s'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+			$query_args['user_email'] = EmailNormalizer::normalize( sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 		}

 		// Views.
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationCreatePage.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationCreatePage.php
index 23c7331b975..c8efb8f0b8d 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationCreatePage.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationCreatePage.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\StockNotifications\Admin;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 /**
  * Notification create page for Customer Stock Notifications.
@@ -58,17 +59,21 @@ class NotificationCreatePage {
 			}

 			$user                      = get_user_by( 'id', $posted_data['user_id'] );
-			$posted_data['user_email'] = is_a( $user, 'WP_User' ) ? $user->user_email : '';
+			$posted_data['user_email'] = is_a( $user, 'WP_User' ) ? EmailNormalizer::normalize( $user->user_email ) : '';

 		} elseif ( isset( $_POST['user_email'] ) && ! empty( $_POST['user_email'] ) ) {

-			$posted_data['user_email'] = sanitize_text_field( wp_unslash( $_POST['user_email'] ) );
-			if ( ! filter_var( $posted_data['user_email'], FILTER_VALIDATE_EMAIL ) ) {
+			$posted_email              = wp_unslash( $_POST['user_email'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below, after the type check.
+			$posted_email              = is_string( $posted_email ) ? sanitize_email( $posted_email ) : '';
+			$posted_data['user_email'] = is_email( $posted_email ) ? EmailNormalizer::normalize( $posted_email ) : '';
+			if ( '' === $posted_data['user_email'] ) {
 				NotificationsPage::add_notice( __( 'Please enter a valid email address.', 'woocommerce' ), 'error' );
 				return;
 			}

-			$user                   = get_user_by( 'email', $posted_data['user_email'] );
+			// Look up the account with the letter case as entered: `wp_users.user_email` is never
+			// normalized, so on a case-sensitive collation the lowercased form would miss it.
+			$user                   = get_user_by( 'email', $posted_email );
 			$posted_data['user_id'] = is_a( $user, 'WP_User' ) ? $user->ID : 0;
 		}

diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
index 4b7c093300d..54978eeedb1 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
@@ -171,7 +171,7 @@ class CustomerStockNotificationEmail extends WC_Email {
 		);

 		$unsubscribe_key = $notification->get_unsubscribe_key( true );
-		$user            = get_user_by( 'email', $notification->get_user_email() );
+		$user            = $notification instanceof Notification && $notification->get_user_id() ? get_user_by( 'id', $notification->get_user_id() ) : false;
 		$is_guest        = ! is_a( $user, 'WP_User' );

 		return array(
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
index a15dd9e7cf1..5bd646998df 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
@@ -143,7 +143,7 @@ class CustomerStockNotificationVerifiedEmail extends WC_Email {
 	private function get_additional_template_args(): array {
 		$notification    = $this->object;
 		$unsubscribe_key = $notification->get_unsubscribe_key( true );
-		$user            = get_user_by( 'email', $notification->get_user_email() );
+		$user            = $notification instanceof Notification && $notification->get_user_id() ? get_user_by( 'id', $notification->get_user_id() ) : false;
 		$is_guest        = ! is_a( $user, 'WP_User' );

 		return array(
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
index 530b4e370d5..81c58b92fc1 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoin
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
 use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 /**
  * A class for handling the business logic of the signup process.
@@ -88,6 +89,8 @@ class SignupService {
 	 */
 	public function signup( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ) {

+		$user_email = EmailNormalizer::normalize( $user_email );
+
 		// Sanity checks.
 		if ( ! Config::allows_signups() ) {
 			return new \WP_Error( self::ERROR_FAILED );
@@ -201,6 +204,8 @@ class SignupService {
 	 */
 	public function is_already_signed_up( int $product_id, int $user_id, string $user_email, array $posted_attributes = array() ) {

+		$user_email = EmailNormalizer::normalize( $user_email );
+
 		if ( empty( $product_id ) ) {
 			return null;
 		}
@@ -344,20 +349,18 @@ class SignupService {
 		}

 		if ( ! $is_logged_in ) {
-			$email = isset( $source['wc_bis_email'] ) ? sanitize_email( wp_unslash( $source['wc_bis_email'] ) ) : false;
-			if ( ! $email ) {
-				return new \WP_Error( self::ERROR_INVALID_EMAIL );
-			}
-
-			if ( ! is_email( $email ) ) {
+			$posted_email = isset( $source['wc_bis_email'] ) && is_string( $source['wc_bis_email'] ) ? sanitize_email( wp_unslash( $source['wc_bis_email'] ) ) : '';
+			$email        = is_email( $posted_email ) ? EmailNormalizer::normalize( $posted_email ) : '';
+			if ( '' === $email ) {
 				return new \WP_Error( self::ERROR_INVALID_EMAIL );
 			}

 			$data['user_id']    = 0;
 			$data['user_email'] = $email;

-			// Check if user exists with this email.
-			$user = get_user_by( 'email', $email );
+			// Look up the account with the letter case as entered: `wp_users.user_email` is never
+			// normalized, so on a case-sensitive collation the lowercased form would miss it.
+			$user = get_user_by( 'email', $posted_email );
 			if ( $user ) {
 				$data['user_id'] = $user->ID;
 			}
@@ -368,7 +371,7 @@ class SignupService {
 			}

 			$data['user_id']    = $user->ID;
-			$data['user_email'] = $user->user_email;
+			$data['user_email'] = EmailNormalizer::normalize( $user->user_email );
 		}

 		return $data;
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
index 116782edc2e..d4f41a52260 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Internal\StockNotifications;

 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 defined( 'ABSPATH' ) || exit;

@@ -236,10 +237,12 @@ class Notification extends \WC_Data {
 	/**
 	 * Set the user email.
 	 *
+	 * The value is stored in canonical form (trimmed, lowercased).
+	 *
 	 * @param string $user_email User email.
 	 */
 	public function set_user_email( string $user_email ) {
-		$this->set_prop( 'user_email', $user_email );
+		$this->set_prop( 'user_email', EmailNormalizer::normalize( $user_email ) );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
index 1a3d8b2b746..f209ec1f3b4 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Privacy/PrivacyEraser.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Factory;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;

 /**
  * Privacy eraser for WooCommerce Customer Stock Notifications.
@@ -55,6 +56,8 @@ class PrivacyEraser extends \WC_Abstract_Privacy {
 			'done'           => true,
 		);

+		$email_address = EmailNormalizer::normalize( $email_address );
+
 		$notifications = NotificationQuery::get_notifications(
 			array(
 				'user_email' => $email_address,
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Utilities/EmailNormalizer.php b/plugins/woocommerce/src/Internal/StockNotifications/Utilities/EmailNormalizer.php
new file mode 100644
index 00000000000..b95890854b5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Utilities/EmailNormalizer.php
@@ -0,0 +1,29 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\StockNotifications\Utilities;
+
+/**
+ * Canonical form for customer emails stored in stock notifications.
+ *
+ * Lookups on `wc_stock_notifications.user_email` use plain SQL equality, so every
+ * write and every query must go through the same normalization or the same customer
+ * can appear more than once under a different letter case.
+ *
+ * @internal
+ */
+final class EmailNormalizer {
+
+	/**
+	 * Normalize an already validated email address.
+	 *
+	 * Trims and lowercases. No validation is performed.
+	 *
+	 * @param string $email The email address.
+	 * @return string
+	 */
+	public static function normalize( string $email ): string {
+		return strtolower( trim( $email ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 4fc24f442df..b1309535183 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -640,4 +640,114 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

 		return $variation_id;
 	}
+
+	/**
+	 * @testdox Migration rewrites stored stock notification emails in canonical form and leaves canonical rows alone.
+	 */
+	public function test_wc_update_11203_normalize_stock_notification_emails(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$db_updates = WC_Install::get_db_update_callbacks();
+
+		$this->assertArrayHasKey( '11.2.0', $db_updates );
+		$this->assertContains( 'wc_update_11203_normalize_stock_notification_emails', $db_updates['11.2.0'] );
+
+		$table = $wpdb->prefix . 'wc_stock_notifications';
+		foreach ( array( 'Legacy@Example.com', " padded@example.com\t", 'canonical@example.com' ) as $email ) {
+			$wpdb->insert(
+				$table,
+				array(
+					'product_id'       => 1,
+					'user_id'          => 0,
+					'user_email'       => $email,
+					'status'           => 'active',
+					'date_created_gmt' => gmdate( 'Y-m-d H:i:s' ),
+				)
+			);
+		}
+
+		$this->assertFalse( wc_update_11203_normalize_stock_notification_emails(), 'A table smaller than one batch should complete in a single run' );
+		$this->assertFalse( get_option( 'woocommerce_update_11203_last_stock_notification_id' ), 'The cursor should be cleared on completion' );
+
+		$emails = $wpdb->get_col( "SELECT user_email FROM {$table} WHERE product_id = 1 ORDER BY id" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		$this->assertSame( array( 'legacy@example.com', 'padded@example.com', 'canonical@example.com' ), $emails );
+	}
+
+	/**
+	 * @testdox Migration resumes from the persisted cursor and leaves rows before it untouched.
+	 */
+	public function test_wc_update_11203_normalize_stock_notification_emails_resumes_from_cursor(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$table = $wpdb->prefix . 'wc_stock_notifications';
+		$ids   = array();
+		foreach ( array( 'Before@Example.com', 'After@Example.com' ) as $email ) {
+			$wpdb->insert(
+				$table,
+				array(
+					'product_id'       => 1,
+					'user_id'          => 0,
+					'user_email'       => $email,
+					'status'           => 'active',
+					'date_created_gmt' => gmdate( 'Y-m-d H:i:s' ),
+				)
+			);
+			$ids[] = $wpdb->insert_id;
+		}
+
+		update_option( 'woocommerce_update_11203_last_stock_notification_id', $ids[0], false );
+
+		wc_update_11203_normalize_stock_notification_emails();
+
+		$emails = $wpdb->get_col( "SELECT user_email FROM {$table} WHERE product_id = 1 ORDER BY id" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		$this->assertSame( array( 'Before@Example.com', 'after@example.com' ), $emails );
+	}
+
+	/**
+	 * @testdox Migration stops at the first failed write, clears its cursor and does not request another run.
+	 */
+	public function test_wc_update_11203_normalize_stock_notification_emails_stops_on_failed_write(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$table = $wpdb->prefix . 'wc_stock_notifications';
+		foreach ( array( 'First@Example.com', 'Second@Example.com' ) as $email ) {
+			$wpdb->insert(
+				$table,
+				array(
+					'product_id'       => 1,
+					'user_id'          => 0,
+					'user_email'       => $email,
+					'status'           => 'active',
+					'date_created_gmt' => gmdate( 'Y-m-d H:i:s' ),
+				)
+			);
+		}
+
+		$break_update = function ( $query ) use ( $table ) {
+			return 0 === strpos( $query, "UPDATE `{$table}` SET `user_email`" ) ? "UPDATE `{$table}` SET `no_such_column` = 1" : $query;
+		};
+		add_filter( 'query', $break_update );
+		$suppressed = $wpdb->suppress_errors();
+
+		try {
+			$this->assertFalse( wc_update_11203_normalize_stock_notification_emails(), 'A failed write should not request another run' );
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
+			remove_filter( 'query', $break_update );
+		}
+
+		$this->assertFalse( get_option( 'woocommerce_update_11203_last_stock_notification_id' ), 'The cursor should be cleared after a failed write' );
+
+		$emails = $wpdb->get_col( "SELECT user_email FROM {$table} WHERE product_id = 1 ORDER BY id" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		$this->assertSame( array( 'First@Example.com', 'Second@Example.com' ), $emails, 'No row should be rewritten after the write fails' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
index 5e94eb71cf8..8348251da7b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/StockNotifications/StockNotificationsDataStoreTests.php
@@ -622,4 +622,33 @@ class StockNotificationsDataStoreTests extends \WC_Unit_Test_Case {
 		$this->assertInstanceOf( Notification::class, $notifications[0] );
 		$this->assertEquals( 'test@test.com', $notifications[0]->get_user_email() );
 	}
+
+	/**
+	 * @testdox notification_exists_by_email() should match a stored lowercase row from mixed-case input.
+	 */
+	public function test_notification_exists_by_email_is_case_insensitive(): void {
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( 'foo@bar.com' );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->save();
+
+		$this->assertTrue( $this->data_store->notification_exists_by_email( 1, 'FOO@bar.com' ) );
+		$this->assertTrue( $this->data_store->notification_exists_by_email( 1, ' Foo@Bar.COM ' ) );
+		$this->assertFalse( $this->data_store->notification_exists_by_email( 1, 'other@bar.com' ) );
+	}
+
+	/**
+	 * @testdox query() should match an email containing a single quote.
+	 */
+	public function test_query_notifications_with_quoted_user_email(): void {
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( "o'brien@example.com" );
+		$notification->save();
+
+		$notifications = $this->data_store->query( array( 'user_email' => "O'Brien@Example.com" ) );
+
+		$this->assertSame( array( $notification->get_id() ), array_map( 'intval', $notifications ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationCreatePageTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationCreatePageTests.php
new file mode 100644
index 00000000000..1371b041d81
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/NotificationCreatePageTests.php
@@ -0,0 +1,65 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Admin;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationCreatePage;
+use Automattic\WooCommerce\Internal\StockNotifications\Admin\NotificationsPage;
+
+/**
+ * Tests for the admin notification create form handler.
+ */
+class NotificationCreatePageTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var NotificationCreatePage
+	 */
+	private $sut;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new NotificationCreatePage();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$_POST    = array();
+		$_REQUEST = array();
+		delete_option( NotificationsPage::ADMIN_NOTICE_OPTION_NAME );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should reject a posted email that is not a string instead of passing it to the sanitizers.
+	 *
+	 * @testWith [["guest@example.com"]]
+	 *           ["not an email"]
+	 *
+	 * @param mixed $posted_email The submitted email value.
+	 */
+	public function test_create_form_rejects_invalid_email( $posted_email ): void {
+		$_POST = array(
+			'save'       => '1',
+			'product_id' => '1',
+			'user_email' => $posted_email,
+		);
+
+		$_POST['customer_stock_notification_create_security'] = wp_create_nonce( 'woocommerce-customer-stock-notification-create' );
+
+		$_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Simulates the request the handler verifies.
+
+		$this->sut->process_create_form();
+
+		$notice = get_option( NotificationsPage::ADMIN_NOTICE_OPTION_NAME );
+		$this->assertIsArray( $notice, 'An invalid email should produce an admin notice' );
+		$this->assertSame( 'error', $notice['type'] );
+		$this->assertSame( 'Please enter a valid email address.', $notice['message'] );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
index 1a339a5beba..a465aeae312 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
@@ -122,4 +122,101 @@ class SignupServiceTests extends \WC_Unit_Test_Case {

 		return $product;
 	}
+
+	/**
+	 * @testdox A second signup with a different-case email should be reported as already joined.
+	 */
+	public function test_signup_dedupes_case_variants(): void {
+		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+		$product = $this->create_out_of_stock_product();
+
+		$first = $this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+		$this->assertSame( SignupService::SIGNUP_SUCCESS, $first->get_code() );
+		$this->assertSame( 'guest@example.com', $first->get_notification()->get_user_email() );
+
+		$second = $this->sut->signup( $product->get_id(), 0, ' Guest@Example.COM ' );
+		$this->assertSame( SignupService::SIGNUP_ALREADY_JOINED, $second->get_code() );
+		$this->assertSame( $first->get_notification()->get_id(), $second->get_notification()->get_id() );
+	}
+
+	/**
+	 * @testdox parse() should return a lowercase email for mixed-case guest input.
+	 */
+	public function test_parse_normalizes_guest_email(): void {
+		$product = $this->create_out_of_stock_product();
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $product->get_id(),
+				'wc_bis_email'      => ' Guest@Example.COM ',
+				'wc_bis_opt_in'     => 'on',
+			)
+		);
+
+		$this->assertIsArray( $data );
+		$this->assertSame( 'guest@example.com', $data['user_email'] );
+	}
+
+	/**
+	 * @testdox parse() should resolve a guest email to an existing account stored in mixed case and keep the canonical email.
+	 */
+	public function test_parse_resolves_mixed_case_account_email(): void {
+		global $wpdb;
+
+		$product = $this->create_out_of_stock_product();
+		$user_id = $this->factory()->user->create( array( 'user_email' => 'Mixed.Case@Example.com' ) );
+
+		// The test database collation is case-insensitive, so capture the value the lookup actually
+		// sends instead of relying on the row being found.
+		$user_queries = array();
+		add_filter(
+			'query',
+			function ( $query ) use ( &$user_queries, $wpdb ) {
+				if ( false !== strpos( $query, "FROM {$wpdb->users} WHERE user_email" ) ) {
+					$user_queries[] = $query;
+				}
+				return $query;
+			}
+		);
+		wp_cache_flush();
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $product->get_id(),
+				'wc_bis_email'      => 'Mixed.Case@Example.com',
+				'wc_bis_opt_in'     => 'on',
+			)
+		);
+
+		$this->assertIsArray( $data );
+		$this->assertSame( $user_id, $data['user_id'] );
+		$this->assertSame( 'mixed.case@example.com', $data['user_email'] );
+		$this->assertCount( 1, $user_queries, 'The account lookup should query the users table' );
+		$this->assertStringContainsString( "'Mixed.Case@Example.com'", $user_queries[0], 'The account lookup should keep the letter case as entered' );
+	}
+
+	/**
+	 * @testdox parse() should reject a guest email that is not a valid address.
+	 *
+	 * @testWith ["not an email"]
+	 *           [["guest@example.com"]]
+	 *           [42]
+	 *
+	 * @param mixed $posted_email The submitted email value.
+	 */
+	public function test_parse_rejects_invalid_guest_email( $posted_email ): void {
+		$product = $this->create_out_of_stock_product();
+
+		$data = $this->sut->parse(
+			array(
+				'wc_bis_product_id' => $product->get_id(),
+				'wc_bis_email'      => $posted_email,
+				'wc_bis_opt_in'     => 'on',
+			)
+		);
+
+		$this->assertInstanceOf( \WP_Error::class, $data );
+		$this->assertSame( SignupService::ERROR_INVALID_EMAIL, $data->get_error_code() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
index 1111bc23674..202e941b437 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
@@ -269,4 +269,31 @@ class NotificationTests extends \WC_Unit_Test_Case {

 		$this->assertFalse( $notification->check_verification_key( 'test' ) );
 	}
+
+	/**
+	 * @testdox set_user_email() should store the canonical (trimmed, lowercased) form.
+	 */
+	public function test_set_user_email_stores_canonical_form(): void {
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( ' Foo@Bar.COM ' );
+		$notification->save();
+
+		$this->assertSame( 'foo@bar.com', $notification->get_user_email() );
+		$this->assertSame( 'foo@bar.com', ( new Notification( $notification->get_id() ) )->get_user_email() );
+	}
+
+	/**
+	 * @testdox validate() should still reject an invalid email after normalization.
+	 */
+	public function test_invalid_user_email_fails_validation(): void {
+		$notification = new Notification();
+		$notification->set_product_id( 1 );
+		$notification->set_user_email( 'Not An Email' );
+
+		$result = $notification->save();
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( 'User Email is invalid.', $result->get_error_message() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
index 31a28b3b6f8..2d3e49a272c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Privacy/PrivacyEraserTests.php
@@ -49,4 +49,19 @@ class PrivacyEraserTests extends \WC_Unit_Test_Case {
 		$this->assertEquals( $anonymous_notification->get_user_email(), wp_privacy_anonymize_data( 'email', '' ) );
 		$this->assertEquals( NotificationStatus::CANCELLED, $anonymous_notification->get_status() );
 	}
+
+	/**
+	 * @testdox Should erase a lowercase row when given a mixed-case email.
+	 */
+	public function test_privacy_eraser_matches_case_variants(): void {
+		$notification = new Notification();
+		$notification->set_user_email( 'jon@doe.com' );
+		$notification->set_product_id( 1 );
+		$notification_id = $notification->save();
+
+		$response = PrivacyEraser::erase_notification_data( 'Jon@Doe.COM' );
+
+		$this->assertTrue( $response['items_removed'] );
+		$this->assertEquals( NotificationStatus::CANCELLED, ( new Notification( $notification_id ) )->get_status() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EmailNormalizerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EmailNormalizerTests.php
new file mode 100644
index 00000000000..b0fa4b80e60
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/EmailNormalizerTests.php
@@ -0,0 +1,26 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Utilities;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EmailNormalizer;
+
+/**
+ * Tests for EmailNormalizer.
+ */
+class EmailNormalizerTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * @testdox normalize() should trim and lowercase while preserving plus tags and dots.
+	 * @testWith [" Foo@Bar.COM ", "foo@bar.com"]
+	 *           ["First.Last+Tag@Example.com", "first.last+tag@example.com"]
+	 *           ["deleted@site.invalid", "deleted@site.invalid"]
+	 *           ["not-an-email", "not-an-email"]
+	 *
+	 * @param string $input    Raw input.
+	 * @param string $expected Expected canonical form.
+	 */
+	public function test_normalize( string $input, string $expected ): void {
+		$this->assertSame( $expected, EmailNormalizer::normalize( $input ) );
+	}
+}