feat(emails): surface WC emails with chip filtering and preview thumbnails (NPPD-1527)#4732
Conversation
…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>
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>
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>
There was a problem hiding this comment.
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
chipkey on every entry, drops 5 entries, adds 3 WC Subs entries, renames/remapswoo-renewal-reminder. Resolveswoocommerce-source entries to liveWC_Emailinstances and exposes a newPOST .../emails/{id}/toggleroute plus first-run option that enables recommended WC emails. - New
Email_Previewclass andGET .../emails/{post_id}/previewroute, with token substitution for Newspack emails and a cached block-editor-backed render path (with legacy fallback) for WC block emails. - New
EmailPreviewReact component withIntersectionObserverlazy-load,ResizeObserverscaling, sandboxed iframe, and stale-request cancellation; chip filter bar above DataViews; toggle action routing bypost_idtype.
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->enabledproperty on the cached instance returned byWC()->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 indraftstatus. 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: afterupdate_option( $option_key, $options ), the response is built by callingapi_get_email_settings(), which re-uses the cachedWC()->mailer()->get_emails()instances whose$enabledproperty 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
WooCommerceis 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_emailreturns the fullapi_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_emailaccepts any allowed registry email ID, even when its plugin dependency is inactive. For example, a request targetingcustomer_notification_auto_renewalwill still hitWC()->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 fromapi_get_email_settings()here so the error is a deliberate dependency-missing response rather than a coincidentalnot_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.
- 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>
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.phpchipkey (auth-accountorreader-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).woocommerce-subscriptions).api_get_email_settings()to resolvewoocommerce-source registry entries to liveWC_Emailinstances viaWC()->mailer()->get_emails(). Plugin-dependency gating skips entries whose dependency isn't active. Each row gets a stringpost_id(wc:{email_id}), a contextualedit_link(block editor ifwoocommerce_feature_block_email_editor_enabled, classic settings otherwise), and apreview_post_idfor block-editor-backed emails.Backend —
includes/wizards/newspack/class-email-preview.php(new)Email_Previewclass consolidates preview rendering for both Newspack-managed and WooCommerce emails.*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 filternewspack_email_preview_substitutionsfor extension.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 legacyEmailPreview::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.GET /newspack/v1/wizard/newspack-settings/emails/{post_id}/previewreturning rendered HTML, with permission check.Frontend —
src/wizards/newspack/views/settings/emails/EmailPreviewcomponent (email-preview.tsx) replaces the slice 1 envelope placeholder. Lazy-loads viaIntersectionObserver(fetch only when the row scrolls into view), measures container width viaResizeObserverand scales an iframe accordingly. UsessrcDocwithsandbox="allow-same-origin"(no scripts) for safety. Stale-request cancellation guards against postId changes mid-fetch. Fade-in viais-readyclass after stylesheets and images load (with an 8s safety timeout).EmailIteminterface:post_idis nownumber | string(WC emails usewc:prefix), addspreview_post_id?: number | nullandchipfields.post_idtype: string → toggle endpoint, number → existingwp/v2status update. Removes the slice 1source !== 'woocommerce'eligibility guard. Reset action remains Newspack-only (different storage backend).emails.scss, newemail-preview.scss): pill-shaped chip buttons, preview iframe container with aspect-ratio + transform-origin sizing.Tests
tests/unit-tests/emails-section.php,tests/unit-tests/email-preview.php): chip-key validation, dropped entries absent, new entries present with correctwoo_email_idvalues, renamed labels, source switch for renewal reminder, fallback shape; preview HTML generation with stored meta and template fallback,CONTACT_EMAILresolves viaEmails::get_reply_to_email(), REST permissions, REST 404 on wrong post type.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:
PHPUnit not run locally.
All Submissions: