Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5662da7
chore(deps): update php-loremipsum source URL
kmwilkerson May 12, 2026
a1ebea4
feat(emails): unified email registry + DataViews list (NPPD-945, slic…
kmwilkerson May 12, 2026
01fbe6d
feat(emails): refine DataViews UI per PRD mockup (NPPD-945)
kmwilkerson May 14, 2026
df2a242
Merge trunk into nppd-945-unified-emails-newspack-slice
kmwilkerson May 14, 2026
7de56e8
fix(emails): apply design polish, status dots, grid default, and test…
kmwilkerson May 14, 2026
1bae2ad
test(emails): update PHPUnit test to use renamed 'recommended' field …
kmwilkerson May 14, 2026
e16b00a
fix(emails): stabilize sort with registry-index tiebreaker
kmwilkerson May 14, 2026
f8f0ee7
fix(emails): add visually-hidden h1 for a11y (NPPD-945)
kmwilkerson May 14, 2026
2c30737
test(emails): cover api_get_email_settings response shape (NPPD-945)
kmwilkerson May 14, 2026
9693f85
test(emails): cover DataViews action callbacks (NPPD-945)
kmwilkerson May 14, 2026
ff6ae9e
lint(emails): collapse visually-hidden h1 to single line (NPPD-945)
kmwilkerson May 14, 2026
a4ae443
fix(emails): address PR review feedback (NPPD-945)
kmwilkerson May 15, 2026
90b09bf
Restore Reset action on all Newspack-managed emails - NPPD-1528
kmwilkerson May 15, 2026
ebb58fb
fix(emails): use inline-block for Badge so it shrink-wraps in grid view
kmwilkerson May 15, 2026
db4113d
fix(emails): address thomasguillot review findings on slice 1
kmwilkerson May 15, 2026
d66fa5f
fix(emails): prettier formatting on Notice component
kmwilkerson May 15, 2026
c5791f0
fix(emails): address round-2 review feedback (NPPD-945)
kmwilkerson May 15, 2026
c755a1d
Make get_email_registry() filterable for external email integrations …
kmwilkerson May 15, 2026
19c1dce
Merge branch 'trunk' into nppd-945-unified-emails-newspack-slice
dkoo May 15, 2026
31bd102
fix(emails): address review feedback (NPPD-1527)
kmwilkerson May 26, 2026
434a617
refactor(emails): use useWizardApiFetch for loading states
kmwilkerson May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 315 additions & 18 deletions includes/wizards/newspack/class-emails-section.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

namespace Newspack\Wizards\Newspack;

use Newspack\Emails;
use Newspack\Reader_Activation;
use Newspack\Reader_Revenue_Emails;
use Newspack\Wizards\Wizard_Section;
use Newspack\WooCommerce_Emails;
use WP_REST_Server;
Expand All @@ -28,9 +31,6 @@ class Emails_Section extends Wizard_Section {
* Register the endpoints needed for the wizard screens.
*/
public function register_rest_routes() {
if ( ! WooCommerce_Emails::is_active() ) {
return;
}
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/' . $this->wizard_slug . '/emails',
Expand All @@ -40,23 +40,257 @@ public function register_rest_routes() {
'permission_callback' => [ $this, 'api_permissions_check' ],
]
);
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/' . $this->wizard_slug . '/emails',
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ __CLASS__, 'api_update_email_settings' ],
'permission_callback' => [ $this, 'api_permissions_check' ],
'args' => [
'enable_woocommerce_email_editor' => [
'type' => 'boolean',
'required' => true,
'sanitize_callback' => 'rest_sanitize_boolean',
if ( WooCommerce_Emails::is_active() ) {
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/' . $this->wizard_slug . '/emails',
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ __CLASS__, 'api_update_email_settings' ],
'permission_callback' => [ $this, 'api_permissions_check' ],
'args' => [
'enable_woocommerce_email_editor' => [
'type' => 'boolean',
'required' => true,
'sanitize_callback' => 'rest_sanitize_boolean',
],
],
],
]
);
}
}

]
);
/**
* Get the unified email registry.
*
* Returns all known email entries keyed by a stable slug. Each entry
* includes metadata used by the Settings > Emails UI.
*
* @return array Registry entries keyed by slug.
*/
public static function get_email_registry(): array {
Copy link
Copy Markdown
Contributor

@dkoo dkoo May 19, 2026

Choose a reason for hiding this comment

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

🔴 Discussion needed: the email registry

I understand that the plan to integrate WooCommerce transactional emails into our UI is planned for a future PR, but I'd like to take some time to discuss the implementation of the email registry here. Maybe I'm missing something, but at first glance the idea of the registry seems a little more complex than it needs to be. Am I understanding the purpose correctly?

  1. Newspack-specific transactional emails have their own config schema and are registered via the newspack_email_configs filter and Emails::get_email_configs() method
  2. Certain transactional emails from WooCommerce and its extensions need to be integrated so that our UI can pick those up and display/interact with them in a similar way to Newspack emails
  3. The idea of the registry is to fetch Newspack email configs, combine them with the WooCommerce emails we want, and then normalize all of them into a single email config schema

If the normalization of data is the key requirement here, then rather than introducing the registry with a new type of data schema, why not define and extend the data schema based on the existing Newspack email configs? What that would look like:

  • A new method in the WooCommerce_Emails class to get configs for WooCommerce emails would define the configs for the Woo emails we want, based on the same config schema as Newspack emails. I also wonder if there's a WooCommerce function somewhere that would get all available transactional emails, which we could use instead of hard-coding the emails we want (which may or may not exist in the way we expect). Then this method could transform the data from whatever format WooCommerce provides into our own schema.
  • In the Emails::get_email_configs() method, we would define a "default" config object that contains all of the possible keys with reasonable default values, then fetch email configs for both Newspack and Woo emails
  • Each email config would get merged with the default object so their defined values override the defaults, while missing values get the defaults
  • The end result is a single config object containing both Newspack and Woo email configs without introducing a new data schema

This to me seems to be a simpler and more easily maintainable way to combine Newspack and Woo emails with a single data schema based on how Newspack emails are already configured. WDYT?

Copy link
Copy Markdown
Author

@kmwilkerson kmwilkerson May 26, 2026

Choose a reason for hiding this comment

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

Good architectural callout — agreed the registry adds a parallel schema where extending the existing config schema would be cleaner. I'd like to defer this refactor to a focused follow-up PR rather than restructure slice 1's data layer at this stage. The follow-up would cover:

  • Folding trigger_description, recipient, recommended, chip into the existing newspack_email_configs schema with sensible defaults
  • Designing the WC integration approach (whether to add WooCommerce_Emails::get_email_configs() that registers via the filter, or extend Emails::serialize_email() to handle the live WC_Email resolution path slice 2 currently uses)
  • Replacing Emails_Section::get_email_registry() with config-driven enrichment

Filed as NPPD-1550. Happy to do it in this PR instead if you'd prefer — let me know which way you want to go.

Copy link
Copy Markdown
Contributor

@dkoo dkoo May 26, 2026

Choose a reason for hiding this comment

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

Since this is such a major architectural change, I'd rather not merge to trunk as-is with the parallel schema. Let's do this:

  1. Open another PR based off this branch to handle the refactor to unify the schema centered around the existing newspack_email_configs schema.
  2. Pause work on descendent PRs that are based off this branch until the refactor is complete.
  3. Once that refactor PR gets merged to this PR (without squashing since it's not merging to a protected branch), we can update any other descendent PRs based off this PR's branch with the unified schema.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Before I start, one architecture question - the spec sketches three options for how WC emails plug into the unified config schema:

Option A — Your suggestion in full. A new WooCommerce_Emails::get_email_configs() method produces config-schema entries for the WC emails we surface (with source='woocommerce', woo_email_id, chip, plugin gating), registered via the newspack_email_configs filter. Single unified schema across Newspack and WC. Tradeoff: we lose direct access to WC_Email instance methods (get_subject(), get_recipient(), enable()/disable()) — we'd need accessor wrappers or store enough metadata in the config to drive the UI without the live instance.

Option B — Keep slice 2's current path: live WC_Email resolution via WC()->mailer()->get_emails(), extended through Emails::serialize_email() (or a parallel serialize_wc_email()) to produce the same output shape. Tradeoff: no parallel registry, but two code paths into the same response.

Option C — Hybrid. WC emails get config-style entries for discovery + UI metadata, but the toggle endpoint still resolves to the live WC_Email for enable()/disable(). Unified schema for reads; WC stays source of truth for writes.

Option A is cleanest if we're committed to a single schema, but it asks us to wrap or duplicate enough of WC_Email's API to drive activate/deactivate and recipient resolution without the live instance — adding surface area we'd otherwise get for free. C preserves the schema win where it matters (API response, UI driving off it) without re-implementing WC's write path.

Which do you want me to build against?

$registry = [
'verification' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-verification',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Reader verification', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader needs to verify their email address.', 'newspack-plugin' ),
],
'login-link' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-magic-link',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Magic login link', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader requests a magic login link.', 'newspack-plugin' ),
],
'login-otp' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-otp-authentication',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Login one-time password', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader logs in with a one-time password.', 'newspack-plugin' ),
],
'set-new-password' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-reset-password',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Password reset', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader requests a password reset.', 'newspack-plugin' ),
],
'receipt' => [
'source' => 'newspack',
'newspack_type' => 'receipt',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Payment receipt', 'newspack-plugin' ),
'trigger_description' => __( 'Sent after a successful payment.', 'newspack-plugin' ),
],
'welcome' => [
'source' => 'newspack',
'newspack_type' => 'welcome',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Welcome email', 'newspack-plugin' ),
'trigger_description' => __( 'Sent to new supporters after their first payment.', 'newspack-plugin' ),
],
'cancellation' => [
'source' => 'newspack',
'newspack_type' => 'cancellation',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Cancellation confirmation', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader cancels their subscription.', 'newspack-plugin' ),
],
'woo-renewal-reminder' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_renewal_invoice',
'recommended' => true,
'plugin_dependency' => 'woocommerce-subscriptions',
'recipient' => 'reader',
'label' => __( 'Subscription renewal invoice', 'newspack-plugin' ),
'trigger_description' => __( 'Sent to remind a customer that a renewal payment is due.', 'newspack-plugin' ),
],
'woo-payment-retry' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_payment_retry',
'recommended' => true,
'plugin_dependency' => 'woocommerce-subscriptions',
'recipient' => 'reader',
'label' => __( 'Subscription payment retry', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a failed subscription payment is about to be retried.', 'newspack-plugin' ),
],
'woo-subscription-cancelled' => [
'source' => 'woocommerce',
'woo_email_id' => 'cancelled_subscription',
'recommended' => true,
'plugin_dependency' => 'woocommerce-subscriptions',
'recipient' => 'reader',
Comment thread
kmwilkerson marked this conversation as resolved.
'label' => __( 'Subscription cancelled', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a subscription is cancelled.', 'newspack-plugin' ),
],
'woo-expired-subscription' => [
'source' => 'woocommerce',
'woo_email_id' => 'expired_subscription',
'recommended' => true,
'plugin_dependency' => 'woocommerce-subscriptions',
'recipient' => 'reader',
'label' => __( 'Subscription expired', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a subscription reaches its expiration date.', 'newspack-plugin' ),
],
'woo-customer-new-account' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_new_account',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'New account', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a customer creates a new account.', 'newspack-plugin' ),
],
'woo-password-reset' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_reset_password',
'recommended' => true,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Password reset (WooCommerce)', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a customer resets their password via WooCommerce.', 'newspack-plugin' ),
],
'delete-account' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-delete-account',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Account deletion', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a reader requests to delete their account.', 'newspack-plugin' ),
],
'change-email-notification' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-change-email-cancel',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Email change notification', 'newspack-plugin' ),
'trigger_description' => __( 'Sent to the old address when a reader changes their email.', 'newspack-plugin' ),
],
'change-email-confirmation' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-change-email',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Email change confirmation', 'newspack-plugin' ),
'trigger_description' => __( 'Sent to the new address to confirm an email change.', 'newspack-plugin' ),
],
'non-reader-login-reminder' => [
'source' => 'newspack',
'newspack_type' => 'reader-activation-non-reader-user',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Non-reader login reminder', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when a non-reader WordPress user tries to log in as a reader.', 'newspack-plugin' ),
],
'group-subscription-invitation' => [
'source' => 'newspack',
'newspack_type' => 'group-subscription-invite',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Group subscription invitation', 'newspack-plugin' ),
'trigger_description' => __( 'Sent to invite a reader to join a group subscription.', 'newspack-plugin' ),
],
'woo-refund' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_refunded_order',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Order refund', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when an order is refunded.', 'newspack-plugin' ),
],
// The following three WooCommerce emails are customer-facing but marked
// recommended=false because they are lower customization priority for
// subscription-focused publishers.
'woo-processing-order' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_processing_order',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Order processing', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when an order payment is received and the order begins processing.', 'newspack-plugin' ),
],
'woo-completed-order' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_completed_order',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Order complete', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when an order is marked as complete.', 'newspack-plugin' ),
],
'woo-on-hold-order' => [
'source' => 'woocommerce',
'woo_email_id' => 'customer_on_hold_order',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'reader',
'label' => __( 'Order on hold', 'newspack-plugin' ),
'trigger_description' => __( 'Sent when an order is placed on hold.', 'newspack-plugin' ),
],
'woo-new-order' => [
'source' => 'woocommerce',
'woo_email_id' => 'new_order',
'recommended' => false,
'plugin_dependency' => null,
'recipient' => 'admin',
'label' => __( 'New order (admin)', 'newspack-plugin' ),
'trigger_description' => __( 'Sent to the admin when a new order is placed.', 'newspack-plugin' ),
],
];

/**
* Filters the unified email registry.
*
* Allows external integration plugins to register additional email
* entries that appear in the Settings > Emails UI.
*
* @param array $registry Registry entries keyed by slug.
*/
return apply_filters( 'newspack_emails_registry', $registry );
}

/**
Expand All @@ -70,6 +304,69 @@ public static function api_get_email_settings(): array {
$settings['admin_url'] = admin_url( 'admin.php?page=wc-settings&tab=email' );
$settings['enable_woocommerce_email_editor'] = 'yes' === WooCommerce_Emails::is_enabled();
}

// Build newspack_emails from the Emails system, enriched with registry data.
$config_names = [];
if ( ! Reader_Activation::is_enabled() ) {
$config_names = array_values( Reader_Revenue_Emails::EMAIL_TYPES );
}
$emails = Emails::get_emails( $config_names, false );

// Build a lookup from newspack_type => registry entry.
$registry = self::get_email_registry();
$registry_lookup = [];
foreach ( $registry as $slug => $entry ) {
if ( isset( $entry['newspack_type'] ) ) {
$registry_lookup[ $entry['newspack_type'] ] = array_merge( $entry, [ 'registry_slug' => $slug ] );
}
}

$newspack_emails = [];
foreach ( $emails as $type => $email ) {
if ( isset( $registry_lookup[ $type ] ) ) {
$match = $registry_lookup[ $type ];
$email['label'] = $match['label'];
$email['recommended'] = $match['recommended'];
$email['trigger_description'] = $match['trigger_description'];
$email['registry_slug'] = $match['registry_slug'];
Comment thread
kmwilkerson marked this conversation as resolved.
$email['recipient'] = $match['recipient'];
$email['source'] = $match['source'];
} else {
$email['recommended'] = false;
$email['trigger_description'] = '';
$email['registry_slug'] = '';
$email['recipient'] = 'reader';
$email['source'] = 'newspack'; // Default; WooCommerce emails always match above.
}
$newspack_emails[] = $email;
Comment thread
thomasguillot marked this conversation as resolved.
}

// Sort: reader-revenue first, reader-activation second, woocommerce last.
// Within each group, preserve registry insertion order via a stable tiebreaker.
// Category strings originate from Reader_Revenue_Emails::add_email_configs(),
// Reader_Activation_Emails::add_email_configs(), and WooCommerce_Emails.
$category_order = [
'reader-revenue' => 0,
'reader-activation' => 1,
];
$slug_order = array_flip( array_keys( $registry ) );
usort(
$newspack_emails,
function ( $a, $b ) use ( $category_order, $slug_order ) {
$order_a = $category_order[ $a['category'] ?? '' ] ?? 2;
$order_b = $category_order[ $b['category'] ?? '' ] ?? 2;
if ( $order_a !== $order_b ) {
return $order_a - $order_b;
}
$idx_a = $slug_order[ $a['registry_slug'] ?? '' ] ?? PHP_INT_MAX;
$idx_b = $slug_order[ $b['registry_slug'] ?? '' ] ?? PHP_INT_MAX;
return $idx_a - $idx_b;
}
Comment thread
thomasguillot marked this conversation as resolved.
);

$settings['newspack_emails'] = $newspack_emails;
$settings['post_type'] = Emails::POST_TYPE;

return $settings;
}

Expand Down
1 change: 0 additions & 1 deletion includes/wizards/newspack/class-newspack-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ public function get_local_data() {
'dependencies' => [
'newspackNewsletters' => is_plugin_active( 'newspack-newsletters/newspack-newsletters.php' ),
],
'all' => Emails::get_emails( Reader_Activation::is_enabled() ? [] : array_values( Reader_Revenue_Emails::EMAIL_TYPES ), false ),
'postType' => Emails::POST_TYPE,
'isEmailEnhancementsActive' => WooCommerce_Emails::is_active(),
],
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/badge/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ $badge-colors: (
background-color: color-mix(in srgb, wp-colors.$white 90%, var(--base-color));
border-radius: 2px;
color: color-mix(in srgb, wp-colors.$black 50%, var(--base-color));
display: block;
display: inline-block;
flex: 0 0 auto;
font-size: 12px;
line-height: 1.5;
Expand Down
Loading
Loading