Skip to content

feat(emails): surface WC emails with chip filtering and preview thumbnails (NPPD-1527)#4732

Open
kmwilkerson wants to merge 5 commits into
nppd-945-unified-emails-newspack-slicefrom
nppd-1527-slice-2-surface-woocommerce-emails-in-the-unified-emails-ui
Open

feat(emails): surface WC emails with chip filtering and preview thumbnails (NPPD-1527)#4732
kmwilkerson wants to merge 5 commits into
nppd-945-unified-emails-newspack-slicefrom
nppd-1527-slice-2-surface-woocommerce-emails-in-the-unified-emails-ui

Conversation

@kmwilkerson
Copy link
Copy Markdown

@kmwilkerson kmwilkerson commented May 16, 2026

What this PR does

Second slice of the unified email management UI (NPPD-1527). Builds on slice 1's registry + DataViews scaffolding to surface WooCommerce emails as first-class rows, adds chip-based filtering, and renders themed preview thumbnails matched to the block editor's in-editor view.

This slice closes the loop on the PRD's "one place to manage all reader emails" promise — Newspack-managed emails and curated WC emails now share a single screen with consistent search, status, and toggle behavior.

Depends on slice 1 (#4727).

Code changes

Backend — includes/wizards/newspack/class-emails-section.php

  • Adds a chip key (auth-account or reader-revenue) to every registry entry. Cleans up 5 entries that don't belong in the unified UI (woo-password-reset, woo-processing-order, woo-completed-order, woo-on-hold-order, woo-subscription-cancelled).
  • Adds 3 new WC Subscriptions entries: subscription switch complete, new giftee account, new gift order (gifting is bundled into WC Subs core, so all three gate on woocommerce-subscriptions).
  • Renames woo-renewal-reminder → "Renewal reminder" and switches its woo_email_id from customer_renewal_invoice to customer_notification_auto_renewal. Trigger description references WC Subs settings rather than hardcoding the day count, since publishers can change the Reminder Timing in WooCommerce → Settings → Subscriptions.
  • Extends api_get_email_settings() to resolve woocommerce-source registry entries to live WC_Email instances via WC()->mailer()->get_emails(). Plugin-dependency gating skips entries whose dependency isn't active. Each row gets a string post_id (wc:{email_id}), a contextual edit_link (block editor if woocommerce_feature_block_email_editor_enabled, classic settings otherwise), and a preview_post_id for block-editor-backed emails.
  • Adds a first-run option (newspack_unified_emails_wc_first_run) that enables only recommended WC emails on first load (skipping woo-refund and woo-new-order, both recommended=false, so pubs who intentionally left those off aren't surprised).
  • Adds a new REST route: POST /newspack/v1/wizard/newspack-settings/emails/{id}/toggle for enabling/disabling WC emails. Conditional on WooCommerce being active, uses [A-Za-z0-9_]+ to accommodate the mixed-case WCSG_Email_Customer_New_Account ID, and validates the email ID against the registry before toggling so the API surface matches the UI.

Backend — includes/wizards/newspack/class-email-preview.php (new)

  • New Email_Preview class consolidates preview rendering for both Newspack-managed and WooCommerce emails.
  • For Newspack emails: substitutes template tokens (*BILLING_NAME*, *AMOUNT*, *SITE_LOGO*, etc.) with stable sample values. Site/branding tokens use real publisher config; reader/transaction tokens use stable fakes; action URLs become anchor placeholders so preview clicks don't navigate. Falls back to the registered template's default HTML when the post has no saved meta. Exposed via filter newspack_email_preview_substitutions for extension.
  • For WooCommerce emails: tries the block-editor render path first via BlockEmailRenderer::maybe_render_block_email(), producing HTML styled with the site's theme.json colors and logo (matching what the block editor shows). Falls back to WC's legacy EmailPreview::render() if the block path is unavailable or throws. Block output is cached in a transient keyed by post ID + modified time (1 hr TTL) since the block render benchmarks at ~180 ms per email.
  • Adds a new REST route: GET /newspack/v1/wizard/newspack-settings/emails/{post_id}/preview returning rendered HTML, with permission check.

Frontend — src/wizards/newspack/views/settings/emails/

  • New EmailPreview component (email-preview.tsx) replaces the slice 1 envelope placeholder. Lazy-loads via IntersectionObserver (fetch only when the row scrolls into view), measures container width via ResizeObserver and scales an iframe accordingly. Uses srcDoc with sandbox="allow-same-origin" (no scripts) for safety. Stale-request cancellation guards against postId changes mid-fetch. Fade-in via is-ready class after stylesheets and images load (with an 8s safety timeout).
  • Adds a chip bar above the DataViews grid with two options: Reader revenue (default) and Authentication & account. Switching chips filters the data and resets search/page.
  • Updates EmailItem interface: post_id is now number | string (WC emails use wc: prefix), adds preview_post_id?: number | null and chip fields.
  • Deactivate/activate actions now route based on post_id type: string → toggle endpoint, number → existing wp/v2 status update. Removes the slice 1 source !== 'woocommerce' eligibility guard. Reset action remains Newspack-only (different storage backend).
  • Updated styles (emails.scss, new email-preview.scss): pill-shaped chip buttons, preview iframe container with aspect-ratio + transform-origin sizing.

Tests

  • PHPUnit (tests/unit-tests/emails-section.php, tests/unit-tests/email-preview.php): chip-key validation, dropped entries absent, new entries present with correct woo_email_id values, renamed labels, source switch for renewal reminder, fallback shape; preview HTML generation with stored meta and template fallback, CONTACT_EMAIL resolves via Emails::get_reply_to_email(), REST permissions, REST 404 on wrong post type.
  • Jest (emails.test.js, email-preview.test.js): chip toggling between auth-account and reader-revenue, WC toggle endpoint routing for deactivate/activate, WC emails now eligible for activate/deactivate, lazy load via IntersectionObserver, stale-request cancellation, error fallback to envelope icon, correct REST path.

Why slice this PR

Same rationale as slice 1: the full PRD scope (Newspack + WC + WC Subs + WC Subs Gifting, chips UI, plugin-conditional logic, status toggling across different storage backends, themed preview rendering) is meaty. Splitting at the registry-surfacing boundary kept slice 1 reviewable; this slice picks up where that left off without dragging in slice 1's churn.

Manual testing

Tested locally with Newspack, Newspack Newsletters, WooCommerce, WooCommerce Subscriptions (including bundled gifting), and the WC Block Email Editor feature enabled. Verified:

  • Chip bar renders with Reader revenue selected by default; toggling to Auth & account swaps the visible rows and resets search/page
  • All 9 WC emails (1 Auth, 8 Reader revenue) surface alongside Newspack-managed emails when WC + WC Subs are active
  • Renewal reminder maps to the auto-renewal notice and reflects the WC Subs default 3-day offset
  • WC email toggle persists across page refresh and reflects in WC's own settings page
  • Block editor edit-link works for the 3 core WC emails (Account created, Refund, New order); WC Subs emails fall back to classic settings (no block editor support yet from Woo)
  • Preview thumbnails for the 3 core WC emails render with site logo and theme colors (not Woo purple), matching the block editor's in-editor view
  • WC Subs emails show no thumbnail (intentional — they don't have block editor templates yet)
  • First-run enable-by-default activates all recommended WC emails the first time the screen loads
  • Newspack-managed emails still render their thumbnails correctly via the substitution path
  • Editing a WC email template in the block editor causes the preview cache to refresh on next load

PHPUnit not run locally.

All Submissions:

…nails (NPPD-1527)

Add chip-based category filtering (Reader Revenue / Auth & Account),
resolve WC email entries to live WC_Email instances with enable/disable
toggling, first-run enable-by-default, and three new WC Subscriptions
email entries (subscription switch, giftee account, gift order).

For the three core WC emails with block-editor template posts (New Order,
Order Refund, New Account), generate themed preview thumbnails via
BlockEmailRenderer — matching the block editor's in-editor view with
site logo and theme.json colors. Falls back to legacy EmailPreview
render if the block path is unavailable. WC Subscriptions emails
intentionally show no thumbnail (no block editor support yet).

Block render output is cached in a transient (1 hr TTL, invalidated on
save_post_woo_email) since the block path benchmarks at ~180 ms per email.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson kmwilkerson requested a review from a team as a code owner May 16, 2026 02:59
@kmwilkerson kmwilkerson marked this pull request as draft May 16, 2026 02:59
The save_post_woo_email hook was computing the cache key with the
already-updated post_modified_gmt, so it was deleting a key that
didn't exist yet. The cache already self-invalidates via the modified
timestamp in the key — the hook was dead weight leaving orphaned
transients until TTL expiry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson kmwilkerson added the [Status] Needs Review The issue or pull request needs to be reviewed label May 16, 2026
1. First-run now only enables WC emails with recommended=true, not all
   registry entries. Prevents turning on admin "New order" or "Refund"
   emails merchants may have intentionally left off.

2. Non-registry Newspack emails get chip='auth-account' fallback so
   they still appear in a tab instead of being hidden by the filter.

3. Toggle endpoint now validates the WC email ID against the registry
   before toggling, restricting the API surface to match the UI.

4. Renewal reminder trigger description softened to reference WC
   Subscriptions settings instead of hardcoding "3 days".

5. Inline comment on first-run side effect in GET handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Second slice of the unified email management UI. Surfaces curated WooCommerce (and WC Subscriptions/Gifting) emails alongside Newspack-managed emails in the existing DataViews list, adds chip-based filtering between Reader revenue and Authentication & account, and replaces the slice-1 envelope placeholder with a themed preview iframe component backed by a new Email_Preview REST endpoint.

Changes:

  • Backend registry refactor: adds chip key on every entry, drops 5 entries, adds 3 WC Subs entries, renames/remaps woo-renewal-reminder. Resolves woocommerce-source entries to live WC_Email instances and exposes a new POST .../emails/{id}/toggle route plus first-run option that enables recommended WC emails.
  • New Email_Preview class and GET .../emails/{post_id}/preview route, with token substitution for Newspack emails and a cached block-editor-backed render path (with legacy fallback) for WC block emails.
  • New EmailPreview React component with IntersectionObserver lazy-load, ResizeObserver scaling, sandboxed iframe, and stale-request cancellation; chip filter bar above DataViews; toggle action routing by post_id type.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
includes/wizards/newspack/class-emails-section.php Chip key, registry churn, WC mailer resolution, first-run option, toggle endpoint, edit-link + preview-post-id helpers
includes/wizards/newspack/class-email-preview.php New preview class with token substitutions and WC block-render path
includes/class-newspack.php Includes the new email-preview class
src/wizards/newspack/views/settings/emails/emails.tsx Chip bar UI, toggle wiring for string/number post IDs, EmailPreview integration
src/wizards/newspack/views/settings/emails/email-preview.tsx New lazy-loaded preview iframe component
src/wizards/newspack/views/settings/emails/emails.scss Chip-bar styling, removes old placeholder rules
src/wizards/newspack/views/settings/emails/email-preview.scss New iframe + placeholder styles
src/wizards/newspack/views/settings/emails/emails.test.js Tests for chip toggle, WC toggle endpoint routing, eligibility
src/wizards/newspack/views/settings/emails/email-preview.test.js Tests for lazy load, fetch, stale-request cancel, error fallback
tests/unit-tests/emails-section.php Tests for chip validation, dropped/new entries, renamed labels
tests/unit-tests/email-preview.php Tests for substitutions, CONTACT_EMAIL, REST permissions and 404
Comments suppressed due to low confidence (5)

includes/wizards/newspack/class-emails-section.php:575

  • After updating the WC email's option directly via update_option( $option_key, $options ), the in-memory $wc_email->enabled property on the cached instance returned by WC()->mailer()->get_emails() is not refreshed. Immediately afterwards, api_get_email_settings() iterates the same cached instances and computes 'yes' === $wc_email->enabled ? 'publish' : 'draft', so on the very first page load the response will report the just-enabled recommended WC emails as still being in draft status. The UI will only reflect the correct status on a subsequent request. Consider either calling $wc_email->init_settings() (or $wc_email->enabled = 'yes') after the option write, or moving the first-run logic to a separate write endpoint.
			if ( ! $wc_email ) {
				continue;
			}
			if ( 'yes' !== $wc_email->enabled ) {
				$option_key = $wc_email->get_option_key();
				$options    = (array) get_option( $option_key, [] );
				$options['enabled'] = 'yes';
				update_option( $option_key, $options );
			}
			// Auto-renewal notice needs the master switch enabled too.
			if ( 'customer_notification_auto_renewal' === $wc_email_id ) {
				if ( 'yes' !== get_option( 'woocommerce_subscriptions_customer_notifications_enabled' ) ) {
					update_option( 'woocommerce_subscriptions_customer_notifications_enabled', 'yes' );
				}
			}
		}

includes/wizards/newspack/class-emails-section.php:621

  • Same staleness issue as first_run_enable_wc_emails: after update_option( $option_key, $options ), the response is built by calling api_get_email_settings(), which re-uses the cached WC()->mailer()->get_emails() instances whose $enabled property was loaded at construction. The returned row for the toggled email will reflect the pre-toggle state, not the new one. The frontend currently does optimistic updates and ignores the response body, but any consumer that does rely on the response (or any future change to do so) will get incorrect data. Refresh $wc_email->enabled (e.g. $wc_email->init_settings() or direct assignment) before returning the response.
		$option_key = $wc_email->get_option_key();
		$options    = (array) get_option( $option_key, [] );
		$options['enabled'] = $enabled ? 'yes' : 'no';
		update_option( $option_key, $options );

		return rest_ensure_response( self::api_get_email_settings() );
	}

includes/wizards/newspack/class-emails-section.php:376

  • The first-run flag is set unconditionally once WooCommerce is active, even if WC Subscriptions (or any other dependency-gated plugin) is not yet active. If a site activates WC first (so first-run fires and is recorded), then later activates WC Subscriptions, the WC Subs–dependent recommended emails (renewal reminder, payment retry, expired subscription, giftee account, gift order, switch complete) will never be enabled by the first-run pass because the option is already set. Consider either deferring the flag until all dependency-gated emails could be evaluated, or scoping the first-run flag per plugin dependency.
		if ( class_exists( 'WooCommerce' ) && ! get_option( 'newspack_unified_emails_wc_first_run', false ) ) {
			self::first_run_enable_wc_emails( $registry );
			update_option( 'newspack_unified_emails_wc_first_run', true, false );
		}

includes/wizards/newspack/class-emails-section.php:620

  • api_toggle_wc_email returns the full api_get_email_settings() payload on success, which re-resolves every Newspack and WC email (including the WC mailer iteration, plugin dependency checks, and block-editor template post lookups) for every toggle click. The frontend doesn't use the response body — it relies on the optimistic update. Consider returning a minimal response (e.g. [ 'enabled' => $enabled ]) to keep the toggle action lightweight.
		return rest_ensure_response( self::api_get_email_settings() );

includes/wizards/newspack/class-emails-section.php:613

  • api_toggle_wc_email accepts any allowed registry email ID, even when its plugin dependency is inactive. For example, a request targeting customer_notification_auto_renewal will still hit WC()->mailer()->get_emails() and silently no-op (returning 404 only because the mailer doesn't expose it) when WC Subscriptions is not active. Consider mirroring the plugin-dependency gate from api_get_email_settings() here so the error is a deliberate dependency-missing response rather than a coincidental not_found.
		// Only allow toggling IDs that exist in our registry.
		$registry          = self::get_email_registry();
		$allowed_wc_ids    = array_column(
			array_filter( $registry, fn( $e ) => 'woocommerce' === $e['source'] ),
			'woo_email_id'
		);
		if ( ! in_array( $wc_email_id, $allowed_wc_ids, true ) ) {
			return new \WP_Error( 'not_found', 'WC email not found in registry.', [ 'status' => 404 ] );
		}

		$wc_mailer_emails = \WC()->mailer()->get_emails();
		$wc_email         = null;
		foreach ( $wc_mailer_emails as $email_instance ) {
			if ( $email_instance->id === $wc_email_id ) {
				$wc_email = $email_instance;
				break;
			}
		}

		if ( ! $wc_email ) {
			return new \WP_Error( 'not_found', 'WC email not found.', [ 'status' => 404 ] );
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/wizards/newspack/class-emails-section.php
Comment thread src/wizards/newspack/views/settings/emails/emails.tsx
Comment thread includes/wizards/newspack/class-emails-section.php Outdated
Comment thread src/wizards/newspack/views/settings/emails/email-preview.tsx Outdated
Comment thread src/wizards/newspack/views/settings/emails/emails.tsx Outdated
Comment thread includes/wizards/newspack/class-emails-section.php
kmwilkerson and others added 2 commits May 15, 2026 22:28
- Extract chip config array + selectChip helper to reduce duplication
- Extract shared get_wc_email_template_post_id() helper used by both
  get_wc_email_edit_link() and the preview post ID lookup
- Fix race condition in iframe awaitLoad: wire up load/error listeners
  before checking the loaded state so fast assets don't strand the spinner
- Drop unreachable '' from EmailItem.chip type union (PHP fallback now
  assigns 'auth-account')
- Align test mock trigger_description with updated registry wording

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Read WC email enabled state from option instead of stale in-memory
  WC_Email::$enabled property after first-run/toggle updates
- Track first-run per-email (not globally) so newly-activated plugins
  (e.g. WC Subscriptions) get their emails enabled on next visit
- Add aria-pressed to chip filter buttons for accessibility
- Translate iframe title in EmailPreview component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson kmwilkerson marked this pull request as ready for review May 16, 2026 03:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Needs Review The issue or pull request needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants