Commit dc971922fd2 for woocommerce

commit dc971922fd2a4bc67521d8736c3e6531450480f8
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Thu Apr 30 13:49:15 2026 +0100

    Wire verify and confirmation emails for BIS alpha (#64348)

    * Add RSM-438 design and testing docs

    * Wire verify + confirmation emails for BIS alpha

    Dispatch the verification email when double opt-in is required on sign-up,
    dispatch the confirmation email on successful verification, add a
    rate-limited frontend resend endpoint for the existing "Resend
    verification" notice action, fix the admin "Resend verification email"
    button to actually send (CodeRabbit Must-fix #9), and apply the standard
    UTM params to all three notification email links.

    EmailManager gains send_verify_email() / send_verified_email() wrappers
    mirroring the existing send_stock_notification_email(), and the dead
    add_transactional_emails() registration is removed (no do_action ever
    fires those names; the wrappers call trigger() directly). NotificationEditPage
    and EmailActionController now receive EmailManager via the DI container,
    dropping the inline new EmailManager() in the admin send path.

    NotificationManagementService grows a template_redirect handler for the
    wc_bis_resend_notification query arg that verifies the nonce, bails if
    the notification is not PENDING, rate-limits to 60 seconds per
    notification via a _last_verify_email_sent_at meta, and fires the verify
    email through the same wrapper.

    Two baselined PHPStan errors on EmailManager.php are resolved by the
    instanceof narrowing and the removal of add_transactional_emails(), and
    the corresponding baseline entries are removed per AGENTS.md.

    * Log FormHandlerService guest-notice bug in BIS sprint doc

    * Fix PSR2 closing-brace style in NotificationManagementServiceTests

    * Guard BIS verification URL re-hits from re-dispatching the verified email

    A second hit on a still-valid verification URL (double-click, mail client
    link prefetch, link-scanner bots) would re-dispatch the 'verified' email
    because Notification::check_verification_key() remains true after the
    first successful verification.

    Short-circuit process_verification_action() when the notification is
    already ACTIVE so the verified email is sent exactly once. Covered by a
    regression test that invokes the handler twice and asserts a single
    send_verified_email() dispatch.

    * Harden BIS resend-verification handler

    Several correctness fixes to NotificationManagementService::maybe_process_resend_request():

    - Add `exit` after each `wp_safe_redirect()` so `template_redirect`
      doesn't continue rendering the template (and running further
      `template_redirect` listeners) once a redirect has been sent. Matches
      the canonical pattern used across `WC_Form_Handler`.
    - Persist the rate-limit timestamp before dispatching the verification
      email so two near-simultaneous requests can't both pass the rate-limit
      check and trigger duplicate sends (TOCTOU).
    - Bail early on admin / non-GET requests before doing any nonce or DB
      work, since the resend endpoint is a frontend GET-only surface.

    Test updates:

    - Intercept `wp_redirect` with a throwing filter so the SUT's trailing
      `exit` never runs during tests and assertions can still observe
      persisted state.
    - Swap `TRUNCATE TABLE` for `DELETE FROM` in `tearDown()` — `TRUNCATE`
      is DDL and implicitly commits the outer WP_UnitTestCase transaction,
      which can leak fixture data into the next test.
    - Add a regression test that proves the rate-limit timestamp is
      persisted before the email dispatches.
    - Strengthen the invalid-nonce test to also assert no rate-limit meta
      and no queued notice — locks in the 'silent drop' semantics.

    * Prevent real wp_mail dispatch in EmailManagerTests

    The BIS email classes are enabled by default (`enabled` option
    defaults to `yes`), so `send_verify_email` / `send_verified_email`
    would otherwise flow through `trigger() -> send() -> wp_mail()` during
    the test run and attempt a live SMTP handoff on CI.

    Short-circuit via the `pre_wp_mail` filter, capture the recipient for
    a behavioral assertion, and tear down the filter between tests.

    * Sanitize UtmHelper::add_email_utm_params() medium and mark class @internal

    The static helper accepts a freeform $medium that lands in outbound
    tracking URLs. All current callers pass safe constants, but running the
    value through `sanitize_key()` (with a fall-back to the default
    `email` medium when the input sanitizes to empty) locks the invariant
    down defensively against any future caller.

    Also adds an `@internal` tag to the class docblock, matching the other
    `Automattic\WooCommerce\Internal\StockNotifications\**` classes
    (`SignupService`, `EmailManager`, etc.).

    * Swap TRUNCATE for DELETE in SignupServiceTests teardown

    `WC_Unit_Test_Case` wraps each test in a database transaction that
    rolls back in `tearDown()` to keep tests isolated. `TRUNCATE` is DDL
    and triggers an implicit commit in MySQL, ending that outer transaction
    early and leaking test fixture state into subsequent tests. `DELETE FROM`
    runs inside the transaction and rolls back cleanly.

    * Clear notices in NotificationManagementServiceTests setUp

    The strengthened invalid-nonce test asserts wc_get_notices() is empty
    after the SUT call, but prior tests in the suite can leak notices into
    the static cache. Clear on every setUp so the assertion measures only
    what this test's code path actually queues.

    * Scope BIS resend nonce to the notification id

    CodeRabbit flagged this as a replay vector: the resend-verification
    nonce action was a constant, so a valid resend URL for one notification
    could be reused with a different wc_bis_resend_notification query arg
    to trigger verification emails for every other PENDING notification in
    the store. Bind the nonce action to the notification id on both sides.

    Regression test asserts a nonce minted for notification A is rejected
    when presented with notification B's id.

    * Align equals signs in cross-notification-nonce replay test

    Branch-level phpcs-changed treats the Generic.Formatting.MultipleStatementAlignment warnings as fatal.

    * Remove BIS sprint scratch docs from rsm-438

    These were sprint working state and are not meant to ship to trunk;
    the SPRINT.md pre-ship checklist always flagged them for pre-merge
    deletion.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * fix: address review — restore add_transactional_emails registration

    Brings back the woocommerce_email_actions hook registration plus the
    add_transactional_emails() callback that I'd dropped during the rebase
    on the (incorrect) belief that no do_action ever fires those names. The
    verify and verified email actions need to land in
    woocommerce_email_actions so deferred-email handling can pick them up.

    Restores the matching `Action callback returns array but should not
    return anything` baseline entry too — same entry that was on trunk for
    this method before the rebase.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * fix: address review — split inline email lookup for readability

    Replaces the chained `WC()->mailer()->get_emails()['…'] ?? null`
    one-liner in send_stock_notification_email/send_verify_email/
    send_verified_email with a two-step lookup (assign emails array,
    then index it). Easier to scan, mirrors the trunk style for the
    existing send_stock_notification_email helper.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * fix: address review — fire woocommerce_customer_stock_notifications_verified action

    Mirrors the existing `woocommerce_customer_stock_notifications_signup`
    extension hook so listeners that care about the signup lifecycle have a
    matching hook for the double-opt-in completion event. Fires right after
    the notification's status flips to ACTIVE and the row is saved, before
    the verified email dispatch and the redirect.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/rsm-438-wire-bis-emails b/plugins/woocommerce/changelog/rsm-438-wire-bis-emails
new file mode 100644
index 00000000000..f7e6162d70d
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-438-wire-bis-emails
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Wire verification and confirmation emails for the Back in Stock Notifications alpha: dispatch the verify email when double opt-in is required on signup, dispatch the confirmation email on successful verification, add a rate-limited frontend resend endpoint, fix the admin Resend verification button to actually send, and apply standard UTM parameters to all notification email links.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4199cbe80ee..9b72c60fc08 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -70530,12 +70530,6 @@ parameters:
 			count: 1
 			path: src/Internal/StockNotifications/Emails/EmailManager.php

-		-
-			message: '#^Call to an undefined method WC_Email\:\:trigger\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Internal/StockNotifications/Emails/EmailManager.php
-
 		-
 			message: '#^Parameter \$email of method Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails\\EmailManager\:\:add_stylesheets\(\) has invalid type Automattic\\WooCommerce\\Internal\\StockNotifications\\Emails\\WC_Email\.$#'
 			identifier: class.notFound
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php
index 61611c029f6..1de1fbb14b1 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationEditPage.php
@@ -17,6 +17,24 @@ use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancell
  */
 class NotificationEditPage {

+	/**
+	 * Email manager.
+	 *
+	 * @var EmailManager
+	 */
+	private EmailManager $email_manager;
+
+	/**
+	 * Init the service.
+	 *
+	 * @internal
+	 *
+	 * @param EmailManager $email_manager The email manager.
+	 */
+	final public function init( EmailManager $email_manager ): void {
+		$this->email_manager = $email_manager;
+	}
+
 	/**
 	 * Render page.
 	 */
@@ -94,8 +112,7 @@ class NotificationEditPage {
 					$notice_message = __( 'Failed to send notification. Please make sure that the listed product is available.', 'woocommerce' );
 					NotificationsPage::add_notice( $notice_message, 'error' );
 				} else {
-					$email_manager = new EmailManager();
-					$email_manager->send_stock_notification_email( $notification );
+					$this->email_manager->send_stock_notification_email( $notification );
 					$notification->set_status( NotificationStatus::SENT );
 					$notification->set_date_notified( time() );
 					$notification->save();
@@ -105,6 +122,13 @@ class NotificationEditPage {
 				}
 				break;
 			case 'send_verification_email':
+				if ( NotificationStatus::PENDING !== $notification->get_status() ) {
+					$notice_message = __( 'Cannot resend verification: this notification is already verified or cancelled.', 'woocommerce' );
+					NotificationsPage::add_notice( $notice_message, 'error' );
+					break;
+				}
+
+				$this->email_manager->send_verify_email( $notification );
 				// translators: %s user email.
 				$notice_message = sprintf( __( 'Verification email sent to "%s".', 'woocommerce' ), $notification->get_user_email() );
 				NotificationsPage::add_notice( $notice_message, 'success' );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationsPage.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationsPage.php
index ca5a900c123..6e71056d96f 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationsPage.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/NotificationsPage.php
@@ -49,7 +49,7 @@ class NotificationsPage {
 	 * Edit notification.
 	 */
 	public function edit() {
-		$edit_page = new NotificationEditPage();
+		$edit_page = wc_get_container()->get( NotificationEditPage::class );
 		$edit_page->output();
 		$this->output_admin_notice();
 	}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
index 00a88725868..4b7c093300d 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationEmail.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\UtmHelper;
 use WC_Email;

 /**
@@ -153,11 +154,6 @@ class CustomerStockNotificationEmail extends WC_Email {
 		 */
 		$button_text = apply_filters( 'woocommerce_email_stock_notification_button_text', _x( 'Shop Now', 'Email notification', 'woocommerce' ), $notification, $product );

-		$query_args = array(
-			'utm_source' => 'back-in-stock-notifications',
-			'utm_medium' => 'email',
-		);
-
 		/**
 		 * Filter the button href.
 		 *
@@ -169,10 +165,7 @@ class CustomerStockNotificationEmail extends WC_Email {
 		 */
 		$button_link = apply_filters(
 			'woocommerce_email_stock_notification_button_link',
-			add_query_arg(
-				$query_args,
-				$notification->get_product_permalink()
-			),
+			UtmHelper::add_email_utm_params( $notification->get_product_permalink() ),
 			$notification,
 			$product
 		);
@@ -184,13 +177,15 @@ class CustomerStockNotificationEmail extends WC_Email {
 		return array(
 			'button_text'      => $button_text,
 			'button_link'      => $button_link,
-			'unsubscribe_link' => add_query_arg(
-				array(
-					'email_link_action'     => EmailActionController::ACTION_UNSUBSCRIBE,
-					'email_link_action_key' => $unsubscribe_key,
-					'notification_id'       => $notification->get_id(),
-				),
-				get_option( 'siteurl' )
+			'unsubscribe_link' => UtmHelper::add_email_utm_params(
+				add_query_arg(
+					array(
+						'email_link_action'     => EmailActionController::ACTION_UNSUBSCRIBE,
+						'email_link_action_key' => $unsubscribe_key,
+						'notification_id'       => $notification->get_id(),
+					),
+					get_option( 'siteurl' )
+				)
 			),
 			'is_guest'         => $is_guest,
 		);
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
index 14edb78dde6..a15dd9e7cf1 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifiedEmail.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;

 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\UtmHelper;
 use WC_Email;

 /**
@@ -147,13 +148,15 @@ class CustomerStockNotificationVerifiedEmail extends WC_Email {

 		return array(
 			'is_guest'         => $is_guest,
-			'unsubscribe_link' => add_query_arg(
-				array(
-					'email_link_action'     => EmailActionController::ACTION_UNSUBSCRIBE,
-					'email_link_action_key' => $unsubscribe_key,
-					'notification_id'       => $notification->get_id(),
-				),
-				get_option( 'siteurl' )
+			'unsubscribe_link' => UtmHelper::add_email_utm_params(
+				add_query_arg(
+					array(
+						'email_link_action'     => EmailActionController::ACTION_UNSUBSCRIBE,
+						'email_link_action_key' => $unsubscribe_key,
+						'notification_id'       => $notification->get_id(),
+					),
+					get_option( 'siteurl' )
+				)
 			),
 		);
 	}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifyEmail.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifyEmail.php
index af9d30bc53b..9baed3999a7 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifyEmail.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/CustomerStockNotificationVerifyEmail.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\StockNotifications\Emails;
 use Automattic\WooCommerce\Internal\StockNotifications\Config;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\UtmHelper;
 use WC_Email;

 /**
@@ -171,13 +172,15 @@ class CustomerStockNotificationVerifyEmail extends WC_Email {
 		return array(
 			'verification_button_text'          => $verification_button_text,
 			'verification_expiration_threshold' => $expiration_threshold_text,
-			'verification_link'                 => add_query_arg(
-				array(
-					'email_link_action'     => EmailActionController::ACTION_VERIFY,
-					'email_link_action_key' => $verification_key,
-					'notification_id'       => $notification->get_id(),
-				),
-				get_option( 'siteurl' )
+			'verification_link'                 => UtmHelper::add_email_utm_params(
+				add_query_arg(
+					array(
+						'email_link_action'     => EmailActionController::ACTION_VERIFY,
+						'email_link_action_key' => $verification_key,
+						'notification_id'       => $notification->get_id(),
+					),
+					get_option( 'siteurl' )
+				)
 			),
 		);
 	}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
index 1a74bdf55e1..bedaef05d05 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailActionController.php
@@ -17,6 +17,14 @@ use Automattic\WooCommerce\Internal\StockNotifications\Notification;
  * @package Automattic\WooCommerce\Internal\StockNotifications\Emails
  */
 class EmailActionController {
+
+	/**
+	 * Email manager.
+	 *
+	 * @var EmailManager
+	 */
+	private EmailManager $email_manager;
+
 	/**
 	 * Action token for verifying (double opt-in) a pending notification sign-up.
 	 *
@@ -42,6 +50,17 @@ class EmailActionController {
 		add_action( 'template_redirect', array( $this, 'maybe_process_email_action' ) );
 	}

+	/**
+	 * Init the service.
+	 *
+	 * @internal
+	 *
+	 * @param EmailManager $email_manager The email manager.
+	 */
+	final public function init( EmailManager $email_manager ): void {
+		$this->email_manager = $email_manager;
+	}
+
 	/**
 	 * This method checks if the request contains indicators to process an action from an email link.
 	 */
@@ -121,10 +140,30 @@ class EmailActionController {
 	 */
 	private function process_verification_action( Notification $notification, string $action_key ): void {
 		if ( $notification->check_verification_key( $action_key ) ) {
+			// Guard against re-hits of a still-valid verification URL (double-click, email prefetch,
+			// link-scanner bots). Without this, each hit would re-dispatch the verified email.
+			if ( NotificationStatus::ACTIVE === $notification->get_status() ) {
+				return;
+			}
+
 			$notification->set_status( NotificationStatus::ACTIVE );
 			$notification->set_date_confirmed( time() );
 			$notification->save();

+			/**
+			 * Action: woocommerce_customer_stock_notifications_verified
+			 *
+			 * Fires after a stock-notification signup has been verified via the
+			 * double opt-in email link. Mirrors `woocommerce_customer_stock_notifications_signup`.
+			 *
+			 * @since 10.9.0
+			 *
+			 * @param Notification $notification The notification.
+			 */
+			do_action( 'woocommerce_customer_stock_notifications_verified', $notification );
+
+			$this->email_manager->send_verified_email( $notification );
+
 			// We need a cookie-based session for notices to work on frontend pages.
 			if ( WC()->session instanceof \WC_Session_Handler && ! WC()->session->has_session() ) {
 				WC()->session->set_customer_session_cookie( true );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php
index fd0ca54bacf..d32c4758587 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailManager.php
@@ -280,8 +280,40 @@ class EmailManager {
 	 */
 	public function send_stock_notification_email( Notification $notification ) {
 		$emails = WC()->mailer()->get_emails();
-		if ( isset( $emails['WC_Email_Customer_Stock_Notification'] ) ) {
-			$emails['WC_Email_Customer_Stock_Notification']->trigger( $notification );
+		$email  = $emails['WC_Email_Customer_Stock_Notification'] ?? null;
+
+		if ( $email instanceof CustomerStockNotificationEmail ) {
+			$email->trigger( $notification );
+		}
+	}
+
+	/**
+	 * Send the double opt-in verification email for a stock notification.
+	 *
+	 * @param Notification $notification The notification object.
+	 * @return void
+	 */
+	public function send_verify_email( Notification $notification ) {
+		$emails = WC()->mailer()->get_emails();
+		$email  = $emails['WC_Email_Customer_Stock_Notification_Verify'] ?? null;
+
+		if ( $email instanceof CustomerStockNotificationVerifyEmail ) {
+			$email->trigger( $notification );
+		}
+	}
+
+	/**
+	 * Send the sign-up confirmation email for a stock notification.
+	 *
+	 * @param Notification $notification The notification object.
+	 * @return void
+	 */
+	public function send_verified_email( Notification $notification ) {
+		$emails = WC()->mailer()->get_emails();
+		$email  = $emails['WC_Email_Customer_Stock_Notification_Verified'] ?? null;
+
+		if ( $email instanceof CustomerStockNotificationVerifiedEmail ) {
+			$email->trigger( $notification );
 		}
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php
index 1d835505426..fc3fd83e705 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php
@@ -4,6 +4,9 @@ declare( strict_types=1 );

 namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Factory;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;

 /**
@@ -11,6 +14,48 @@ use Automattic\WooCommerce\Internal\StockNotifications\Notification;
  */
 class NotificationManagementService {

+	/**
+	 * Query argument carrying the notification id for a resend-verification request.
+	 */
+	public const RESEND_QUERY_ARG = 'wc_bis_resend_notification';
+
+	/**
+	 * Nonce action for resend-verification URLs.
+	 */
+	public const RESEND_NONCE_ACTION = 'wc_bis_resend_verification_email_nonce';
+
+	/**
+	 * Meta key tracking the last time a verification email was dispatched.
+	 *
+	 * Used to rate-limit the frontend resend endpoint.
+	 */
+	public const LAST_VERIFY_EMAIL_SENT_META = '_last_verify_email_sent_at';
+
+	/**
+	 * Minimum seconds between back-to-back resend requests.
+	 */
+	public const RESEND_RATE_LIMIT_SECONDS = 60;
+
+	/**
+	 * Email manager.
+	 *
+	 * @var EmailManager
+	 */
+	private EmailManager $email_manager;
+
+	/**
+	 * Init the service.
+	 *
+	 * @internal
+	 *
+	 * @param EmailManager $email_manager The email manager.
+	 */
+	final public function init( EmailManager $email_manager ): void {
+		$this->email_manager = $email_manager;
+
+		add_action( 'template_redirect', array( $this, 'maybe_process_resend_request' ) );
+	}
+
 	/**
 	 * Get resend verification email URL.
 	 *
@@ -20,14 +65,92 @@ class NotificationManagementService {
 	public function get_resend_verification_email_url( Notification $notification ): string {
 		$url = add_query_arg(
 			array(
-				'wc_bis_resend_notification' => $notification->get_id(),
+				self::RESEND_QUERY_ARG => $notification->get_id(),
 			),
 			$notification->get_product_permalink()
 		);

-		return wp_nonce_url(
-			$url,
-			'wc_bis_resend_verification_email_nonce'
-		);
+		return wp_nonce_url( $url, self::RESEND_NONCE_ACTION . '_' . $notification->get_id() );
+	}
+
+	/**
+	 * Handle the resend-verification request if the current request carries one.
+	 */
+	public function maybe_process_resend_request(): void {
+		// Only run on frontend GET requests — skip admin, POST, CLI, etc. before doing any nonce/DB work.
+		if ( is_admin() ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.NonceVerification.Recommended
+		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
+		if ( 'GET' !== $method ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		if ( ! isset( $_GET[ self::RESEND_QUERY_ARG ] ) ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$notification_id = absint( wp_unslash( $_GET[ self::RESEND_QUERY_ARG ] ) );
+		$nonce           = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
+
+		if ( empty( $notification_id ) ) {
+			return;
+		}
+
+		// Scope the nonce per-notification so one valid resend URL cannot be replayed
+		// across the notification id query arg to trigger emails for other customers.
+		if ( ! wp_verify_nonce( $nonce, self::RESEND_NONCE_ACTION . '_' . $notification_id ) ) {
+			return;
+		}
+
+		$notification = Factory::get_notification( $notification_id );
+		if ( ! $notification instanceof Notification ) {
+			return;
+		}
+
+		$this->ensure_notice_session();
+
+		$redirect_url = $notification->get_product_permalink();
+		if ( empty( $redirect_url ) ) {
+			$redirect_url = wc_get_page_permalink( 'shop' );
+		}
+
+		if ( NotificationStatus::PENDING !== $notification->get_status() ) {
+			wc_add_notice( esc_html__( 'This notification is already verified or cancelled.', 'woocommerce' ), 'error' );
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		$last_sent_at = (int) $notification->get_meta( self::LAST_VERIFY_EMAIL_SENT_META );
+		if ( $last_sent_at > 0 && ( time() - $last_sent_at ) < self::RESEND_RATE_LIMIT_SECONDS ) {
+			wc_add_notice( esc_html__( 'Please wait a moment before requesting another verification email.', 'woocommerce' ), 'notice' );
+			wp_safe_redirect( $redirect_url );
+			exit;
+		}
+
+		// Persist the rate-limit timestamp before dispatching the email so two near-simultaneous
+		// requests can't both pass the rate-limit check and trigger duplicate sends.
+		$notification->update_meta_data( self::LAST_VERIFY_EMAIL_SENT_META, (string) time() );
+		$notification->save();
+
+		$this->email_manager->send_verify_email( $notification );
+
+		/* translators: %s user email. */
+		wc_add_notice( sprintf( esc_html__( 'Verification email sent to %s.', 'woocommerce' ), $notification->get_user_email() ), 'success' );
+		wp_safe_redirect( $redirect_url );
+		exit;
+	}
+
+	/**
+	 * Ensure there is a cookie-based session so frontend notices survive the redirect.
+	 */
+	private function ensure_notice_session(): void {
+		if ( WC()->session instanceof \WC_Session_Handler && ! WC()->session->has_session() ) {
+			WC()->session->set_customer_session_cookie( true );
+		}
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
index 6b1fb107280..977b56ba7f7 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
@@ -4,6 +4,7 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\StockNotifications\Frontend;

 use Automattic\WooCommerce\Internal\StockNotifications\Config;
+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
@@ -49,6 +50,13 @@ class SignupService {
 	 */
 	private NotificationManagementService $notification_management_service;

+	/**
+	 * Email manager.
+	 *
+	 * @var EmailManager
+	 */
+	private EmailManager $email_manager;
+
 	/**
 	 * Init the service.
 	 *
@@ -56,10 +64,16 @@ class SignupService {
 	 *
 	 * @param EligibilityService            $eligibility_service The eligibility service.
 	 * @param NotificationManagementService $notification_management_service The notification management service.
+	 * @param EmailManager                  $email_manager The email manager.
 	 */
-	final public function init( EligibilityService $eligibility_service, NotificationManagementService $notification_management_service ) {
+	final public function init(
+		EligibilityService $eligibility_service,
+		NotificationManagementService $notification_management_service,
+		EmailManager $email_manager
+	) {
 		$this->eligibility_service             = $eligibility_service;
 		$this->notification_management_service = $notification_management_service;
+		$this->email_manager                   = $email_manager;
 	}

 	/**
@@ -160,6 +174,10 @@ class SignupService {
 		 */
 		do_action( 'woocommerce_customer_stock_notifications_signup', $notification );

+		if ( Config::requires_double_opt_in() && NotificationStatus::PENDING === $notification->get_status() ) {
+			$this->email_manager->send_verify_email( $notification );
+		}
+
 		$signup_code = self::SIGNUP_SUCCESS;
 		if ( Config::requires_double_opt_in() ) {
 			$signup_code = $account_created
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php b/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
index 05f4485685e..8129ab721ec 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/StockNotifications.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\AsyncTasks\NotificationsP
 use Automattic\WooCommerce\Internal\StockNotifications\Admin\AdminManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\ProductPageIntegration;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\FormHandlerService;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;

 /**
  * The controller for the stock notifications.
@@ -55,6 +56,7 @@ class StockNotifications {

 		$container->get( ProductPageIntegration::class );
 		$container->get( FormHandlerService::class );
+		$container->get( NotificationManagementService::class );

 		if ( is_admin() ) {
 			$container->get( AdminManager::class );
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Utilities/UtmHelper.php b/plugins/woocommerce/src/Internal/StockNotifications/Utilities/UtmHelper.php
new file mode 100644
index 00000000000..f802696dfd0
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Utilities/UtmHelper.php
@@ -0,0 +1,55 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\StockNotifications\Utilities;
+
+/**
+ * Helper for appending UTM campaign parameters to outgoing stock-notification email links.
+ *
+ * Centralizes the `utm_source` / `utm_medium` values so order attribution stays consistent
+ * across all three email types (verify, verified, back-in-stock).
+ *
+ * @internal
+ */
+class UtmHelper {
+
+	/**
+	 * UTM source value used for all stock notification emails.
+	 */
+	public const UTM_SOURCE = 'back-in-stock-notifications';
+
+	/**
+	 * Default UTM medium for stock notification emails.
+	 */
+	public const UTM_MEDIUM_EMAIL = 'email';
+
+	/**
+	 * Append the standard email UTM parameters to a URL.
+	 *
+	 * @param string $url    The URL to annotate.
+	 * @param string $medium The UTM medium (defaults to `email`).
+	 * @return string
+	 */
+	public static function add_email_utm_params( string $url, string $medium = self::UTM_MEDIUM_EMAIL ): string {
+		if ( empty( $url ) ) {
+			return $url;
+		}
+
+		// Defensive: lock down the medium to a safe URL-friendly slug, falling back to the default
+		// if sanitization strips everything. Prevents any future caller from piping user-controlled
+		// input into the outbound tracking URL.
+		$sanitized_medium = sanitize_key( $medium );
+		if ( '' === $sanitized_medium ) {
+			$sanitized_medium = self::UTM_MEDIUM_EMAIL;
+		}
+
+		return add_query_arg(
+			array(
+				'utm_source' => self::UTM_SOURCE,
+				'utm_medium' => $sanitized_medium,
+			),
+			$url
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
index 314788b7540..950d3ecc800 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailActionControllerTests.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Emails;

 use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailActionController;
+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancellationSource;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
@@ -15,6 +16,30 @@ use WC_Helper_Product;
  */
 class EmailActionControllerTests extends \WC_Unit_Test_Case {

+	/**
+	 * The System Under Test.
+	 *
+	 * @var EmailActionController
+	 */
+	private $sut;
+
+	/**
+	 * Mock email manager.
+	 *
+	 * @var EmailManager&\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $email_manager;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->email_manager = $this->createMock( EmailManager::class );
+		$this->sut           = new EmailActionController();
+		$this->sut->init( $this->email_manager );
+	}
+
 	/**
 	 * Persist a notification with a single action-key meta entry.
 	 *
@@ -34,7 +59,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Test that verification action is sets notification status to active.
+	 * @testdox Should set notification status to active when verification key matches.
 	 */
 	public function test_process_verification_action_sets_status_active() {
 		$id = $this->arrange_notification(
@@ -43,13 +68,78 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 			time() . ':' . wp_fast_hash( 'test' )
 		);

-		( new EmailActionController() )->validate_and_maybe_process_request( $id, 'test', 'verify' );
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'verify' );

 		$this->assertEquals( NotificationStatus::ACTIVE, Factory::get_notification( $id )->get_status() );
 	}

 	/**
-	 * Test that unsubscribe action sets notification status to cancelled, and sets cancellation source to user.
+	 * @testdox Should send the verified email after a successful verification.
+	 */
+	public function test_verified_email_sent_after_successful_verification() {
+		$id = $this->arrange_notification(
+			NotificationStatus::PENDING,
+			'verification_action_key',
+			time() . ':' . wp_fast_hash( 'test' )
+		);
+
+		$this->email_manager
+			->expects( $this->once() )
+			->method( 'send_verified_email' )
+			->with(
+				$this->callback(
+					static function ( $arg ) use ( $id ) {
+						return $arg instanceof Notification && $arg->get_id() === $id;
+					}
+				)
+			);
+
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'verify' );
+	}
+
+	/**
+	 * @testdox Should not send the verified email when the verification key is invalid.
+	 */
+	public function test_verified_email_not_sent_when_verification_key_invalid() {
+		$id = $this->arrange_notification(
+			NotificationStatus::PENDING,
+			'verification_action_key',
+			time() . ':' . wp_fast_hash( 'correct' )
+		);
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verified_email' );
+
+		$this->sut->validate_and_maybe_process_request( $id, 'wrong-key', 'verify' );
+	}
+
+	/**
+	 * @testdox Should only dispatch the verified email once when the same verification URL is hit repeatedly.
+	 */
+	public function test_verified_email_sent_only_once_on_repeated_verification_hits() {
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_status( NotificationStatus::PENDING );
+		$notification->set_user_email( 'test@example.com' );
+		$key = time() . ':' . wp_fast_hash( 'test' );
+		$notification->update_meta_data( 'verification_action_key', $key );
+		$id = $notification->save();
+
+		$this->email_manager
+			->expects( $this->once() )
+			->method( 'send_verified_email' );
+
+		// First hit transitions PENDING -> ACTIVE and dispatches the verified email.
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'verify' );
+
+		// Second hit (double-click, email prefetch, bot) must short-circuit without re-dispatch.
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'verify' );
+	}
+
+	/**
+	 * @testdox Should set notification status to cancelled and cancellation source to user on unsubscribe.
 	 */
 	public function test_process_unsubscribe_action_sets_status_cancelled() {
 		$id = $this->arrange_notification(
@@ -58,7 +148,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 			wp_fast_hash( 'test' )
 		);

-		( new EmailActionController() )->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );

 		$updated = Factory::get_notification( $id );
 		$this->assertEquals( NotificationStatus::CANCELLED, $updated->get_status() );
@@ -76,7 +166,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 			time() . ':' . wp_fast_hash( 'real-key' )
 		);

-		( new EmailActionController() )->validate_and_maybe_process_request( $id, 'wrong-key', 'verify' );
+		$this->sut->validate_and_maybe_process_request( $id, 'wrong-key', 'verify' );

 		$this->assertEquals( NotificationStatus::PENDING, Factory::get_notification( $id )->get_status() );
 	}
@@ -94,7 +184,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 			time() . ':' . wp_fast_hash( 'test' )
 		);

-		( new EmailActionController() )->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'unsubscribe' );

 		$this->assertEquals( NotificationStatus::ACTIVE, Factory::get_notification( $id )->get_status() );
 	}
@@ -109,7 +199,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 		// warning without a no-op assertion.
 		$this->expectNotToPerformAssertions();

-		( new EmailActionController() )->validate_and_maybe_process_request( 0, 'any-key', 'verify' );
+		$this->sut->validate_and_maybe_process_request( 0, 'any-key', 'verify' );
 	}

 	/**
@@ -123,7 +213,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 			time() . ':' . wp_fast_hash( 'test' )
 		);

-		( new EmailActionController() )->validate_and_maybe_process_request( $id, 'test', 'bogus-action' );
+		$this->sut->validate_and_maybe_process_request( $id, 'test', 'bogus-action' );

 		$this->assertEquals( NotificationStatus::PENDING, Factory::get_notification( $id )->get_status() );
 	}
@@ -140,7 +230,7 @@ class EmailActionControllerTests extends \WC_Unit_Test_Case {
 			time() . ':' . wp_fast_hash( 'test' )
 		);

-		( new EmailActionController() )->validate_and_maybe_process_request( $id, 'test', '' );
+		$this->sut->validate_and_maybe_process_request( $id, 'test', '' );

 		$this->assertEquals( NotificationStatus::PENDING, Factory::get_notification( $id )->get_status() );
 	}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
new file mode 100644
index 00000000000..64a624effbb
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailManagerTests.php
@@ -0,0 +1,129 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Emails;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use WC_Helper_Product;
+
+/**
+ * Tests for EmailManager wrapper methods.
+ */
+class EmailManagerTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var EmailManager
+	 */
+	private $sut;
+
+	/**
+	 * Captured `wp_mail()` recipients.
+	 *
+	 * @var array<string,bool>
+	 */
+	private $sent_to = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Short-circuit `wp_mail()` so the tests never attempt a real SMTP handoff.
+		// Returning a non-null value from `pre_wp_mail` signals WP core to skip the actual send.
+		add_filter( 'pre_wp_mail', array( $this, 'capture_pre_wp_mail' ), 10, 2 );
+
+		$this->sut = new EmailManager();
+		$this->sut->init();
+
+		// Boot the mailer so email classes are registered.
+		WC()->mailer();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'pre_wp_mail', array( $this, 'capture_pre_wp_mail' ), 10 );
+		$this->sent_to = array();
+		parent::tearDown();
+	}
+
+	/**
+	 * `pre_wp_mail` filter: record the recipient and short-circuit the actual send.
+	 *
+	 * @param bool|null $short_circuit Null means "keep going", non-null short-circuits.
+	 * @param array     $atts          Mail arguments.
+	 * @return bool
+	 */
+	public function capture_pre_wp_mail( $short_circuit, $atts ): bool {
+		unset( $short_circuit );
+		$recipients = is_array( $atts['to'] ?? null ) ? $atts['to'] : array( $atts['to'] ?? '' );
+		foreach ( $recipients as $recipient ) {
+			$this->sent_to[ $recipient ] = true;
+		}
+		return true;
+	}
+
+	/**
+	 * @testdox Should register the three BIS email classes via woocommerce_email_classes filter.
+	 */
+	public function test_registers_all_three_bis_email_classes() {
+		$emails = WC()->mailer()->get_emails();
+
+		$this->assertArrayHasKey( 'WC_Email_Customer_Stock_Notification', $emails );
+		$this->assertArrayHasKey( 'WC_Email_Customer_Stock_Notification_Verify', $emails );
+		$this->assertArrayHasKey( 'WC_Email_Customer_Stock_Notification_Verified', $emails );
+	}
+
+	/**
+	 * @testdox Should dispatch the verify email to the notification's user email when send_verify_email is called.
+	 */
+	public function test_send_verify_email_prepares_verify_email_for_notification() {
+		$notification = $this->build_notification();
+
+		$this->sut->send_verify_email( $notification );
+
+		$emails = WC()->mailer()->get_emails();
+		$verify = $emails['WC_Email_Customer_Stock_Notification_Verify'];
+		$this->assertSame( $notification->get_user_email(), $verify->get_recipient() );
+		// Behavior assertion: the trigger path actually dispatched mail to the expected recipient.
+		$this->assertArrayHasKey( $notification->get_user_email(), $this->sent_to );
+	}
+
+	/**
+	 * @testdox Should dispatch the verified email to the notification's user email when send_verified_email is called.
+	 */
+	public function test_send_verified_email_prepares_verified_email_for_notification() {
+		$notification = $this->build_notification();
+
+		$this->sut->send_verified_email( $notification );
+
+		$emails   = WC()->mailer()->get_emails();
+		$verified = $emails['WC_Email_Customer_Stock_Notification_Verified'];
+		$this->assertSame( $notification->get_user_email(), $verified->get_recipient() );
+		// Behavior assertion: the trigger path actually dispatched mail to the expected recipient.
+		$this->assertArrayHasKey( $notification->get_user_email(), $this->sent_to );
+	}
+
+	/**
+	 * Build a persisted notification for an in-stock simple product.
+	 *
+	 * @return Notification
+	 */
+	private function build_notification(): Notification {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_status( NotificationStatus::PENDING );
+		$notification->set_user_email( 'customer@example.com' );
+		$notification->save();
+
+		return $notification;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php
new file mode 100644
index 00000000000..58d04e63316
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php
@@ -0,0 +1,271 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Factory;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification;
+use WC_Helper_Product;
+
+/**
+ * Tests for NotificationManagementService resend handler.
+ */
+class NotificationManagementServiceTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var NotificationManagementService
+	 */
+	private $sut;
+
+	/**
+	 * Mock email manager.
+	 *
+	 * @var EmailManager&\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $email_manager;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Intercept redirects so headers aren't emitted, and throw so the trailing `exit;`
+		// in production code never runs during the test.
+		add_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );
+
+		// Clear any notices left by earlier tests in the suite so the silent-drop
+		// assertion in test_resend_request_rejects_invalid_nonce is meaningful.
+		wc_clear_notices();
+
+		$this->email_manager = $this->createMock( EmailManager::class );
+		$this->sut           = new NotificationManagementService();
+		$this->sut->init( $this->email_manager );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );
+
+		unset( $_GET['_wpnonce'], $_GET[ NotificationManagementService::RESEND_QUERY_ARG ] );
+
+		// DELETE rather than TRUNCATE so the outer WP_UnitTestCase transaction can still roll back.
+		// TRUNCATE is DDL and implicitly commits the surrounding transaction.
+		global $wpdb;
+		$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_stock_notificationmeta" );
+		$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_stock_notifications" );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * `wp_redirect` filter callback that throws so the SUT's trailing `exit;`
+	 * never executes and the test can still assert state after the method.
+	 *
+	 * @param string $location Redirect target.
+	 * @return never
+	 * @throws \RuntimeException Always.
+	 */
+	public function intercept_redirect( string $location ): void {
+		throw new \RuntimeException( 'wp_redirect intercepted: ' . esc_url_raw( $location ) );
+	}
+
+	/**
+	 * @testdox Should generate a nonce-protected resend URL including the notification id.
+	 */
+	public function test_get_resend_verification_email_url_contains_notification_id_and_nonce() {
+		$notification = $this->build_pending_notification();
+
+		$url = $this->sut->get_resend_verification_email_url( $notification );
+
+		$this->assertStringContainsString( NotificationManagementService::RESEND_QUERY_ARG . '=' . $notification->get_id(), $url );
+		$this->assertStringContainsString( '_wpnonce=', $url );
+	}
+
+	/**
+	 * @testdox Should send the verify email and persist last-sent timestamp on a valid resend request.
+	 */
+	public function test_resend_request_sends_verify_email_and_persists_timestamp() {
+		$notification = $this->build_pending_notification();
+
+		$this->seed_resend_request( $notification->get_id() );
+
+		$this->email_manager
+			->expects( $this->once() )
+			->method( 'send_verify_email' )
+			->with(
+				$this->callback(
+					static function ( $arg ) use ( $notification ) {
+						return $arg instanceof Notification && $arg->get_id() === $notification->get_id();
+					}
+				)
+			);
+
+		// The SUT redirects (and would `exit`) at the end of the happy path; the redirect filter
+		// throws so we can assert side effects instead of actually halting.
+		try {
+			$this->sut->maybe_process_resend_request();
+			$this->fail( 'Expected redirect to be intercepted via exception.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'wp_redirect intercepted', $e->getMessage() );
+		}
+
+		$reloaded = Factory::get_notification( $notification->get_id() );
+		$this->assertNotEmpty( $reloaded->get_meta( NotificationManagementService::LAST_VERIFY_EMAIL_SENT_META ) );
+	}
+
+	/**
+	 * @testdox Should persist the rate-limit timestamp before dispatching the email (TOCTOU guard).
+	 */
+	public function test_resend_request_writes_rate_limit_timestamp_before_sending_email() {
+		$notification    = $this->build_pending_notification();
+		$notification_id = $notification->get_id();
+
+		$this->seed_resend_request( $notification_id );
+
+		$this->email_manager
+			->expects( $this->once() )
+			->method( 'send_verify_email' )
+			->willReturnCallback(
+				function () use ( $notification_id ) {
+					// At the moment the email would be dispatched, the rate-limit timestamp must
+					// already be persisted so a concurrent request can't pass the rate-limit check.
+					$reloaded = Factory::get_notification( $notification_id );
+					$this->assertNotEmpty( $reloaded->get_meta( NotificationManagementService::LAST_VERIFY_EMAIL_SENT_META ) );
+				}
+			);
+
+		try {
+			$this->sut->maybe_process_resend_request();
+			$this->fail( 'Expected redirect to be intercepted via exception.' );
+		} catch ( \RuntimeException $e ) {
+			$this->assertStringContainsString( 'wp_redirect intercepted', $e->getMessage() );
+		}
+	}
+
+	/**
+	 * @testdox Should not send a verify email when the most recent send is within the rate-limit window.
+	 */
+	public function test_resend_request_rate_limited() {
+		$notification = $this->build_pending_notification();
+		$notification->update_meta_data( NotificationManagementService::LAST_VERIFY_EMAIL_SENT_META, time() );
+		$notification->save();
+
+		$this->seed_resend_request( $notification->get_id() );
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$this->expectException( \RuntimeException::class );
+		$this->sut->maybe_process_resend_request();
+	}
+
+	/**
+	 * @testdox Should not send a verify email when the notification is already verified.
+	 */
+	public function test_resend_request_rejected_if_already_verified() {
+		$product      = WC_Helper_Product::create_simple_product();
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_status( NotificationStatus::ACTIVE );
+		$notification->set_user_email( 'customer@example.com' );
+		$notification->save();
+
+		$this->seed_resend_request( $notification->get_id() );
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$this->expectException( \RuntimeException::class );
+		$this->sut->maybe_process_resend_request();
+	}
+
+	/**
+	 * @testdox Should silently drop the request when the nonce is invalid — no email, no redirect, no meta write, no notice.
+	 */
+	public function test_resend_request_rejects_invalid_nonce() {
+		$notification = $this->build_pending_notification();
+
+		$_GET[ NotificationManagementService::RESEND_QUERY_ARG ] = (string) $notification->get_id();
+		$_GET['_wpnonce']                                        = 'not-a-real-nonce';
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		// Invalid nonce must return before reaching the redirect path.
+		$this->sut->maybe_process_resend_request();
+
+		// Silent drop: no rate-limit meta written, no notice queued, no session cookie primed.
+		$reloaded = Factory::get_notification( $notification->get_id() );
+		$this->assertSame( '', (string) $reloaded->get_meta( NotificationManagementService::LAST_VERIFY_EMAIL_SENT_META ) );
+		$this->assertEmpty( wc_get_notices() );
+	}
+
+	/**
+	 * @testdox A nonce minted for notification A must not validate for notification B.
+	 */
+	public function test_resend_request_rejects_cross_notification_nonce_replay() {
+		$victim   = $this->build_pending_notification();
+		$attacker = $this->build_pending_notification();
+
+		// Attacker mints a valid nonce for their own notification and replays it on the victim's id.
+		$_GET[ NotificationManagementService::RESEND_QUERY_ARG ] = (string) $victim->get_id();
+		$_GET['_wpnonce']                                        = wp_create_nonce(
+			NotificationManagementService::RESEND_NONCE_ACTION . '_' . $attacker->get_id()
+		);
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$this->sut->maybe_process_resend_request();
+	}
+
+	/**
+	 * @testdox Should be a no-op when the request does not carry the resend query arg.
+	 */
+	public function test_resend_request_noop_without_query_arg() {
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$this->sut->maybe_process_resend_request();
+	}
+
+	/**
+	 * Build a pending notification for a fresh simple product.
+	 *
+	 * @return Notification
+	 */
+	private function build_pending_notification(): Notification {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$notification = new Notification();
+		$notification->set_product_id( $product->get_id() );
+		$notification->set_status( NotificationStatus::PENDING );
+		$notification->set_user_email( 'customer@example.com' );
+		$notification->save();
+
+		return $notification;
+	}
+
+	/**
+	 * Populate superglobals as if a valid resend request reached the site.
+	 *
+	 * @param int $notification_id Notification id.
+	 */
+	private function seed_resend_request( int $notification_id ): void {
+		$_GET[ NotificationManagementService::RESEND_QUERY_ARG ] = (string) $notification_id;
+		$_GET['_wpnonce']                                        = wp_create_nonce( NotificationManagementService::RESEND_NONCE_ACTION . '_' . $notification_id );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
new file mode 100644
index 00000000000..768aff45a06
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
@@ -0,0 +1,120 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Frontend;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailManager;
+use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\SignupService;
+use Automattic\WooCommerce\Internal\StockNotifications\Notification;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\EligibilityService;
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\StockManagementHelper;
+use WC_Helper_Product;
+
+/**
+ * Tests for SignupService email dispatch.
+ */
+class SignupServiceTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var SignupService
+	 */
+	private $sut;
+
+	/**
+	 * Mock email manager.
+	 *
+	 * @var EmailManager&\PHPUnit\Framework\MockObject\MockObject
+	 */
+	private $email_manager;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_customer_stock_notifications_allow_signups', 'yes' );
+
+		$eligibility_service = new EligibilityService();
+		$eligibility_service->init( new StockManagementHelper() );
+
+		$this->email_manager = $this->createMock( EmailManager::class );
+
+		$notification_management_service = new NotificationManagementService();
+		$notification_management_service->init( $this->email_manager );
+
+		$this->sut = new SignupService();
+		$this->sut->init( $eligibility_service, $notification_management_service, $this->email_manager );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		delete_option( 'woocommerce_customer_stock_notifications_allow_signups' );
+		delete_option( 'woocommerce_customer_stock_notifications_require_double_opt_in' );
+
+		// DELETE rather than TRUNCATE so the outer WP_UnitTestCase transaction can still roll back.
+		// TRUNCATE is DDL and implicitly commits the surrounding transaction.
+		global $wpdb;
+		$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_stock_notificationmeta" );
+		$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_stock_notifications" );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should send the verify email when double opt-in is required and a new pending notification is created.
+	 */
+	public function test_verify_email_sent_when_double_opt_in_required() {
+		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'yes' );
+
+		$product = $this->create_out_of_stock_product();
+
+		$this->email_manager
+			->expects( $this->once() )
+			->method( 'send_verify_email' )
+			->with(
+				$this->callback(
+					static function ( $arg ) {
+						return $arg instanceof Notification
+							&& NotificationStatus::PENDING === $arg->get_status();
+					}
+				)
+			);
+
+		$this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+	}
+
+	/**
+	 * @testdox Should not send the verify email when double opt-in is disabled.
+	 */
+	public function test_verify_email_not_sent_when_double_opt_in_disabled() {
+		update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+		$product = $this->create_out_of_stock_product();
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+	}
+
+	/**
+	 * Create an out-of-stock simple product for signup.
+	 *
+	 * @return \WC_Product_Simple
+	 */
+	private function create_out_of_stock_product(): \WC_Product_Simple {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_stock_status( 'outofstock' );
+		$product->save();
+
+		return $product;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/UtmHelperTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/UtmHelperTests.php
new file mode 100644
index 00000000000..77ec94b77fd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Utilities/UtmHelperTests.php
@@ -0,0 +1,68 @@
+<?php
+
+declare( strict_types = 1 );
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Utilities;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Utilities\UtmHelper;
+
+/**
+ * Tests for UtmHelper.
+ */
+class UtmHelperTests extends \WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should append standard UTM params to a plain URL.
+	 */
+	public function test_add_email_utm_params_to_plain_url() {
+		$url = UtmHelper::add_email_utm_params( 'https://shop.example.com/product/test/' );
+
+		$this->assertStringContainsString( 'utm_source=back-in-stock-notifications', $url );
+		$this->assertStringContainsString( 'utm_medium=email', $url );
+	}
+
+	/**
+	 * @testdox Should preserve existing query params when appending UTMs.
+	 */
+	public function test_add_email_utm_params_preserves_existing_query() {
+		$url = UtmHelper::add_email_utm_params( 'https://shop.example.com/product/test/?notification_id=123' );
+
+		$this->assertStringContainsString( 'notification_id=123', $url );
+		$this->assertStringContainsString( 'utm_source=back-in-stock-notifications', $url );
+		$this->assertStringContainsString( 'utm_medium=email', $url );
+	}
+
+	/**
+	 * @testdox Should use an explicit medium when supplied.
+	 */
+	public function test_add_email_utm_params_uses_custom_medium() {
+		$url = UtmHelper::add_email_utm_params( 'https://shop.example.com/', 'custom-medium' );
+
+		$this->assertStringContainsString( 'utm_medium=custom-medium', $url );
+	}
+
+	/**
+	 * @testdox Should return an empty string when given an empty URL.
+	 */
+	public function test_add_email_utm_params_returns_empty_for_empty_input() {
+		$this->assertSame( '', UtmHelper::add_email_utm_params( '' ) );
+	}
+
+	/**
+	 * @testdox Should sanitize the medium argument so only URL-safe characters land in the outbound URL.
+	 */
+	public function test_add_email_utm_params_sanitizes_medium() {
+		$url = UtmHelper::add_email_utm_params( 'https://shop.example.com/', 'Weird Medium!' );
+
+		// sanitize_key() lowercases and strips spaces / punctuation.
+		$this->assertStringContainsString( 'utm_medium=weirdmedium', $url );
+	}
+
+	/**
+	 * @testdox Should fall back to the default medium when sanitization strips everything.
+	 */
+	public function test_add_email_utm_params_falls_back_to_default_medium_on_empty_sanitized_value() {
+		$url = UtmHelper::add_email_utm_params( 'https://shop.example.com/', '!!!' );
+
+		$this->assertStringContainsString( 'utm_medium=email', $url );
+	}
+}