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