Commit 37a99a9ffd2 for woocommerce

commit 37a99a9ffd2b9bd06d89f8d796ddb8e628f48ab0
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Wed Sep 9 22:22:50 2026 +0300

    Add a pending-verification section to the Stock notifications My Account tab (#68295)

    * Add My Account endpoint for back in stock notifications

    Registers the back-in-stock-notifications endpoint under WC > My Account
    with a matching template that renders the current user's signups as an
    accessible table. Wired via woocommerce_get_query_vars (rewrite endpoint),
    woocommerce_account_menu_items (menu label), and the standard
    woocommerce_account_<slug>_endpoint action. Notifications are fetched
    exclusively via NotificationQuery::get_notifications() scoped to
    get_current_user_id(), never by client-supplied ids.

    The whole MyAccountEndpoint is instantiated from StockNotifications
    init_hooks(), which is already gated behind WOOCOMMERCE_BIS_ALPHA_ENABLED
    in class-woocommerce.php, so the endpoint and menu item stay hidden
    whenever the alpha constant is off.

    * Add PHPUnit coverage for My Account BIS endpoint

    Covers the customer-facing scenarios that were previously only exercised
    in ad-hoc manual testing:

    - Endpoint helper returns only the current user's notifications.
    - User A cannot see user B's notifications.
    - Anonymous visitors get an empty list (no query by client ids).
    - Empty state for users with no signups.
    - Cancel with a valid per-notification nonce flips to cancelled +
      records cancellation source = user.
    - Cancel with an invalid nonce is a no-op.
    - Cross-notification nonce replay (A's nonce + B's id) is a no-op.
    - Ownership check blocks user A cancelling user B's notification.
    - Anonymous cancel POST is silently dropped.
    - Menu filter registers the label and preserves Log out at the end.
    - Query var filter adds the expected slug.

    * Add Playwright coverage for My Account BIS tab

    Covers the customer-facing surface in tests/e2e-pw:

    - Logged-in customer with one pending + one active notification sees
      both in the tab with the expected Status cells.
    - Clicking Cancel on a pending row flips it to Cancelled and disables
      the button.
    - Empty state renders the friendly copy and a Browse products link.
    - Anonymous visitor hitting /my-account/back-in-stock-notifications/
      gets the standard WC login form.

    The helper file utils/back-in-stock-notifications.ts matches the
    version on rsm-437-e2e-followup so the two branches merge cleanly
    without duplicating or forking the helper surface.

    * Drop the Status column from the My Account BIS notifications table

    The status (active / pending / sent / cancelled) is implicit from the
    Cancel button state — the button is disabled for sent/cancelled rows
    and active for everything else — so the dedicated column was just
    visual noise. Drop the column header, cell, and status-label map.

    The underlying `$status` is still computed because the Cancel-button
    gating and the row's `--status-<value>` CSS hook both depend on it.

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

    * Shorten the My Account menu label to "Stock notifications"

    "Back in stock notifications" was wider than every other My Account
    sidebar item ("Orders", "Downloads", "Addresses", "Account details",
    "Log out") and pushed the column off-grid. The endpoint slug stays
    `back-in-stock-notifications` for URL stability; only the label and
    the endpoint page title shorten.

    Updates the matching PHPUnit + Playwright assertions.

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

    * Paginate the My Account stock notifications table

    Default `per_page` is 10. The current page comes from WC's rewrite
    endpoint capture — `/my-account/back-in-stock-notifications/2/` lands
    in the `woocommerce_account_back-in-stock-notifications_endpoint` hook
    as the first arg, mirroring how `/my-account/orders/2/` works.

    Implementation notes:

    - `NotificationQuery` gains a `count_notifications()` wrapper so the
      endpoint can ask for a total without going through `get_notifications`,
      whose `array` return type can't honour the data store's `int` count
      return. Both wrappers funnel through a single private `run_query`
      helper so the existing `WC_Data_Store::query()` PHPStan suppression
      in `phpstan-baseline.neon` keeps applying to one call site, not two.
    - `MyAccountEndpoint::get_current_user_notifications_page()` returns a
      struct with `notifications`, `current_page`, `total_pages`,
      `total_items`. Out-of-range page numbers clamp to the last page so a
      stale link doesn't render an empty table.
    - Per-page count is filterable via `woocommerce_account_back_in_stock_notifications_per_page`.
    - Template renders WP core's `paginate_links()` below the table when
      more than one page exists, matching the orders endpoint's pattern.

    Drive-by PHPStan cleanups:

    - Drop a redundant `is_array()` check on a value already guaranteed to
      be an array by `NotificationQuery::get_notifications()`'s return type.
    - Narrow `Factory::get_notification()` returns with
      `instanceof Notification` in the cancel handler, since the helper
      returns `Notification|true` and the previous truthy-only check
      couldn't filter `true` out.

    Tests:

    - Existing endpoint-helper tests migrated to the new
      `get_current_user_notifications_page()` signature.
    - New tests cover pagination math (page 2 of 7 with per_page=3 returns
      rows 4-6 in `id` DESC order) and the out-of-range clamp.

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

    * Hide SENT and CANCELLED notifications from the My Account view

    The My Account view is "what am I waiting for" — once the email has
    been dispatched (SENT) or the customer has cancelled, the row is just
    noise. Filter the query to PENDING + ACTIVE only and let the merchant
    see the full history in the BIS admin (which already shows everything).

    Knock-on simplifications:

    - Drop the `disabled` Cancel button branch from the template — every
      visible row is actionable now, so the button is always live.
    - Drop the `$is_cancelled` / `$is_sent` template variables and the
      `NotificationStatus` import from the template, both unused.
    - Cancelling a row makes it vanish from the table; the toast notice
      ("Back in stock notification for X cancelled.") is the only feedback.
      Cleaner than leaving a tombstone row with a disabled button.

    Plumbing:

    - `StockNotificationsDataStore::query()` now accepts an array for
      `status` (mirroring how `product_id` already accepts arrays) and
      emits `status IN (...)`. Backwards-compatible — single-string status
      args still work via the `(array)` cast.
    - Default statuses are filterable via
      `woocommerce_account_back_in_stock_notifications_statuses` for
      merchants who want a different view (e.g. include SENT for a
      history-style listing).

    Tests:

    - New PHPUnit case asserts SENT + CANCELLED are excluded.
    - Playwright cancel test rewritten — used to assert "Cancelled" cell +
      disabled button; now asserts the row is gone + a notice is shown.
    - Two-rows test stops asserting Status cells (Status column was
      dropped earlier; the assertion was leftover).

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

    * Rename the My Account endpoint slug to `stock-notifications`

    URL goes from `/my-account/back-in-stock-notifications/` to
    `/my-account/stock-notifications/` to match the menu label "Stock
    notifications". Renames:

    - `MyAccountEndpoint::ENDPOINT` constant.
    - The endpoint template, `myaccount/back-in-stock-notifications.php`
      → `myaccount/stock-notifications.php` (matching the WC convention
      that template basename equals the endpoint slug — see
      `myaccount/orders.php`, `myaccount/downloads.php`).
    - Pagination `aria-label` to "Stock notifications pagination".
    - Slug references in docblocks and the e2e spec URLs.

    Knock-on auto-changes:

    - The endpoint dispatch hook becomes
      `woocommerce_account_stock-notifications_endpoint` (driven by the
      new constant).
    - The query var driving `is_stock_notifications_endpoint` checks is
      now `$wp->query_vars['stock-notifications']`.

    Deliberately NOT renamed (these name the FEATURE, not the URL):

    - `woocommerce_account_back_in_stock_notifications_per_page` filter.
    - `woocommerce_account_back_in_stock_notifications_statuses` filter.
    - `woocommerce_before_account_back_in_stock_notifications` /
      `woocommerce_after_account_back_in_stock_notifications` actions.
    - `.woocommerce-back-in-stock-notifications-*` CSS classes.
    - The e2e test directory + utils module path.

    Production sites will need a one-time `wp rewrite flush` (or any
    permalink-settings save) after upgrading for the new URL to resolve.

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

    * style: fix phpcs and eslint findings in My Account BIS files

    * refactor: rename BIS my-account classes and hooks to customer-stock-notifications

    * fix: wrap stock notifications table headers in nobr spans

    * fix: make stock notifications Product cell a row header for screen readers

    * fix: drop redundant Actions label from stock notifications table on mobile

    * fix: show stock notification variations under the My Account product name

    * add: changelog entry for the My Account stock notifications tab

    * fix: exit after the stock notification cancel redirect so the notice shows

    * test: fix stale My Account stock notifications Playwright spec

    * style: drop unescaped exception message from stock notifications tests

    * fix: only offer Cancel on cancellable stock notifications

    * fix: name the product in each stock notification Cancel button

    * fix: report an error when a stock notification cancel fails

    * docs: drop orders-pagination reference from stock notifications hook

    * perf: prime the post cache for My Account stock notification products

    * fix: report an error when a stock notification cancel doesn't persist

    * test: use exact string locators in the My Account stock notifications spec

    * refactor: fold stock notifications table CSS into existing responsive rules

    * fix: keep stock notifications Product column visible without custom CSS

    * test: expect the stock notifications endpoint in the core account tests

    * feat: split My Account stock notifications into pending and active tables

    * test: match the resend link by its aria-label and quiet phpcs on the test setup

    * refactor: post My Account stock notification resend like cancel via one action handler

    * fix: name the stock notification tables and pagination for screen readers

    * test: pin that stock notification actions are ignored off the My Account endpoint

    * refactor: drive My Account stock notification actions from nonce'd links like cancel order

    * fix: align My Account stock notification copy with the BIS extension

    * fix: indent the exact heading match in the My Account stock notifications spec

    * perf: hydrate stock notification objects from the rows already queried

    * fix: keep the customer on their page after cancelling a stock notification

    * revert: restore the per-row read on stock notification object queries

    * test: assert the exact resend redirect URL in the stock notifications spec

    * fix: include visible label in resend stock notification aria-label

    Claude-Session: https://claude.ai/code/session_01QfFfJ6FBEp67MZtpcsXAYX

    * style: wrap the resend link locators in the stock notifications spec

    Claude-Session: https://claude.ai/code/session_01QfFfJ6FBEp67MZtpcsXAYX

    * style: add empty lines before nested stock notification action link rules

    Claude-Session: https://claude.ai/code/session_01QfFfJ6FBEp67MZtpcsXAYX

    ---------

    Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/68292-my-account-pending-stock-notifications b/plugins/woocommerce/changelog/68292-my-account-pending-stock-notifications
new file mode 100644
index 00000000000..411548dd413
--- /dev/null
+++ b/plugins/woocommerce/changelog/68292-my-account-pending-stock-notifications
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an "Awaiting confirmation" section to the Stock notifications My Account tab, listing unconfirmed sign-ups with Resend email and Cancel actions above the active notifications table.
diff --git a/plugins/woocommerce/client/legacy/css/woocommerce.scss b/plugins/woocommerce/client/legacy/css/woocommerce.scss
index 697e96f2797..786a7a26ff4 100644
--- a/plugins/woocommerce/client/legacy/css/woocommerce.scss
+++ b/plugins/woocommerce/client/legacy/css/woocommerce.scss
@@ -1105,6 +1105,23 @@ p.demo_store,
 		}
 	}

+	table.woocommerce-MyAccount-customerStockNotifications {
+
+		.woocommerce-customer-stock-notifications-table__cell-actions {
+
+			.woocommerce-customer-stock-notifications-action-link {
+				display: inline-block;
+				vertical-align: middle;
+				margin-block: 0.25em;
+				margin-inline-end: 0.5em;
+
+				&:last-child {
+					margin-inline-end: 0;
+				}
+			}
+		}
+	}
+
 	td.product-name {

 		dl.variation,
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php
index fa990de0941..486995286e8 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/MyAccountEndpoint.php
@@ -15,7 +15,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\NotificationQuery;

 /**
  * Registers the "Stock notifications" My Account endpoint and handles
- * the cancel action for customer-owned notifications.
+ * the resend and cancel actions for customer-owned notifications.
  *
  * @internal
  */
@@ -49,27 +49,67 @@ class MyAccountEndpoint {
 	}

 	/**
-	 * Query argument triggered by the cancel form post.
+	 * Query argument naming the action a row link triggers.
 	 */
-	public const CANCEL_ACTION = 'wc_bis_cancel_notification';
+	public const ACTION_FIELD = 'wc_bis_action';

 	/**
-	 * Build the nonce action name for a given notification id.
+	 * Action value: send the verification email again for a pending notification.
+	 */
+	public const ACTION_RESEND = 'resend';
+
+	/**
+	 * Action value: cancel a pending or active notification.
+	 */
+	public const ACTION_CANCEL = 'cancel';
+
+	/**
+	 * Build the nonce action name for an action on a given notification.
 	 *
-	 * Same shape as the admin-side scoping from #64348: `wc_bis_cancel_<id>`.
+	 * Scoped per action and per notification (`wc_bis_cancel_<id>`), so a nonce minted for one
+	 * row's Cancel link cannot be replayed on another row or on its Resend link.
 	 *
-	 * @param int $notification_id The notification id.
+	 * @param string $action          One of the `ACTION_*` values.
+	 * @param int    $notification_id The notification id.
 	 * @return string The nonce action name.
 	 */
-	public static function get_cancel_nonce_action( int $notification_id ): string {
-		return 'wc_bis_cancel_' . $notification_id;
+	public static function get_nonce_action( string $action, int $notification_id ): string {
+		return 'wc_bis_' . $action . '_' . $notification_id;
+	}
+
+	/**
+	 * Build the nonce-protected URL a row action link points at.
+	 *
+	 * Same shape as the other My Account action links (cancel order, resend set-password):
+	 * a GET back to the endpoint carrying the action, the id, and a scoped nonce.
+	 *
+	 * @param string $action          One of the `ACTION_*` values.
+	 * @param int    $notification_id The notification id.
+	 * @param int    $page            1-indexed page the link is rendered on.
+	 * @return string The action URL.
+	 */
+	public static function get_action_url( string $action, int $notification_id, int $page = 1 ): string {
+		$args = array(
+			self::ACTION_FIELD => $action,
+			'notification_id'  => $notification_id,
+		);
+
+		// Carried so the redirect afterwards returns the customer to the page they acted from.
+		if ( $page > 1 ) {
+			$args['notifications_page'] = $page;
+		}
+
+		$url = add_query_arg( $args, self::get_endpoint_url() );
+
+		return wp_nonce_url( $url, self::get_nonce_action( $action, $notification_id ) );
 	}

 	/**
 	 * Statuses a customer can still cancel from My Account.
 	 *
-	 * The same list backs the listing query, the Cancel button in the template
-	 * and the cancel request handler, so the three can't drift apart.
+	 * Backs the Cancel button in the template and the cancel request handler,
+	 * so the two can't drift apart. The listing itself splits into a pending
+	 * table and an active table, each querying a single status.
 	 *
 	 * @return string[] List of {@see NotificationStatus} values.
 	 */
@@ -87,6 +127,13 @@ class MyAccountEndpoint {
 		return in_array( (string) $notification->get_status(), self::get_cancellable_statuses(), true );
 	}

+	/**
+	 * Notification management service, owns the resend-verification domain logic.
+	 *
+	 * @var NotificationManagementService
+	 */
+	private NotificationManagementService $notification_management_service;
+
 	/**
 	 * Constructor.
 	 */
@@ -95,7 +142,18 @@ class MyAccountEndpoint {
 		add_filter( 'woocommerce_account_menu_items', array( $this, 'register_menu_item' ), 10, 2 );
 		add_filter( 'woocommerce_endpoint_' . self::ENDPOINT . '_title', array( $this, 'filter_endpoint_title' ) );
 		add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', array( $this, 'render_endpoint' ) );
-		add_action( 'template_redirect', array( $this, 'maybe_handle_cancel' ) );
+		add_action( 'template_redirect', array( $this, 'maybe_handle_action' ) );
+	}
+
+	/**
+	 * Initialize the class instance.
+	 *
+	 * @internal
+	 *
+	 * @param NotificationManagementService $notification_management_service The notification management service.
+	 */
+	final public function init( NotificationManagementService $notification_management_service ): void {
+		$this->notification_management_service = $notification_management_service;
 	}

 	/**
@@ -227,23 +285,74 @@ class MyAccountEndpoint {
 		$per_page = (int) apply_filters( 'woocommerce_account_customer_stock_notifications_per_page', self::DEFAULT_PER_PAGE );
 		$per_page = max( 1, $per_page );

-		$page = $this->get_current_user_notifications_page( $current_page, $per_page );
+		/**
+		 * Filter how many pending (awaiting confirmation) notifications the My Account
+		 * stock-notifications tab lists above the active table.
+		 *
+		 * The pending table is not paginated, so this is a hard cap.
+		 *
+		 * @since 11.2.0
+		 *
+		 * @param int $limit Maximum number of pending notifications shown. Default {@see self::DEFAULT_PER_PAGE}.
+		 */
+		$pending_limit = (int) apply_filters( 'woocommerce_account_customer_stock_notifications_pending_limit', self::DEFAULT_PER_PAGE );
+		$pending_limit = max( 1, $pending_limit );
+
+		$pending = $this->get_current_user_pending_notifications( $pending_limit );
+		$page    = $this->get_current_user_notifications_page( $current_page, $per_page );
+
+		// Both lists come from raw SQL selects, so nothing has primed the post cache
+		// for the products the rows render. Prime it once here instead of letting
+		// each row's wc_get_product() call issue its own query.
+		$product_ids = array_filter( array_map( static fn( $notification ) => (int) $notification->get_product_id(), array_merge( $pending, $page['notifications'] ) ) );
+		if ( $product_ids ) {
+			_prime_post_caches( array_values( array_unique( $product_ids ) ) );
+		}

 		\wc_get_template(
 			'myaccount/stock-notifications.php',
 			array(
-				'notifications' => $page['notifications'],
-				'has_items'     => ! empty( $page['notifications'] ),
-				'current_page'  => $page['current_page'],
-				'total_pages'   => $page['total_pages'],
-				'total_items'   => $page['total_items'],
-				'per_page'      => $per_page,
+				'notifications'         => $page['notifications'],
+				'pending_notifications' => $pending,
+				'has_pending'           => ! empty( $pending ),
+				'has_items'             => ! empty( $pending ) || ! empty( $page['notifications'] ),
+				'current_page'          => $page['current_page'],
+				'total_pages'           => $page['total_pages'],
+				'total_items'           => $page['total_items'],
+				'per_page'              => $per_page,
+			)
+		);
+	}
+
+	/**
+	 * Return the current user's pending (awaiting confirmation) notifications, newest first.
+	 *
+	 * Always scopes to `get_current_user_id()` — the caller is never trusted.
+	 *
+	 * @param int $limit Maximum number of notifications to return.
+	 * @return array<Notification>
+	 */
+	public function get_current_user_pending_notifications( int $limit ): array {
+		$limit = max( 1, $limit );
+
+		$user_id = get_current_user_id();
+		if ( $user_id <= 0 ) {
+			return array();
+		}
+
+		return NotificationQuery::get_notifications(
+			array(
+				'user_id'  => $user_id,
+				'status'   => NotificationStatus::PENDING,
+				'order_by' => array( 'id' => 'DESC' ),
+				'return'   => 'objects',
+				'limit'    => $limit,
 			)
 		);
 	}

 	/**
-	 * Return one page of the current user's notifications, newest first.
+	 * Return one page of the current user's active notifications, newest first.
 	 *
 	 * Always scopes to `get_current_user_id()` — the caller is never trusted.
 	 *
@@ -265,10 +374,11 @@ class MyAccountEndpoint {
 			);
 		}

-		// Only the statuses the customer can act on. SENT and CANCELLED rows are
-		// noise here; merchants who want a full history can build their own view
-		// via {@see NotificationQuery::get_notifications()}.
-		$statuses = self::get_cancellable_statuses();
+		// Only confirmed sign-ups. PENDING rows render in their own table above this
+		// one ({@see self::get_current_user_pending_notifications()}); SENT and
+		// CANCELLED rows are noise here, and merchants who want a full history can
+		// build their own view via {@see NotificationQuery::get_notifications()}.
+		$statuses = array( NotificationStatus::ACTIVE );

 		$total_items = NotificationQuery::count_notifications(
 			array(
@@ -296,13 +406,6 @@ class MyAccountEndpoint {
 			)
 		) : array();

-		// The notifications come from a raw SQL select, so nothing has primed the
-		// post cache for the products the rows render. Prime it once here instead
-		// of letting each row's wc_get_product() call issue its own query.
-		if ( $notifications ) {
-			_prime_post_caches( array_filter( array_map( static fn( $notification ) => (int) $notification->get_product_id(), $notifications ) ) );
-		}
-
 		return array(
 			'notifications' => $notifications,
 			'current_page'  => $current_page,
@@ -312,24 +415,25 @@ class MyAccountEndpoint {
 	}

 	/**
-	 * Intercept a cancel POST to flip a notification to `cancelled`.
+	 * Intercept a row-action link (resend or cancel) from the stock notifications tab.
 	 *
-	 * Guards:
+	 * Guards, shared by every action:
 	 * - Must be on the My Account > stock-notifications endpoint.
 	 * - Must be authenticated.
-	 * - Nonce must be scoped to the specific notification id ({@see ::get_cancel_nonce_action()}).
-	 * - The notification must belong to the current user.
+	 * - Nonce must be scoped to the action and the notification id ({@see ::get_nonce_action()}).
+	 * - The notification must exist and belong to the current user.
 	 *
-	 * Requests that aren't a cancel submission, and ones whose notification isn't the
-	 * current user's, are dropped silently. A cancel the customer can act on — an expired
-	 * nonce, a notification that's gone or already cancelled, a save that fails — redirects
-	 * back with an error notice instead, so the button never looks dead.
+	 * Requests that aren't an action link, or that arrive anonymously, are dropped
+	 * silently. Everything else redirects back to the clean endpoint URL with a notice,
+	 * so the link never looks dead and a refresh can't replay it. A missing notification
+	 * and one owned by someone else share the same error, so the response doesn't
+	 * confirm whether the id exists.
 	 */
-	public function maybe_handle_cancel(): void {
+	public function maybe_handle_action(): void {
 		global $wp;

-		// phpcs:disable WordPress.Security.NonceVerification.Missing
-		if ( ! isset( $_POST[ self::CANCEL_ACTION ] ) ) {
+		// phpcs:disable WordPress.Security.NonceVerification.Recommended
+		if ( ! isset( $_GET[ self::ACTION_FIELD ] ) ) {
 			return;
 		}

@@ -341,32 +445,69 @@ class MyAccountEndpoint {
 			return;
 		}

-		$notification_id = isset( $_POST['notification_id'] ) ? absint( wp_unslash( $_POST['notification_id'] ) ) : 0;
+		$action = sanitize_key( wp_unslash( $_GET[ self::ACTION_FIELD ] ) );
+		if ( ! in_array( $action, array( self::ACTION_RESEND, self::ACTION_CANCEL ), true ) ) {
+			return;
+		}
+
+		$notification_id = isset( $_GET['notification_id'] ) ? absint( wp_unslash( $_GET['notification_id'] ) ) : 0;
 		if ( $notification_id <= 0 ) {
 			return;
 		}

-		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
+		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';

-		// The page the customer cancelled from, so they land back on it instead of page 1.
-		$page = isset( $_POST['notifications_page'] ) ? max( 1, absint( wp_unslash( $_POST['notifications_page'] ) ) ) : 1;
-		// phpcs:enable WordPress.Security.NonceVerification.Missing
+		// The page the link was clicked on, so the redirect lands there instead of page 1.
+		$page = isset( $_GET['notifications_page'] ) ? max( 1, absint( wp_unslash( $_GET['notifications_page'] ) ) ) : 1;
+		// phpcs:enable WordPress.Security.NonceVerification.Recommended

-		if ( ! wp_verify_nonce( $nonce, self::get_cancel_nonce_action( $notification_id ) ) ) {
+		if ( ! wp_verify_nonce( $nonce, self::get_nonce_action( $action, $notification_id ) ) ) {
 			$this->redirect_with_error( __( 'This link has expired. Please reload the page and try again.', 'woocommerce' ), $page );
 		}

 		$notification = Factory::get_notification( $notification_id );
-		if ( ! $notification instanceof Notification ) {
-			$this->redirect_with_error( __( 'That back in stock notification no longer exists.', 'woocommerce' ), $page );
+		if ( ! $notification instanceof Notification || (int) $notification->get_user_id() !== get_current_user_id() ) {
+			$this->redirect_with_error( __( 'We were unable to process your request. Notification not found.', 'woocommerce' ), $page );
 		}

-		if ( (int) $notification->get_user_id() !== get_current_user_id() ) {
-			return;
+		$result = self::ACTION_RESEND === $action
+			? $this->resend( $notification )
+			: $this->cancel( $notification );
+
+		if ( is_wp_error( $result ) ) {
+			$this->redirect_with_error( $result->get_error_message(), $page );
 		}

+		\wc_add_notice( esc_html( $result ) );
+		wp_safe_redirect( self::get_endpoint_url( $page ) );
+		exit;
+	}
+
+	/**
+	 * Send the verification email again for a pending notification.
+	 *
+	 * @param Notification $notification The notification, already checked to belong to the current user.
+	 * @return string|\WP_Error Success notice text, or the error to show instead.
+	 */
+	private function resend( Notification $notification ) {
+		$result = $this->notification_management_service->resend_verification_email( $notification );
+		if ( is_wp_error( $result ) ) {
+			return $result;
+		}
+
+		/* translators: %s: email address the verification email was sent to. */
+		return sprintf( __( 'Verification email sent to "%s". Please check your inbox!', 'woocommerce' ), $notification->get_user_email() );
+	}
+
+	/**
+	 * Flip a pending or active notification to `cancelled` on the customer's behalf.
+	 *
+	 * @param Notification $notification The notification, already checked to belong to the current user.
+	 * @return string|\WP_Error Success notice text, or the error to show instead.
+	 */
+	private function cancel( Notification $notification ) {
 		if ( ! self::is_cancellable( $notification ) ) {
-			$this->redirect_with_error( __( 'That back in stock notification has already been cancelled.', 'woocommerce' ), $page );
+			return new \WP_Error( 'wc_bis_cancel_not_cancellable', __( 'That back in stock notification has already been cancelled.', 'woocommerce' ) );
 		}

 		$notification->set_status( NotificationStatus::CANCELLED );
@@ -379,24 +520,16 @@ class MyAccountEndpoint {
 		// reaches us as a successful save. Read the row back to confirm it really changed.
 		$saved = Factory::get_notification( $notification->get_id() );
 		if ( ! $saved instanceof Notification || NotificationStatus::CANCELLED !== $saved->get_status() ) {
-			$this->redirect_with_error( __( 'We could not cancel that back in stock notification. Please try again.', 'woocommerce' ), $page );
+			return new \WP_Error( 'wc_bis_cancel_failed', __( 'We could not cancel that back in stock notification. Please try again.', 'woocommerce' ) );
 		}

 		$product_name = $notification->get_product_name();
-		if ( '' !== $product_name ) {
-			\wc_add_notice(
-				sprintf(
-					/* translators: %s: product name */
-					esc_html__( 'Back in stock notification for "%s" cancelled.', 'woocommerce' ),
-					esc_html( $product_name )
-				)
-			);
-		} else {
-			\wc_add_notice( esc_html__( 'Back in stock notification cancelled.', 'woocommerce' ) );
+		if ( '' === $product_name ) {
+			return __( 'Back in stock notification cancelled.', 'woocommerce' );
 		}

-		wp_safe_redirect( self::get_endpoint_url( $page ) );
-		exit;
+		/* translators: %s: product name */
+		return sprintf( __( 'Back in stock notification for "%s" cancelled.', 'woocommerce' ), $product_name );
 	}

 	/**
@@ -418,7 +551,7 @@ class MyAccountEndpoint {
 	 * @param int $page 1-indexed page to link to. Page 1 has no page segment.
 	 * @return string
 	 */
-	private static function get_endpoint_url( int $page = 1 ): string {
+	public static function get_endpoint_url( int $page = 1 ): string {
 		$value = $page > 1 ? (string) $page : '';
 		return \wc_get_endpoint_url( self::ENDPOINT, $value, \wc_get_page_permalink( 'myaccount' ) );
 	}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php
index fc3fd83e705..256d6d55c23 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/NotificationManagementService.php
@@ -36,6 +36,16 @@ class NotificationManagementService {
 	 */
 	public const RESEND_RATE_LIMIT_SECONDS = 60;

+	/**
+	 * Error code returned by {@see self::resend_verification_email()} when the notification is no longer pending.
+	 */
+	public const RESEND_ERROR_NOT_PENDING = 'wc_bis_resend_not_pending';
+
+	/**
+	 * Error code returned by {@see self::resend_verification_email()} when a send happened too recently.
+	 */
+	public const RESEND_ERROR_RATE_LIMITED = 'wc_bis_resend_rate_limited';
+
 	/**
 	 * Email manager.
 	 *
@@ -112,6 +122,13 @@ class NotificationManagementService {
 			return;
 		}

+		// Only the owner may resend a customer-linked notification. Bail silently on a
+		// mismatch so the response doesn't confirm that the id exists.
+		$owner_id = (int) $notification->get_user_id();
+		if ( $owner_id > 0 && is_user_logged_in() && get_current_user_id() !== $owner_id ) {
+			return;
+		}
+
 		$this->ensure_notice_session();

 		$redirect_url = $notification->get_product_permalink();
@@ -119,17 +136,37 @@ class NotificationManagementService {
 			$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' );
+		$result = $this->resend_verification_email( $notification );
+		if ( is_wp_error( $result ) ) {
+			$notice_type = self::RESEND_ERROR_RATE_LIMITED === $result->get_error_code() ? 'notice' : 'error';
+			wc_add_notice( esc_html( $result->get_error_message() ), $notice_type );
 			wp_safe_redirect( $redirect_url );
 			exit;
 		}

+		/* 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;
+	}
+
+	/**
+	 * Send the verification email for a pending notification again.
+	 *
+	 * Pure domain step: checks the status and the rate limit, records the send time,
+	 * and dispatches the email. Callers own authentication, nonces, notices and redirects.
+	 *
+	 * @param Notification $notification The notification to resend for.
+	 * @return true|\WP_Error True on send, or an error carrying one of the `RESEND_ERROR_*` codes.
+	 */
+	public function resend_verification_email( Notification $notification ) {
+		if ( NotificationStatus::PENDING !== $notification->get_status() ) {
+			return new \WP_Error( self::RESEND_ERROR_NOT_PENDING, __( 'This notification is already verified or cancelled.', 'woocommerce' ) );
+		}
+
 		$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;
+			return new \WP_Error( self::RESEND_ERROR_RATE_LIMITED, __( 'Please wait a moment before requesting another verification email.', 'woocommerce' ) );
 		}

 		// Persist the rate-limit timestamp before dispatching the email so two near-simultaneous
@@ -139,10 +176,7 @@ class NotificationManagementService {

 		$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;
+		return true;
 	}

 	/**
diff --git a/plugins/woocommerce/templates/myaccount/stock-notifications.php b/plugins/woocommerce/templates/myaccount/stock-notifications.php
index b4df8681371..f147fabf411 100644
--- a/plugins/woocommerce/templates/myaccount/stock-notifications.php
+++ b/plugins/woocommerce/templates/myaccount/stock-notifications.php
@@ -16,12 +16,14 @@
  * @package WooCommerce\Templates
  * @version 11.2.0
  *
- * @var array $notifications Array of Notification objects for the current user (one page).
- * @var bool  $has_items     Whether there are any notifications to render.
- * @var int   $current_page  1-indexed current page number.
- * @var int   $total_pages   Total number of pages of notifications.
- * @var int   $total_items   Total number of notifications across all pages.
- * @var int   $per_page      Notifications shown per page.
+ * @var array $notifications         Array of active Notification objects for the current user (one page).
+ * @var array $pending_notifications Array of Notification objects awaiting email confirmation (capped, not paginated).
+ * @var bool  $has_pending           Whether there are any pending notifications to render.
+ * @var bool  $has_items             Whether there are any notifications (pending or active) to render.
+ * @var int   $current_page          1-indexed current page number of the active table.
+ * @var int   $total_pages           Total number of pages of active notifications.
+ * @var int   $total_items           Total number of active notifications across all pages.
+ * @var int   $per_page              Active notifications shown per page.
  */

 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
@@ -40,9 +42,98 @@ $wp_button_class = wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_
 do_action( 'woocommerce_before_account_customer_stock_notifications', $has_items );
 ?>

-<?php if ( $has_items ) : ?>
+<?php if ( $has_pending ) : ?>

-	<table class="woocommerce-customer-stock-notifications-table woocommerce-MyAccount-customerStockNotifications shop_table shop_table_responsive">
+	<?php
+	/**
+	 * Fires before the pending (awaiting confirmation) stock notifications table is rendered on My Account.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param bool $has_pending Whether there are any pending notifications to render.
+	 */
+	do_action( 'woocommerce_before_account_customer_stock_notifications_pending', $has_pending );
+	?>
+
+	<h2 class="woocommerce-customer-stock-notifications-heading woocommerce-customer-stock-notifications-heading--pending"><?php esc_html_e( 'Awaiting confirmation', 'woocommerce' ); ?></h2>
+
+	<table class="woocommerce-customer-stock-notifications-table woocommerce-customer-stock-notifications-table--pending woocommerce-MyAccount-customerStockNotifications shop_table shop_table_responsive">
+		<caption class="screen-reader-text"><?php esc_html_e( 'Stock notifications awaiting confirmation', 'woocommerce' ); ?></caption>
+		<thead>
+			<tr>
+				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-product"><span class="nobr"><?php esc_html_e( 'Product', 'woocommerce' ); ?></span></th>
+				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-date"><span class="nobr"><?php esc_html_e( 'Date', 'woocommerce' ); ?></span></th>
+				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-actions"><span class="nobr"><?php esc_html_e( 'Actions', 'woocommerce' ); ?></span></th>
+			</tr>
+		</thead>
+		<tbody>
+		<?php foreach ( $pending_notifications as $notification ) : ?>
+			<?php
+			$product_name = MyAccountEndpoint::get_display_product_name( $notification );
+			$permalink    = $notification->get_product_permalink();
+			$variation    = $notification->get_product_formatted_variation_list( true );
+			$date_created = $notification->get_date_created();
+
+			$action_label_name = '' !== $product_name ? $product_name : __( 'an unavailable product', 'woocommerce' );
+			if ( '' !== $variation ) {
+				$action_label_name .= ' ' . $variation;
+			}
+			/* translators: %s: product name, followed by its variation attributes when the sign-up is for a variation. */
+			$resend_label = sprintf( __( 'Resend verification email for %s', 'woocommerce' ), $action_label_name );
+			/* translators: %s: product name, followed by its variation attributes when the sign-up is for a variation. */
+			$cancel_label = sprintf( __( 'Cancel stock notification for %s', 'woocommerce' ), $action_label_name );
+			?>
+			<tr class="woocommerce-customer-stock-notifications-table__row woocommerce-customer-stock-notifications-table__row--status-<?php echo esc_attr( (string) $notification->get_status() ); ?>">
+				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-product" data-title="<?php esc_attr_e( 'Product', 'woocommerce' ); ?>">
+					<?php if ( '' !== $product_name && '' !== $permalink ) : ?>
+						<a href="<?php echo esc_url( $permalink ); ?>"><?php echo esc_html( $product_name ); ?></a>
+					<?php elseif ( '' !== $product_name ) : ?>
+						<?php echo esc_html( $product_name ); ?>
+					<?php else : ?>
+						<?php esc_html_e( 'Product unavailable', 'woocommerce' ); ?>
+					<?php endif; ?>
+
+					<?php if ( '' !== $variation ) : ?>
+						<div class="description"><?php echo esc_html( $variation ); ?></div>
+					<?php endif; ?>
+				</td>
+				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-date" data-title="<?php esc_attr_e( 'Date', 'woocommerce' ); ?>">
+					<?php if ( $date_created ) : ?>
+						<time datetime="<?php echo esc_attr( $date_created->date( 'c' ) ); ?>"><?php echo esc_html( wc_format_datetime( $date_created ) ); ?></time>
+					<?php else : ?>
+						&mdash;
+					<?php endif; ?>
+				</td>
+				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-actions actions" data-title="<?php esc_attr_e( 'Actions', 'woocommerce' ); ?>">
+					<a href="<?php echo esc_url( MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_RESEND, (int) $notification->get_id(), $current_page ) ); ?>" class="woocommerce-button button woocommerce-customer-stock-notifications-action-link woocommerce-customer-stock-notifications-action-link--resend<?php echo esc_attr( $wp_button_class ); ?>" aria-label="<?php echo esc_attr( $resend_label ); ?>"><?php esc_html_e( 'Resend verification', 'woocommerce' ); ?></a>
+					<a href="<?php echo esc_url( MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_CANCEL, (int) $notification->get_id(), $current_page ) ); ?>" class="woocommerce-button button woocommerce-customer-stock-notifications-action-link woocommerce-customer-stock-notifications-action-link--cancel<?php echo esc_attr( $wp_button_class ); ?>" aria-label="<?php echo esc_attr( $cancel_label ); ?>"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></a>
+				</td>
+			</tr>
+		<?php endforeach; ?>
+		</tbody>
+	</table>
+
+	<?php
+	/**
+	 * Fires after the pending (awaiting confirmation) stock notifications table is rendered on My Account.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @param bool $has_pending Whether there were any pending notifications rendered.
+	 */
+	do_action( 'woocommerce_after_account_customer_stock_notifications_pending', $has_pending );
+	?>
+
+<?php endif; ?>
+
+<?php if ( ! empty( $notifications ) ) : ?>
+
+	<?php if ( $has_pending ) : ?>
+		<h2 class="woocommerce-customer-stock-notifications-heading woocommerce-customer-stock-notifications-heading--active"><?php esc_html_e( 'Active', 'woocommerce' ); ?></h2>
+	<?php endif; ?>
+
+	<table class="woocommerce-customer-stock-notifications-table woocommerce-customer-stock-notifications-table--active woocommerce-MyAccount-customerStockNotifications shop_table shop_table_responsive">
+		<caption class="screen-reader-text"><?php esc_html_e( 'Active stock notifications', 'woocommerce' ); ?></caption>
 		<thead>
 			<tr>
 				<th scope="col" class="woocommerce-customer-stock-notifications-table__header woocommerce-customer-stock-notifications-table__header-product"><span class="nobr"><?php esc_html_e( 'Product', 'woocommerce' ); ?></span></th>
@@ -94,13 +185,7 @@ do_action( 'woocommerce_before_account_customer_stock_notifications', $has_items
 				</td>
 				<td class="woocommerce-customer-stock-notifications-table__cell woocommerce-customer-stock-notifications-table__cell-actions actions" data-title="<?php esc_attr_e( 'Actions', 'woocommerce' ); ?>">
 					<?php if ( MyAccountEndpoint::is_cancellable( $notification ) ) : ?>
-						<form method="post" action="<?php echo esc_url( wc_get_endpoint_url( MyAccountEndpoint::ENDPOINT, '', wc_get_page_permalink( 'myaccount' ) ) ); ?>" class="woocommerce-customer-stock-notifications-cancel-form">
-							<input type="hidden" name="<?php echo esc_attr( MyAccountEndpoint::CANCEL_ACTION ); ?>" value="1" />
-							<input type="hidden" name="notification_id" value="<?php echo esc_attr( (string) $notification->get_id() ); ?>" />
-							<input type="hidden" name="notifications_page" value="<?php echo esc_attr( (string) $current_page ); ?>" />
-							<?php wp_nonce_field( MyAccountEndpoint::get_cancel_nonce_action( (int) $notification->get_id() ) ); ?>
-							<button type="submit" class="woocommerce-button button<?php echo esc_attr( $wp_button_class ); ?>" aria-label="<?php echo esc_attr( $cancel_label ); ?>"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></button>
-						</form>
+						<a href="<?php echo esc_url( MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_CANCEL, (int) $notification->get_id(), $current_page ) ); ?>" class="woocommerce-button button woocommerce-customer-stock-notifications-action-link woocommerce-customer-stock-notifications-action-link--cancel<?php echo esc_attr( $wp_button_class ); ?>" aria-label="<?php echo esc_attr( $cancel_label ); ?>"><?php esc_html_e( 'Cancel', 'woocommerce' ); ?></a>
 					<?php else : ?>
 						&mdash;
 					<?php endif; ?>
@@ -120,7 +205,7 @@ do_action( 'woocommerce_before_account_customer_stock_notifications', $has_items
 	?>

 	<?php if ( $total_pages > 1 ) : ?>
-		<div class="woocommerce-pagination woocommerce-pagination--without-numbers woocommerce-Pagination">
+		<div class="woocommerce-pagination woocommerce-pagination--without-numbers woocommerce-Pagination" role="navigation" aria-label="<?php esc_attr_e( 'Active stock notifications pagination', 'woocommerce' ); ?>">
 			<?php if ( 1 !== $current_page ) : ?>
 				<a class="woocommerce-button woocommerce-button--previous woocommerce-Button woocommerce-Button--previous button<?php echo esc_attr( $wp_button_class ); ?>" href="<?php echo esc_url( wc_get_endpoint_url( MyAccountEndpoint::ENDPOINT, (string) ( $current_page - 1 ), wc_get_page_permalink( 'myaccount' ) ) ); ?>"><?php esc_html_e( 'Previous', 'woocommerce' ); ?></a>
 			<?php endif; ?>
@@ -131,7 +216,9 @@ do_action( 'woocommerce_before_account_customer_stock_notifications', $has_items
 		</div>
 	<?php endif; ?>

-<?php else : ?>
+<?php endif; ?>
+
+<?php if ( ! $has_items ) : ?>

 	<?php wc_print_notice( esc_html__( "You haven't signed up for any back-in-stock notifications yet.", 'woocommerce' ) . ' <a class="woocommerce-Button wc-forward button' . esc_attr( $wp_button_class ) . '" href="' . esc_url( apply_filters( 'woocommerce_return_to_shop_redirect', wc_get_page_permalink( 'shop' ) ) ) . '">' . esc_html__( 'Browse products', 'woocommerce' ) . '</a>', 'notice' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment ?>

diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts
index ecdcb6d2159..ab179f9c7d3 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/my-account.spec.ts
@@ -18,6 +18,8 @@ import { logInFromMyAccount } from '../../utils/login';

 const MY_ACCOUNT_ENDPOINT = 'my-account/stock-notifications/';
 const TABLE = '.woocommerce-customer-stock-notifications-table';
+const PENDING_TABLE = `${ TABLE }--pending`;
+const ACTIVE_TABLE = `${ TABLE }--active`;

 /**
  * A customer account owned by a single test.
@@ -114,26 +116,65 @@ test.describe(
 						} )
 					).toBeVisible();

-					// Both products appear as rows.
-					const table = page.locator( TABLE );
-					await expect( table ).toBeVisible();
+					// The unconfirmed signup sits in its own "Awaiting confirmation" table
+					// with Resend verification + Cancel actions.
 					await expect(
-						table.getByRole( 'link', {
-							name: product.name,
-							exact: true,
+						page.getByRole( 'heading', {
+							name: 'Awaiting confirmation',
 						} )
 					).toBeVisible();
+					const pendingTable = page.locator( PENDING_TABLE );
+					await expect( pendingTable ).toBeVisible();
+					const pendingRow = pendingTable
+						.locator( 'tbody tr' )
+						.filter( {
+							has: page.getByRole( 'link', {
+								name: secondProduct.name,
+								exact: true,
+							} ),
+						} );
+					await expect( pendingRow ).toBeVisible();
 					await expect(
-						table.getByRole( 'link', {
-							name: secondProduct.name,
-							exact: true,
+						pendingRow.getByRole( 'link', {
+							name: 'Resend verification email',
 						} )
 					).toBeVisible();
+					await expect(
+						pendingRow.getByRole( 'link', { name: 'Cancel' } )
+					).toBeVisible();
+					await expect(
+						pendingTable.locator( 'tbody tr' )
+					).toHaveCount( 1 );

-					// Exactly two rows — both signups are PENDING/ACTIVE so neither is filtered out.
-					await expect( table.locator( 'tbody tr' ) ).toHaveCount(
-						2
-					);
+					// The confirmed signup sits in the "Active" table.
+					await expect(
+						page.getByRole( 'heading', {
+							name: 'Active',
+							exact: true,
+						} )
+					).toBeVisible();
+					const activeTable = page.locator( ACTIVE_TABLE );
+					await expect( activeTable ).toBeVisible();
+					const activeRow = activeTable
+						.locator( 'tbody tr' )
+						.filter( {
+							has: page.getByRole( 'link', {
+								name: product.name,
+								exact: true,
+							} ),
+						} );
+					await expect( activeRow ).toBeVisible();
+					await expect(
+						activeRow.getByRole( 'link', { name: 'Cancel' } )
+					).toBeVisible();
+					await expect(
+						activeRow.getByRole( 'link', {
+							name: 'Resend verification email',
+						} )
+					).toHaveCount( 0 );
+					await expect(
+						activeTable.locator( 'tbody tr' )
+					).toHaveCount( 1 );
 				} finally {
 					await restApi.delete(
 						`${ WC_API_PATH }/products/${ secondProduct.id }`,
@@ -146,6 +187,84 @@ test.describe(
 				}
 			} );

+			test( 'resend email click sends the verification email and stays on the tab', async ( {
+				page,
+				baseURL,
+				product,
+				restApi,
+			} ) => {
+				const customer = await createTestCustomer( restApi );
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: true,
+				} );
+
+				try {
+					await page.goto( 'my-account/' );
+					await logInFromMyAccount(
+						page,
+						customer.username,
+						customer.password
+					);
+
+					await page.goto( product.permalink );
+					await signUpOnProductPage( page );
+
+					await page.goto( MY_ACCOUNT_ENDPOINT );
+
+					// Only a pending signup exists, so the pending table renders on its
+					// own — no active table and no "nothing signed up" notice.
+					await expect( page.locator( PENDING_TABLE ) ).toBeVisible();
+					await expect( page.locator( ACTIVE_TABLE ) ).toHaveCount(
+						0
+					);
+					await expect(
+						page.getByText(
+							"You haven't signed up for any back-in-stock notifications yet."
+						)
+					).toHaveCount( 0 );
+
+					const row = page
+						.locator( `${ PENDING_TABLE } tbody tr` )
+						.filter( {
+							has: page.getByRole( 'link', {
+								name: product.name,
+								exact: true,
+							} ),
+						} );
+					await expect( row ).toBeVisible();
+
+					await row
+						.getByRole( 'link', {
+							name: 'Resend verification email',
+						} )
+						.click();
+
+					// The redirect after the GET lands us back on the clean tab URL with a notice.
+					await expect(
+						page.getByRole( 'heading', {
+							name: 'Stock notifications',
+						} )
+					).toBeVisible();
+					await expect( page ).toHaveURL( MY_ACCOUNT_ENDPOINT );
+					await expect(
+						page.getByText( /Verification email sent to/ )
+					).toBeVisible();
+
+					// The row is still pending and still offers the actions.
+					await expect(
+						row.getByRole( 'link', {
+							name: 'Resend verification email',
+						} )
+					).toBeVisible();
+				} finally {
+					await restApi.delete(
+						`${ WC_API_PATH }/customers/${ customer.id }`,
+						{ force: true }
+					);
+				}
+			} );
+
 			test( 'cancel click removes the row from the My Account list', async ( {
 				page,
 				baseURL,
@@ -171,17 +290,20 @@ test.describe(

 					await page.goto( MY_ACCOUNT_ENDPOINT );

-					const row = page.locator( `${ TABLE } tbody tr` ).filter( {
-						has: page.getByRole( 'link', {
-							name: product.name,
-							exact: true,
-						} ),
-					} );
+					// Double opt-in is on, so the signup lands in the pending table.
+					const row = page
+						.locator( `${ PENDING_TABLE } tbody tr` )
+						.filter( {
+							has: page.getByRole( 'link', {
+								name: product.name,
+								exact: true,
+							} ),
+						} );
 					await expect( row ).toBeVisible();

-					await row.getByRole( 'button', { name: 'Cancel' } ).click();
+					await row.getByRole( 'link', { name: 'Cancel' } ).click();

-					// The redirect after the POST lands us back on the same tab.
+					// The redirect after the GET lands us back on the clean tab URL.
 					await expect(
 						page.getByRole( 'heading', {
 							name: 'Stock notifications',
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php
index 449fc83b4b3..6c8909d207d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/MyAccountEndpointTests.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationCancell
 use Automattic\WooCommerce\Internal\StockNotifications\Enums\NotificationStatus;
 use Automattic\WooCommerce\Internal\StockNotifications\Factory;
 use Automattic\WooCommerce\Internal\StockNotifications\Frontend\MyAccountEndpoint;
+use Automattic\WooCommerce\Internal\StockNotifications\Frontend\NotificationManagementService;
 use Automattic\WooCommerce\Internal\StockNotifications\Notification;
 use Automattic\WooCommerce\Tests\Internal\StockNotifications\StockNotificationsFeatureTrait;
 use WC_Helper_Product;
@@ -50,7 +51,7 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		\wp_set_current_user( 0 );
 		delete_option( MyAccountEndpoint::ENDPOINT_OPTION );
 		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-		$_POST = array();
+		$_GET = array();
 		global $wp;
 		if ( isset( $wp->query_vars[ MyAccountEndpoint::ENDPOINT ] ) ) {
 			unset( $wp->query_vars[ MyAccountEndpoint::ENDPOINT ] );
@@ -101,17 +102,31 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Run the cancel handler expecting it to redirect, and assert the target.
+	 * Build an endpoint wired to the given management service, or the real one.
+	 *
+	 * @param NotificationManagementService|null $service Service to inject; defaults to the container's.
+	 * @return MyAccountEndpoint
+	 */
+	private function make_endpoint( ?NotificationManagementService $service = null ): MyAccountEndpoint {
+		$endpoint = new MyAccountEndpoint();
+		$endpoint->init( $service ?? wc_get_container()->get( NotificationManagementService::class ) );
+
+		return $endpoint;
+	}
+
+	/**
+	 * Run the action handler expecting it to redirect, and assert the target.
 	 *
 	 * `capture_redirect()` throws in place of the `exit` that follows the redirect
 	 * in production, so the call has to be wrapped.
 	 *
-	 * @param int $expected_page 1-indexed page the redirect should land on.
+	 * @param NotificationManagementService|null $service       Service to inject; defaults to the container's.
+	 * @param int                                $expected_page 1-indexed page the redirect should land on.
 	 */
-	private function run_cancel_expecting_redirect( int $expected_page = 1 ): void {
+	private function run_action_expecting_redirect( ?NotificationManagementService $service = null, int $expected_page = 1 ): void {
 		try {
-			( new MyAccountEndpoint() )->maybe_handle_cancel();
-			$this->fail( 'Expected the cancel handler to redirect.' );
+			$this->make_endpoint( $service )->maybe_handle_action();
+			$this->fail( 'Expected the action handler to redirect.' );
 		} catch ( \RuntimeException $e ) {
 			unset( $e );
 		}
@@ -183,7 +198,7 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		$user_b = $this->factory->user->create( array( 'role' => 'customer' ) );

 		$this->create_notification( $user_a, NotificationStatus::ACTIVE );
-		$b_notification = $this->create_notification( $user_b, NotificationStatus::PENDING );
+		$b_notification = $this->create_notification( $user_b, NotificationStatus::ACTIVE );

 		\wp_set_current_user( $user_b );
 		$endpoint = new MyAccountEndpoint();
@@ -244,23 +259,183 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * SENT and CANCELLED notifications are filtered out — only ACTIVE/PENDING render in the My Account view.
+	 * Only ACTIVE notifications render in the main My Account table. PENDING rows
+	 * have their own table, and SENT / CANCELLED rows are filtered out entirely.
 	 */
-	public function test_get_current_user_notifications_page_excludes_sent_and_cancelled(): void {
+	public function test_get_current_user_notifications_page_lists_active_only(): void {
 		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
 		\wp_set_current_user( $user_id );

-		$active  = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
-		$pending = $this->create_notification( $user_id, NotificationStatus::PENDING );
+		$active = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		$this->create_notification( $user_id, NotificationStatus::PENDING );
 		$this->create_notification( $user_id, NotificationStatus::SENT );
 		$this->create_notification( $user_id, NotificationStatus::CANCELLED );

 		$endpoint = new MyAccountEndpoint();
 		$page     = $endpoint->get_current_user_notifications_page( 1, MyAccountEndpoint::DEFAULT_PER_PAGE );

-		$this->assertSame( 2, $page['total_items'] );
-		$visible_ids = array_map( static fn ( $n ) => $n->get_id(), $page['notifications'] );
-		$this->assertEqualsCanonicalizing( array( $active->get_id(), $pending->get_id() ), $visible_ids );
+		$this->assertSame( 1, $page['total_items'] );
+		$this->assertSame( array( $active->get_id() ), array_map( static fn ( $n ) => $n->get_id(), $page['notifications'] ) );
+	}
+
+	/**
+	 * Pagination counts only ACTIVE rows, so pending sign-ups never shift the active pages.
+	 */
+	public function test_get_current_user_notifications_page_paginates_active_only(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		for ( $i = 0; $i < 3; $i++ ) {
+			$this->create_notification( $user_id, NotificationStatus::ACTIVE );
+			$this->create_notification( $user_id, NotificationStatus::PENDING );
+		}
+
+		$endpoint = new MyAccountEndpoint();
+		$page     = $endpoint->get_current_user_notifications_page( 1, 2 );
+
+		$this->assertSame( 3, $page['total_items'] );
+		$this->assertSame( 2, $page['total_pages'] );
+		$this->assertCount( 2, $page['notifications'] );
+		foreach ( $page['notifications'] as $notification ) {
+			$this->assertSame( NotificationStatus::ACTIVE, $notification->get_status() );
+		}
+	}
+
+	/**
+	 * The pending fetcher returns only the current user's PENDING rows, newest first.
+	 */
+	public function test_get_current_user_pending_notifications_returns_users_pending_rows_newest_first(): void {
+		$user_a = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$user_b = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$first  = $this->create_notification( $user_a, NotificationStatus::PENDING );
+		$second = $this->create_notification( $user_a, NotificationStatus::PENDING );
+		$this->create_notification( $user_a, NotificationStatus::ACTIVE );
+		$this->create_notification( $user_a, NotificationStatus::SENT );
+		$this->create_notification( $user_a, NotificationStatus::CANCELLED );
+		$this->create_notification( $user_b, NotificationStatus::PENDING );
+
+		\wp_set_current_user( $user_a );
+		$endpoint = new MyAccountEndpoint();
+		$pending  = $endpoint->get_current_user_pending_notifications( MyAccountEndpoint::DEFAULT_PER_PAGE );
+
+		$this->assertSame( array( $second->get_id(), $first->get_id() ), array_map( static fn ( $n ) => $n->get_id(), $pending ) );
+		foreach ( $pending as $notification ) {
+			$this->assertSame( $user_a, (int) $notification->get_user_id() );
+			$this->assertSame( NotificationStatus::PENDING, $notification->get_status() );
+		}
+	}
+
+	/**
+	 * The pending fetcher caps the result set at the requested limit.
+	 */
+	public function test_get_current_user_pending_notifications_respects_limit(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		for ( $i = 0; $i < 4; $i++ ) {
+			$this->create_notification( $user_id, NotificationStatus::PENDING );
+		}
+
+		$endpoint = new MyAccountEndpoint();
+
+		$this->assertCount( 2, $endpoint->get_current_user_pending_notifications( 2 ) );
+		// A non-positive limit is clamped to 1 rather than dropping the LIMIT clause.
+		$this->assertCount( 1, $endpoint->get_current_user_pending_notifications( 0 ) );
+	}
+
+	/**
+	 * Anonymous visitors get no pending rows without querying by user-supplied ids.
+	 */
+	public function test_get_current_user_pending_notifications_returns_empty_for_anonymous(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$this->create_notification( $user_id, NotificationStatus::PENDING );
+
+		\wp_set_current_user( 0 );
+
+		$endpoint = new MyAccountEndpoint();
+		$this->assertSame( array(), $endpoint->get_current_user_pending_notifications( MyAccountEndpoint::DEFAULT_PER_PAGE ) );
+	}
+
+	/**
+	 * The rendered endpoint splits pending and active rows into their own tables, links
+	 * each pending row to a resend URL that returns to the endpoint, and honours the
+	 * pending-limit filter.
+	 */
+	public function test_render_endpoint_splits_pending_and_active_tables(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+
+		$active    = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+		$pending_1 = $this->create_notification( $user_id, NotificationStatus::PENDING );
+		$pending_2 = $this->create_notification( $user_id, NotificationStatus::PENDING );
+
+		$limit_filter = static fn () => 1;
+		add_filter( 'woocommerce_account_customer_stock_notifications_pending_limit', $limit_filter );
+		try {
+			ob_start();
+			wc_get_container()->get( MyAccountEndpoint::class )->render_endpoint( 1 );
+			$html = ob_get_clean();
+		} finally {
+			remove_filter( 'woocommerce_account_customer_stock_notifications_pending_limit', $limit_filter );
+		}
+
+		$this->assertStringContainsString( 'woocommerce-customer-stock-notifications-table--pending', $html );
+		$this->assertStringContainsString( 'woocommerce-customer-stock-notifications-table--active', $html );
+		$this->assertStringContainsString( 'Awaiting confirmation', $html );
+		$this->assertStringContainsString( 'woocommerce-customer-stock-notifications-heading--active', $html );
+		$this->assertStringNotContainsString( "You haven't signed up", $html );
+
+		// Only the newest pending row survives the limit of 1.
+		$this->assertStringContainsString( 'notification_id=' . $pending_2->get_id() . '&', $html );
+		$this->assertStringNotContainsString( 'notification_id=' . $pending_1->get_id() . '&', $html );
+		$this->assertStringContainsString( 'notification_id=' . $active->get_id() . '&', $html );
+
+		// Only the pending row offers a Resend link; both actions point back at the endpoint.
+		$this->assertSame( 1, substr_count( $html, 'woocommerce-customer-stock-notifications-action-link--resend' ) );
+		$this->assertSame( 2, substr_count( $html, 'woocommerce-customer-stock-notifications-action-link--cancel' ) );
+		$this->assertStringContainsString( esc_url( MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_RESEND, $pending_2->get_id() ) ), $html );
+		$this->assertStringContainsString( esc_url( MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_CANCEL, $active->get_id() ) ), $html );
+		$this->assertStringNotContainsString( MyAccountEndpoint::ACTION_FIELD . '=' . MyAccountEndpoint::ACTION_RESEND . '&#038;notification_id=' . $active->get_id(), $html );
+		$this->assertStringContainsString( 'aria-label="Resend verification email for ', $html );
+		$this->assertStringNotContainsString( 'wc_bis_resend_notification=', $html );
+	}
+
+	/**
+	 * A customer with only pending sign-ups sees the pending table, not the empty state.
+	 */
+	public function test_render_endpoint_with_only_pending_rows_hides_empty_state(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$this->create_notification( $user_id, NotificationStatus::PENDING );
+
+		ob_start();
+		wc_get_container()->get( MyAccountEndpoint::class )->render_endpoint( 1 );
+		$html = ob_get_clean();
+
+		$this->assertStringContainsString( 'woocommerce-customer-stock-notifications-table--pending', $html );
+		$this->assertStringNotContainsString( 'woocommerce-customer-stock-notifications-table--active', $html );
+		$this->assertStringNotContainsString( 'woocommerce-customer-stock-notifications-heading--active', $html );
+		$this->assertStringNotContainsString( "You haven't signed up", $html );
+	}
+
+	/**
+	 * With no pending rows the active table renders without the "Active" heading,
+	 * so a store without double opt-in sees the single table it always had.
+	 */
+	public function test_render_endpoint_without_pending_rows_omits_active_heading(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		ob_start();
+		wc_get_container()->get( MyAccountEndpoint::class )->render_endpoint( 1 );
+		$html = ob_get_clean();
+
+		$this->assertStringNotContainsString( 'woocommerce-customer-stock-notifications-table--pending', $html );
+		$this->assertStringContainsString( 'woocommerce-customer-stock-notifications-table--active', $html );
+		$this->assertStringNotContainsString( 'Awaiting confirmation', $html );
+		$this->assertStringNotContainsString( 'woocommerce-customer-stock-notifications-heading--active', $html );
 	}

 	/**
@@ -290,9 +465,9 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		\wp_set_current_user( $user_id );
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

-		$this->simulate_cancel_request( $notification->get_id(), true );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), true );

-		$this->run_cancel_expecting_redirect();
+		$this->run_action_expecting_redirect();

 		$this->assertEmpty( \wc_get_notices( 'error' ) );

@@ -317,9 +492,9 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		\wp_set_current_user( $user_id );
 		$notification = $this->create_notification( $user_id, $status );

-		$this->simulate_cancel_request( $notification->get_id(), true );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), true );

-		$this->run_cancel_expecting_redirect();
+		$this->run_action_expecting_redirect();

 		$this->assertCount( 1, \wc_get_notices( 'error' ) );

@@ -329,7 +504,7 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * The cancellable statuses are the ones the My Account view lists.
+	 * Both PENDING and ACTIVE rows keep their Cancel button; SENT and CANCELLED never get one.
 	 */
 	public function test_is_cancellable_matches_listed_statuses(): void {
 		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
@@ -349,9 +524,9 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		\wp_set_current_user( $user_id );
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

-		$this->simulate_cancel_request( $notification->get_id(), false );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), false );

-		$this->run_cancel_expecting_redirect();
+		$this->run_action_expecting_redirect();

 		$this->assertCount( 1, \wc_get_notices( 'error' ) );

@@ -370,19 +545,12 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		$notification_a = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
 		$notification_b = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

-		// Nonce was minted against A's id, but we POST it alongside B's id.
-		$nonce = \wp_create_nonce( MyAccountEndpoint::get_cancel_nonce_action( $notification_a->get_id() ) );
-
-		$this->set_endpoint_query_var();
-		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
-		$_POST = array(
-			MyAccountEndpoint::CANCEL_ACTION => '1',
-			'notification_id'                => (string) $notification_b->get_id(),
-			'_wpnonce'                       => $nonce,
-		);
-		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
+		// Nonce was minted against A's id, but the request carries B's id.
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification_b->get_id(), true );
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$_GET['_wpnonce'] = \wp_create_nonce( MyAccountEndpoint::get_nonce_action( MyAccountEndpoint::ACTION_CANCEL, $notification_a->get_id() ) );

-		$this->run_cancel_expecting_redirect();
+		$this->run_action_expecting_redirect();

 		$this->assertCount( 1, \wc_get_notices( 'error' ) );

@@ -405,10 +573,10 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
 		$id           = $notification->get_id();

-		$this->simulate_cancel_request( $id, true );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $id, true );
 		$notification->delete( true );

-		$this->run_cancel_expecting_redirect();
+		$this->run_action_expecting_redirect();

 		$this->assertCount( 1, \wc_get_notices( 'error' ) );
 	}
@@ -422,12 +590,12 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 		\wp_set_current_user( $user_id );
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

-		$this->simulate_cancel_request( $notification->get_id(), true );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), true );

 		$neutralize_update = array( $this, 'neutralize_notification_update' );
 		add_filter( 'query', $neutralize_update );
 		try {
-			$this->run_cancel_expecting_redirect();
+			$this->run_action_expecting_redirect();
 		} finally {
 			remove_filter( 'query', $neutralize_update );
 		}
@@ -461,7 +629,8 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 	 * User A cannot cancel user B's notification even when the nonce validates against B's action name
 	 * (WordPress nonces bind to the current user, so this effectively asserts the ownership check).
 	 *
-	 * This one stays silent: an error notice would confirm that the id exists.
+	 * The response is the same "no longer exists" error a missing id gets, so it
+	 * doesn't confirm that the notification exists.
 	 */
 	public function test_cancel_does_not_touch_other_users_notification(): void {
 		$user_a = $this->factory->user->create( array( 'role' => 'customer' ) );
@@ -471,12 +640,11 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {

 		// User A logs in and tries to cancel B's notification.
 		\wp_set_current_user( $user_a );
-		$this->simulate_cancel_request( $notification_b->get_id(), true );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification_b->get_id(), true );

-		( new MyAccountEndpoint() )->maybe_handle_cancel();
+		$this->run_action_expecting_redirect();

-		$this->assertNull( $this->redirect_location );
-		$this->assertEmpty( \wc_get_notices( 'error' ) );
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );

 		$updated_b = Factory::get_notification( $notification_b->get_id() );
 		$this->assertInstanceOf( Notification::class, $updated_b );
@@ -484,22 +652,177 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * An anonymous POST with a cancel payload is silently dropped.
+	 * An anonymous request carrying a cancel link's query args is silently dropped.
 	 */
 	public function test_cancel_ignored_when_anonymous(): void {
 		$user_id      = $this->factory->user->create( array( 'role' => 'customer' ) );
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

 		\wp_set_current_user( 0 );
-		$this->simulate_cancel_request( $notification->get_id(), true );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), true );

-		( new MyAccountEndpoint() )->maybe_handle_cancel();
+		$this->make_endpoint()->maybe_handle_action();

 		$updated = Factory::get_notification( $notification->get_id() );
 		$this->assertInstanceOf( Notification::class, $updated );
 		$this->assertSame( NotificationStatus::ACTIVE, $updated->get_status() );
 	}

+	/**
+	 * A valid resend link from the owner hands the notification to the service and reports success.
+	 */
+	public function test_resend_with_valid_nonce_sends_verification_email(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::PENDING );
+
+		$service = $this->createMock( NotificationManagementService::class );
+		$service
+			->expects( $this->once() )
+			->method( 'resend_verification_email' )
+			->with(
+				$this->callback(
+					static function ( $arg ) use ( $notification ) {
+						return $arg instanceof Notification && $arg->get_id() === $notification->get_id();
+					}
+				)
+			)
+			->willReturn( true );
+
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_RESEND, $notification->get_id(), true );
+
+		$this->run_action_expecting_redirect( $service );
+
+		$this->assertEmpty( \wc_get_notices( 'error' ) );
+		$this->assertCount( 1, \wc_get_notices( 'success' ) );
+	}
+
+	/**
+	 * A resend the service refuses (already verified, rate limited) surfaces its message as an error notice.
+	 */
+	public function test_resend_reports_service_error(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::PENDING );
+
+		$service = $this->createMock( NotificationManagementService::class );
+		$service
+			->method( 'resend_verification_email' )
+			->willReturn( new \WP_Error( NotificationManagementService::RESEND_ERROR_RATE_LIMITED, 'Please wait.' ) );
+
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_RESEND, $notification->get_id(), true );
+
+		$this->run_action_expecting_redirect( $service );
+
+		$this->assertEmpty( \wc_get_notices( 'success' ) );
+		$errors = \wc_get_notices( 'error' );
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Please wait.', $errors[0]['notice'] );
+	}
+
+	/**
+	 * A nonce minted for the row's Cancel link does not validate its Resend link.
+	 */
+	public function test_resend_rejects_nonce_minted_for_cancel(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::PENDING );
+
+		$service = $this->createMock( NotificationManagementService::class );
+		$service
+			->expects( $this->never() )
+			->method( 'resend_verification_email' );
+
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_RESEND, $notification->get_id(), true );
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$_GET['_wpnonce'] = \wp_create_nonce( MyAccountEndpoint::get_nonce_action( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id() ) );
+
+		$this->run_action_expecting_redirect( $service );
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+	}
+
+	/**
+	 * User A cannot trigger a verification email for user B's notification.
+	 */
+	public function test_resend_does_not_touch_other_users_notification(): void {
+		$user_a = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$user_b = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$notification_b = $this->create_notification( $user_b, NotificationStatus::PENDING );
+
+		$service = $this->createMock( NotificationManagementService::class );
+		$service
+			->expects( $this->never() )
+			->method( 'resend_verification_email' );
+
+		\wp_set_current_user( $user_a );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_RESEND, $notification_b->get_id(), true );
+
+		$this->run_action_expecting_redirect( $service );
+
+		$this->assertCount( 1, \wc_get_notices( 'error' ) );
+	}
+
+	/**
+	 * A valid action link that lands on any page other than the stock notifications
+	 * endpoint is dropped, so the handler never widens to the whole front end.
+	 */
+	public function test_action_ignored_off_the_endpoint(): void {
+		global $wp;
+
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), true );
+		unset( $wp->query_vars[ MyAccountEndpoint::ENDPOINT ] );
+
+		$this->make_endpoint()->maybe_handle_action();
+
+		$this->assertNull( $this->redirect_location );
+		$this->assertEmpty( \wc_get_notices() );
+
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated->get_status() );
+	}
+
+	/**
+	 * An unknown action value is dropped before any nonce or database work.
+	 */
+	public function test_unknown_action_is_ignored(): void {
+		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		\wp_set_current_user( $user_id );
+		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );
+
+		$this->simulate_action_request( 'delete', $notification->get_id(), true );
+
+		$this->make_endpoint()->maybe_handle_action();
+
+		$this->assertNull( $this->redirect_location );
+		$this->assertEmpty( \wc_get_notices() );
+
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertSame( NotificationStatus::ACTIVE, $updated->get_status() );
+	}
+
+	/**
+	 * Action links point at the endpoint and carry the action, the id, and a nonce scoped to both.
+	 */
+	public function test_get_action_url_carries_action_id_and_scoped_nonce(): void {
+		$url = MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_RESEND, 42 );
+
+		$this->assertStringStartsWith( MyAccountEndpoint::get_endpoint_url(), $url );
+		$this->assertStringContainsString( MyAccountEndpoint::ACTION_FIELD . '=' . MyAccountEndpoint::ACTION_RESEND, $url );
+		$this->assertStringContainsString( 'notification_id=42', $url );
+
+		// wp_nonce_url() returns an HTML-escaped URL (`&amp;`), so decode it before parsing the query.
+		$query = array();
+		\wp_parse_str( (string) \wp_parse_url( html_entity_decode( $url ), PHP_URL_QUERY ), $query );
+		$this->assertNotFalse( \wp_verify_nonce( $query['_wpnonce'], MyAccountEndpoint::get_nonce_action( MyAccountEndpoint::ACTION_RESEND, 42 ) ) );
+		$this->assertFalse( \wp_verify_nonce( $query['_wpnonce'], MyAccountEndpoint::get_nonce_action( MyAccountEndpoint::ACTION_CANCEL, 42 ) ) );
+	}
+
 	/**
 	 * The menu filter adds the Stock notifications item.
 	 */
@@ -575,55 +898,79 @@ class MyAccountEndpointTests extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should return the customer to the page they cancelled from.
+	 * @testdox Should return the customer to the page they acted from.
 	 */
-	public function test_cancel_returns_to_the_page_it_was_submitted_from(): void {
+	public function test_action_returns_to_the_page_it_was_triggered_from(): void {
 		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
 		\wp_set_current_user( $user_id );
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

-		$this->simulate_cancel_request( $notification->get_id(), true, 3 );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), true, 3 );
+
+		$this->run_action_expecting_redirect( null, 3 );

-		$this->run_cancel_expecting_redirect( 3 );
+		$updated = Factory::get_notification( $notification->get_id() );
+		$this->assertSame( NotificationStatus::CANCELLED, $updated->get_status() );
 	}

 	/**
-	 * @testdox Should keep the customer on their page when the cancel fails.
+	 * @testdox Should keep the customer on their page when the action fails.
 	 */
-	public function test_failed_cancel_returns_to_the_page_it_was_submitted_from(): void {
+	public function test_failed_action_returns_to_the_page_it_was_triggered_from(): void {
 		$user_id = $this->factory->user->create( array( 'role' => 'customer' ) );
 		\wp_set_current_user( $user_id );
 		$notification = $this->create_notification( $user_id, NotificationStatus::ACTIVE );

-		$this->simulate_cancel_request( $notification->get_id(), false, 2 );
+		$this->simulate_action_request( MyAccountEndpoint::ACTION_CANCEL, $notification->get_id(), false, 2 );

-		$this->run_cancel_expecting_redirect( 2 );
+		$this->run_action_expecting_redirect( null, 2 );

 		$this->assertNotEmpty( \wc_get_notices( 'error' ), 'An invalid nonce should queue an error notice.' );
 	}

 	/**
-	 * Helper: fake the global state needed for `maybe_handle_cancel()` to proceed past guards.
+	 * @testdox Should carry the page on action links so the redirect can return to it.
+	 */
+	public function test_get_action_url_carries_the_page(): void {
+		$this->assertStringNotContainsString(
+			'notifications_page',
+			MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_CANCEL, 42 ),
+			'Page 1 is the default, so it should not be spelled out in the link.'
+		);
+
+		$this->assertStringContainsString(
+			'notifications_page=3',
+			html_entity_decode( MyAccountEndpoint::get_action_url( MyAccountEndpoint::ACTION_CANCEL, 42, 3 ) )
+		);
+	}
+
+	/**
+	 * Helper: fake the global state needed for `maybe_handle_action()` to proceed past guards,
+	 * as if the customer followed a row action link.
 	 *
-	 * @param int  $notification_id Notification id.
-	 * @param bool $valid_nonce     Whether to mint a nonce that validates for this id.
-	 * @param int  $page            1-indexed page the cancel was submitted from.
+	 * @param string $action          One of the `MyAccountEndpoint::ACTION_*` values.
+	 * @param int    $notification_id Notification id.
+	 * @param bool   $valid_nonce     Whether to mint a nonce that validates for this action and id.
+	 * @param int    $page            1-indexed page the link was rendered on.
 	 */
-	private function simulate_cancel_request( int $notification_id, bool $valid_nonce, int $page = 1 ): void {
+	private function simulate_action_request( string $action, int $notification_id, bool $valid_nonce, int $page = 1 ): void {
 		$this->set_endpoint_query_var();

 		$nonce = $valid_nonce
-			? \wp_create_nonce( MyAccountEndpoint::get_cancel_nonce_action( $notification_id ) )
+			? \wp_create_nonce( MyAccountEndpoint::get_nonce_action( $action, $notification_id ) )
 			: 'clearly-not-a-valid-nonce';

-		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
-		$_POST = array(
-			MyAccountEndpoint::CANCEL_ACTION => '1',
-			'notification_id'                => (string) $notification_id,
-			'notifications_page'             => (string) $page,
-			'_wpnonce'                       => $nonce,
+		// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.WP.GlobalVariablesOverride.Prohibited
+		$_GET = array(
+			MyAccountEndpoint::ACTION_FIELD => $action,
+			'notification_id'               => (string) $notification_id,
+			'_wpnonce'                      => $nonce,
 		);
-		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		if ( $page > 1 ) {
+			$_GET['notifications_page'] = (string) $page; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		}
+		// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.WP.GlobalVariablesOverride.Prohibited
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php
index 8bee8865016..5c9771e1f2c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/NotificationManagementServiceTests.php
@@ -59,6 +59,7 @@ class NotificationManagementServiceTests extends \WC_Unit_Test_Case {
 		remove_filter( 'wp_redirect', array( $this, 'intercept_redirect' ) );

 		unset( $_GET['_wpnonce'], $_GET[ NotificationManagementService::RESEND_QUERY_ARG ] );
+		wp_set_current_user( 0 );

 		// DELETE rather than TRUNCATE so the outer WP_UnitTestCase transaction can still roll back.
 		// TRUNCATE is DDL and implicitly commits the surrounding transaction.
@@ -94,6 +95,53 @@ class NotificationManagementServiceTests extends \WC_Unit_Test_Case {
 		$this->assertStringContainsString( '_wpnonce=', $url );
 	}

+	/**
+	 * @testdox Should silently drop a resend request from a logged-in user who does not own the notification.
+	 */
+	public function test_resend_request_ignores_other_users_notification() {
+		$owner_id    = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$attacker_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$notification = $this->build_pending_notification();
+		$notification->set_user_id( $owner_id );
+		$notification->save();
+
+		wp_set_current_user( $attacker_id );
+		$this->seed_resend_request( $notification->get_id() );
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		// Must return before the redirect path; a redirect would throw.
+		$this->sut->maybe_process_resend_request();
+
+		$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 Should let the owner resend a notification linked to their account.
+	 */
+	public function test_resend_request_allows_owner() {
+		$owner_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+
+		$notification = $this->build_pending_notification();
+		$notification->set_user_id( $owner_id );
+		$notification->save();
+
+		wp_set_current_user( $owner_id );
+		$this->seed_resend_request( $notification->get_id() );
+
+		$this->email_manager
+			->expects( $this->once() )
+			->method( 'send_verify_email' );
+
+		$this->expectException( \RuntimeException::class );
+		$this->sut->maybe_process_resend_request();
+	}
+
 	/**
 	 * @testdox Should send the verify email and persist last-sent timestamp on a valid resend request.
 	 */
@@ -247,6 +295,69 @@ class NotificationManagementServiceTests extends \WC_Unit_Test_Case {
 		$this->sut->maybe_process_resend_request();
 	}

+	/**
+	 * @testdox resend_verification_email() should send the verify email and persist the last-sent timestamp.
+	 */
+	public function test_resend_verification_email_sends_and_persists_timestamp() {
+		$notification = $this->build_pending_notification();
+
+		$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();
+					}
+				)
+			);
+
+		$this->assertTrue( $this->sut->resend_verification_email( $notification ) );
+
+		$reloaded = Factory::get_notification( $notification->get_id() );
+		$this->assertNotEmpty( $reloaded->get_meta( NotificationManagementService::LAST_VERIFY_EMAIL_SENT_META ) );
+		$this->assertEmpty( wc_get_notices() );
+	}
+
+	/**
+	 * @testdox resend_verification_email() should refuse a notification that is no longer pending.
+	 */
+	public function test_resend_verification_email_rejects_non_pending() {
+		$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->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$result = $this->sut->resend_verification_email( $notification );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( NotificationManagementService::RESEND_ERROR_NOT_PENDING, $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox resend_verification_email() should refuse a send inside the rate-limit window.
+	 */
+	public function test_resend_verification_email_rate_limited() {
+		$notification = $this->build_pending_notification();
+		$notification->update_meta_data( NotificationManagementService::LAST_VERIFY_EMAIL_SENT_META, time() );
+		$notification->save();
+
+		$this->email_manager
+			->expects( $this->never() )
+			->method( 'send_verify_email' );
+
+		$result = $this->sut->resend_verification_email( $notification );
+
+		$this->assertInstanceOf( \WP_Error::class, $result );
+		$this->assertSame( NotificationManagementService::RESEND_ERROR_RATE_LIMITED, $result->get_error_code() );
+	}
+
 	/**
 	 * Build a pending notification for a fresh simple product.
 	 *