diff --git a/includes/wizards/newspack/class-emails-section.php b/includes/wizards/newspack/class-emails-section.php
index 148face613..fd5bd302c0 100644
--- a/includes/wizards/newspack/class-emails-section.php
+++ b/includes/wizards/newspack/class-emails-section.php
@@ -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;
@@ -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',
@@ -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 {
+ $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',
+ '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 );
}
/**
@@ -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'];
+ $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;
+ }
+
+ // 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;
+ }
+ );
+
+ $settings['newspack_emails'] = $newspack_emails;
+ $settings['post_type'] = Emails::POST_TYPE;
+
return $settings;
}
diff --git a/includes/wizards/newspack/class-newspack-settings.php b/includes/wizards/newspack/class-newspack-settings.php
index b8886a3d24..4e7b6e1180 100644
--- a/includes/wizards/newspack/class-newspack-settings.php
+++ b/includes/wizards/newspack/class-newspack-settings.php
@@ -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(),
],
diff --git a/packages/components/src/badge/style.scss b/packages/components/src/badge/style.scss
index 72615a38c3..241d4faad1 100644
--- a/packages/components/src/badge/style.scss
+++ b/packages/components/src/badge/style.scss
@@ -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;
diff --git a/src/wizards/newspack/views/settings/emails/emails.scss b/src/wizards/newspack/views/settings/emails/emails.scss
new file mode 100644
index 0000000000..6719a3d3e7
--- /dev/null
+++ b/src/wizards/newspack/views/settings/emails/emails.scss
@@ -0,0 +1,58 @@
+@use "~@wordpress/base-styles/colors" as wp-colors;
+
+// Full-width override for the emails tab.
+// The parent `.newspack-wizard__sections` constrains width via max-width.
+// Break out of that for the DataViews table.
+.newspack-emails-tab.newspack-wizard__sections {
+ max-width: 100%;
+}
+
+// DataViews search/filter/icon alignment.
+.newspack-emails-tab {
+ .dataviews__view-actions {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+ }
+
+ .dataviews__search {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ }
+}
+
+// Preview placeholder (grid view media field).
+.newspack-emails__preview-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 1;
+ width: 100%;
+ background: wp-colors.$gray-100;
+ border-radius: 4px;
+ color: wp-colors.$gray-700;
+}
+
+// Clickable name link in grid/table view.
+.newspack-emails__name-link {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+}
+
+// Trigger description sub-text under the title.
+// Two-line clamp keeps grid cards uniform while still showing enough context.
+.newspack-emails__trigger-description {
+ color: wp-colors.$gray-900;
+ font-size: 12px;
+ margin-top: 4px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
diff --git a/src/wizards/newspack/views/settings/emails/emails.test.js b/src/wizards/newspack/views/settings/emails/emails.test.js
new file mode 100644
index 0000000000..d9cf87108b
--- /dev/null
+++ b/src/wizards/newspack/views/settings/emails/emails.test.js
@@ -0,0 +1,346 @@
+// @jest-environment jsdom
+
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor } from '@testing-library/react';
+
+jest.mock( './emails.scss', () => ( {} ) );
+
+// Use mock-prefixed names so Jest's hoisted jest.mock can close over them.
+const mockWizardApiFetch = jest.fn();
+const mockResetError = jest.fn();
+let mockErrorMessage = null;
+
+jest.mock( '../../../../hooks/use-wizard-api-fetch', () => ( {
+ useWizardApiFetch: () => ( {
+ wizardApiFetch: ( ...args ) => mockWizardApiFetch( ...args ),
+ isFetching: false,
+ errorMessage: mockErrorMessage,
+ resetError: ( ...args ) => mockResetError( ...args ),
+ } ),
+} ) );
+
+jest.mock( '@wordpress/icons', () => ( {
+ Icon: ( { icon } ) => { icon },
+ envelope: 'envelope',
+} ) );
+
+jest.mock( '@wordpress/dataviews', () => ( {
+ filterSortAndPaginate: data => ( {
+ data,
+ paginationInfo: { totalItems: data.length, totalPages: 1 },
+ } ),
+} ) );
+
+// Use mock-prefixed name so Jest's hoisted jest.mock can close over it.
+let mockCapturedActions = [];
+
+jest.mock( '../../../../../../packages/components/src', () => {
+ function renderField( field, item ) {
+ if ( field.render ) {
+ return field.render( { item } );
+ }
+ if ( field.getValue ) {
+ return field.getValue( { item } );
+ }
+ return null;
+ }
+ return {
+ Badge: ( { text } ) => { text },
+ DataViews: ( { data, fields, actions } ) => {
+ mockCapturedActions = actions || [];
+ return (
+
+
+ { data.map( ( item, i ) => (
+
+ { fields.map( field => (
+ | { renderField( field, item ) } |
+ ) ) }
+
+ ) ) }
+
+
+ );
+ },
+ Card: ( { children } ) => { children }
,
+ Notice: ( { noticeText } ) => { noticeText }
,
+ utils: {
+ confirmAction: jest.fn( () => true ),
+ },
+ };
+} );
+
+jest.mock(
+ '../../../../wizards-plugin-card',
+ () =>
+ function MockPluginCard( props ) {
+ return { props.slug }
;
+ }
+);
+
+const mockEmails = [
+ {
+ label: 'Payment receipt',
+ post_id: 1,
+ edit_link: '/edit/1',
+ status: 'publish',
+ type: 'receipt',
+ category: 'reader-revenue',
+ trigger_description: 'Sent after a successful payment.',
+ registry_slug: 'receipt',
+ recipient: 'reader',
+ source: 'newspack',
+ },
+ {
+ label: 'Cancellation confirmation',
+ post_id: 2,
+ edit_link: '/edit/2',
+ status: 'publish',
+ type: 'cancellation',
+ category: 'reader-revenue',
+ trigger_description: 'Sent when a reader cancels their subscription.',
+ registry_slug: 'cancellation',
+ recipient: 'reader',
+ source: 'newspack',
+ },
+ {
+ label: 'Reader verification',
+ post_id: 3,
+ edit_link: '/edit/3',
+ status: 'publish',
+ type: 'reader-activation-verification',
+ category: 'reader-activation',
+ trigger_description: 'Sent when a reader needs to verify their email address.',
+ registry_slug: 'verification',
+ recipient: 'reader',
+ source: 'newspack',
+ },
+ {
+ label: 'Account deletion',
+ post_id: 4,
+ edit_link: '/edit/4',
+ status: 'draft',
+ type: 'reader-activation-delete-account',
+ category: 'reader-activation',
+ trigger_description: 'Sent when a reader requests to delete their account.',
+ registry_slug: 'delete-account',
+ recipient: 'reader',
+ source: 'newspack',
+ },
+ {
+ label: 'New order (admin)',
+ post_id: 5,
+ edit_link: '/edit/5',
+ status: 'publish',
+ type: 'new_order',
+ category: 'woocommerce',
+ trigger_description: 'Sent to the admin when a new order is placed.',
+ registry_slug: 'woo-new-order',
+ recipient: 'admin',
+ source: 'woocommerce',
+ },
+ {
+ label: 'Order on hold',
+ post_id: 6,
+ edit_link: '/edit/6',
+ status: 'draft',
+ type: 'customer_on_hold_order',
+ category: 'woocommerce',
+ trigger_description: 'Sent when an order is placed on hold.',
+ registry_slug: 'woo-on-hold-order',
+ recipient: 'reader',
+ source: 'woocommerce',
+ },
+];
+
+describe( 'Emails', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockErrorMessage = null;
+ window.newspackSettings = {
+ emails: {
+ sections: {
+ emails: {
+ dependencies: {
+ newspackNewsletters: true,
+ },
+ postType: 'newspack_rr_email',
+ isEmailEnhancementsActive: false,
+ },
+ },
+ },
+ };
+ mockWizardApiFetch.mockImplementation( ( opts, callbacks ) => {
+ if ( opts.path === '/newspack/v1/wizard/newspack-settings/emails' ) {
+ callbacks?.onSuccess?.( {
+ newspack_emails: mockEmails,
+ post_type: 'newspack_rr_email',
+ } );
+ }
+ return Promise.resolve();
+ } );
+ } );
+
+ it( 'renders all emails in a single view', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByText( 'Payment receipt' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Cancellation confirmation' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Reader verification' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Account deletion' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'New order (admin)' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Order on hold' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'renders Recipient column with correct values', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ const readerCells = screen.getAllByText( 'Reader' );
+ expect( readerCells.length ).toBeGreaterThanOrEqual( 4 );
+ expect( screen.getByText( 'Admin' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'renders status as Enabled / Disabled', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ const enabledCells = screen.getAllByText( 'Enabled' );
+ expect( enabledCells.length ).toBeGreaterThanOrEqual( 3 );
+ const disabledCells = screen.getAllByText( 'Disabled' );
+ expect( disabledCells.length ).toBeGreaterThanOrEqual( 2 );
+ } );
+ } );
+
+ it( 'deactivate action calls wizardApiFetch with draft status', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument();
+ } );
+
+ const deactivate = mockCapturedActions.find( a => a.id === 'deactivate' );
+ deactivate.callback( [ mockEmails[ 0 ] ] );
+
+ expect( mockWizardApiFetch ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ path: '/wp/v2/newspack_rr_email/1',
+ method: 'POST',
+ data: { status: 'draft' },
+ } ),
+ expect.objectContaining( {
+ onSuccess: expect.any( Function ),
+ } )
+ );
+ } );
+
+ it( 'displays error notice when hook reports an error', async () => {
+ mockErrorMessage = 'Something went wrong';
+
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'notice' ) ).toHaveTextContent( 'Something went wrong' );
+ } );
+ } );
+
+ it( 'activate action calls wizardApiFetch with publish status', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument();
+ } );
+
+ const activate = mockCapturedActions.find( a => a.id === 'activate' );
+ // mockEmails[3] (Account deletion) is newspack + draft — eligible for activate.
+ activate.callback( [ mockEmails[ 3 ] ] );
+
+ expect( mockWizardApiFetch ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ path: '/wp/v2/newspack_rr_email/4',
+ method: 'POST',
+ data: { status: 'publish' },
+ } ),
+ expect.objectContaining( {
+ onSuccess: expect.any( Function ),
+ } )
+ );
+ } );
+
+ it( 'deactivate/activate are not eligible for reader-activation or woocommerce emails', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument();
+ } );
+
+ const deactivate = mockCapturedActions.find( a => a.id === 'deactivate' );
+ const activate = mockCapturedActions.find( a => a.id === 'activate' );
+
+ // Reader-activation emails cannot be toggled.
+ expect( deactivate.isEligible( mockEmails[ 2 ] ) ).toBe( false );
+ // WooCommerce emails cannot be toggled.
+ expect( deactivate.isEligible( mockEmails[ 4 ] ) ).toBe( false );
+ // Newspack reader-revenue email can be deactivated.
+ expect( deactivate.isEligible( mockEmails[ 0 ] ) ).toBe( true );
+ // Draft reader-activation email cannot be activated.
+ expect( activate.isEligible( mockEmails[ 3 ] ) ).toBe( false );
+ // Draft WooCommerce email cannot be activated.
+ expect( activate.isEligible( mockEmails[ 5 ] ) ).toBe( false );
+ } );
+
+ it( 'reset action calls wizardApiFetch with DELETE after confirmation', async () => {
+ const { utils } = require( '../../../../../../packages/components/src' );
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument();
+ } );
+
+ const reset = mockCapturedActions.find( a => a.id === 'reset' );
+ reset.callback( [ mockEmails[ 0 ] ] );
+
+ expect( utils.confirmAction ).toHaveBeenCalled();
+
+ expect( mockWizardApiFetch ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ path: '/newspack/v1/wizard/newspack-audience-donations/emails/1',
+ method: 'DELETE',
+ } ),
+ expect.objectContaining( {
+ onSuccess: expect.any( Function ),
+ } )
+ );
+ } );
+
+ it( 'reset is eligible for newspack-source emails with a registry_slug', async () => {
+ const Emails = require( './emails' ).default;
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument();
+ } );
+
+ const reset = mockCapturedActions.find( a => a.id === 'reset' );
+ // Newspack email with registry_slug — eligible.
+ expect( reset.isEligible( mockEmails[ 0 ] ) ).toBe( true );
+ // WooCommerce email — not eligible.
+ expect( reset.isEligible( mockEmails[ 4 ] ) ).toBe( false );
+ // Email without registry_slug — not eligible.
+ expect( reset.isEligible( { ...mockEmails[ 0 ], registry_slug: '' } ) ).toBe( false );
+ } );
+} );
diff --git a/src/wizards/newspack/views/settings/emails/emails.tsx b/src/wizards/newspack/views/settings/emails/emails.tsx
index 144781df48..4ec8aaaded 100644
--- a/src/wizards/newspack/views/settings/emails/emails.tsx
+++ b/src/wizards/newspack/views/settings/emails/emails.tsx
@@ -6,77 +6,238 @@
* WordPress dependencies.
*/
import { __ } from '@wordpress/i18n';
-import { useState, Fragment } from '@wordpress/element';
+import { useState, useEffect, useMemo, Fragment } from '@wordpress/element';
+import { filterSortAndPaginate } from '@wordpress/dataviews';
+import type { Action, Field, View } from '@wordpress/dataviews';
+import { Icon, envelope } from '@wordpress/icons';
/**
* Internal dependencies.
*/
-import { Notice, utils } from '../../../../../../packages/components/src';
-import { useWizardApiFetch } from '../../../../hooks/use-wizard-api-fetch';
-import WizardsActionCard from '../../../../wizards-action-card';
+import { Badge, DataViews, Notice, utils } from '../../../../../../packages/components/src';
import WizardsPluginCard from '../../../../wizards-plugin-card';
+import { useWizardApiFetch } from '../../../../hooks/use-wizard-api-fetch';
+import './emails.scss';
+
+interface EmailItem {
+ label: string;
+ post_id: number;
+ edit_link: string;
+ status: string;
+ type: string;
+ category: string;
+ trigger_description: string;
+ registry_slug: string;
+ recipient: 'reader' | 'admin';
+ source: 'newspack' | 'woocommerce';
+}
+
+interface EmailSettings {
+ newspack_emails: EmailItem[];
+ post_type: string;
+ admin_url?: string;
+ enable_woocommerce_email_editor?: boolean;
+}
+
+const DEFAULT_VIEW: View = {
+ type: 'grid',
+ page: 1,
+ perPage: 50,
+ search: '',
+ fields: [ 'recipient', 'status' ],
+ filters: [],
+ layout: {},
+ titleField: 'name',
+ descriptionField: 'trigger_description',
+ mediaField: 'preview',
+};
+
+const PageHeading = () => { __( 'Emails', 'newspack-plugin' ) }
;
const Emails = () => {
const emailSections = window.newspackSettings.emails.sections;
- const postType = emailSections.emails.postType;
+ const [ pluginsReady, setPluginsReady ] = useState( Boolean( emailSections.emails.dependencies.newspackNewsletters ) );
- const [ pluginsReady, setPluginsReady ] = useState( emailSections.emails.dependencies.newspackNewsletters );
+ const [ data, setData ] = useState< EmailItem[] >( [] );
+ const [ postType, setPostType ] = useState< string >( emailSections.emails.postType );
+ const [ view, setView ] = useState< View >( DEFAULT_VIEW );
const { wizardApiFetch, isFetching, errorMessage, resetError } = useWizardApiFetch( 'newspack-settings/emails' );
- const [ emails, setEmails ] = useState( Object.values( emailSections.emails.all ) );
+ const fetchData = () => {
+ resetError();
+ wizardApiFetch< EmailSettings >(
+ {
+ path: '/newspack/v1/wizard/newspack-settings/emails',
+ isCached: false,
+ },
+ {
+ onSuccess( result: EmailSettings ) {
+ setData( result.newspack_emails || [] );
+ if ( result.post_type ) {
+ setPostType( result.post_type );
+ }
+ },
+ }
+ );
+ };
+
+ useEffect( fetchData, [] );
- const updateStatus = ( postId: number, status: string ) => {
+ const updateStatus = ( postId: number, nextStatus: string ) => {
+ resetError();
wizardApiFetch(
{
path: `/wp/v2/${ postType }/${ postId }`,
method: 'POST',
- data: { status },
+ data: { status: nextStatus },
},
{
- onStart() {
- resetError();
- },
- onSuccess() {
- setEmails(
- emails.map( email => {
- if ( email.post_id === postId ) {
- return { ...email, status };
- }
- return email;
- } )
- );
- },
+ onSuccess: () => fetchData(),
}
);
};
const resetEmail = ( postId: number ) => {
+ resetError();
+ // @todo NPPD-1532 Move reset handler to class-emails-section.php so it
+ // lives under wizard/newspack-settings/emails/{id} instead of reaching
+ // into the donations wizard namespace.
wizardApiFetch(
{
path: `/newspack/v1/wizard/newspack-audience-donations/emails/${ postId }`,
method: 'DELETE',
},
{
- onSuccess( result ) {
- window.newspackSettings.emails.sections.emails.all = result;
- setEmails( Object.values( result ) );
- },
+ onSuccess: () => fetchData(),
}
);
};
+ const fields: Field< EmailItem >[] = useMemo(
+ () => [
+ {
+ id: 'preview',
+ label: __( 'Preview', 'newspack-plugin' ),
+ type: 'media',
+ enableSorting: false,
+ enableHiding: true,
+ // @todo NPPD-1525 Replace with component.
+ render: ( { item }: { item: EmailItem } ) => (
+
+
+
+ ),
+ },
+ {
+ id: 'name',
+ label: __( 'Email', 'newspack-plugin' ),
+ enableGlobalSearch: true,
+ getValue: ( { item }: { item: EmailItem } ) => item.label,
+ render: ( { item }: { item: EmailItem } ) => (
+
+ { item.label }
+
+ ),
+ },
+ {
+ id: 'trigger_description',
+ label: __( 'Description', 'newspack-plugin' ),
+ enableGlobalSearch: true,
+ getValue: ( { item }: { item: EmailItem } ) => item.trigger_description,
+ render: ( { item }: { item: EmailItem } ) => (
+ { item.trigger_description }
+ ),
+ enableHiding: false,
+ enableSorting: false,
+ },
+ {
+ id: 'recipient',
+ label: __( 'Recipient', 'newspack-plugin' ),
+ enableGlobalSearch: true,
+ getValue: ( { item }: { item: EmailItem } ) =>
+ item.recipient === 'admin' ? __( 'Admin', 'newspack-plugin' ) : __( 'Reader', 'newspack-plugin' ),
+ render: ( { item }: { item: EmailItem } ) => (
+ { item.recipient === 'admin' ? __( 'Admin', 'newspack-plugin' ) : __( 'Reader', 'newspack-plugin' ) }
+ ),
+ },
+ {
+ id: 'status',
+ label: __( 'Status', 'newspack-plugin' ),
+ getValue: ( { item }: { item: EmailItem } ) => item.status,
+ render: ( { item }: { item: EmailItem } ) => {
+ const isEnabled = item.status === 'publish';
+ return (
+
+ );
+ },
+ elements: [
+ { value: 'publish', label: __( 'Enabled', 'newspack-plugin' ) },
+ { value: 'draft', label: __( 'Disabled', 'newspack-plugin' ) },
+ ],
+ filterBy: { isPrimary: false, operators: [ 'is' ] },
+ },
+ ],
+ []
+ );
+
+ const actions: Action< EmailItem >[] = [
+ {
+ id: 'edit',
+ label: __( 'Edit', 'newspack-plugin' ),
+ callback: ( items: EmailItem[] ) => {
+ window.location.href = items[ 0 ].edit_link;
+ },
+ },
+ {
+ id: 'deactivate',
+ label: __( 'Deactivate', 'newspack-plugin' ),
+ isEligible: ( item: EmailItem ) => item.source !== 'woocommerce' && item.category !== 'reader-activation' && item.status === 'publish',
+ callback: ( items: EmailItem[] ) => {
+ updateStatus( items[ 0 ].post_id, 'draft' );
+ },
+ },
+ {
+ id: 'activate',
+ label: __( 'Activate', 'newspack-plugin' ),
+ isEligible: ( item: EmailItem ) => item.source !== 'woocommerce' && item.category !== 'reader-activation' && item.status !== 'publish',
+ callback: ( items: EmailItem[] ) => {
+ updateStatus( items[ 0 ].post_id, 'publish' );
+ },
+ },
+ {
+ id: 'reset',
+ label: __( 'Reset', 'newspack-plugin' ),
+ isDestructive: true,
+ isEligible: ( item: EmailItem ) => item.source !== 'woocommerce' && Boolean( item.registry_slug ),
+ callback: ( items: EmailItem[] ) => {
+ if ( utils.confirmAction( __( 'Are you sure you want to reset the contents of this email?', 'newspack-plugin' ) ) ) {
+ resetEmail( items[ 0 ].post_id );
+ }
+ },
+ },
+ ];
+
+ const { data: processedData, paginationInfo } = useMemo( () => filterSortAndPaginate( data, view, fields ), [ data, view, fields ] );
+
if ( false === pluginsReady ) {
return (
-
- { __(
- 'Newspack uses Newspack Newsletters to handle editing email-type content. Please activate this plugin to proceed.',
- 'newspack-plugin'
- ) }
-
- { __( 'Until this feature is configured, default receipts will be used.', 'newspack-plugin' ) }
-
+
+
{
return (
- { emails.map( email => {
- const isActive = email.status === 'publish';
- const isAudience = email.category === 'reader-activation';
- let notification = __( 'This email is not active.', 'newspack-plugin' );
- if ( email.type === 'receipt' ) {
- notification = __( 'This email is not active. The default receipt will be used.', 'newspack-plugin' );
- }
- if ( email.type === 'welcome' ) {
- notification = __( 'This email is not active. The receipt template will be used if active.', 'newspack-plugin' );
- }
- return (
- {
- if ( utils.confirmAction( __( 'Are you sure you want to reset the contents of this email?', 'newspack-plugin' ) ) ) {
- resetEmail( email.post_id );
- }
- } }
- secondaryDestructive={ true }
- { ...( isAudience
- ? {}
- : {
- toggleChecked: isActive,
- toggleOnChange: value => updateStatus( email.post_id, value ? 'publish' : 'draft' ),
- } ) }
- { ...( isActive
- ? {}
- : {
- notification,
- notificationLevel: 'info',
- } ) }
- >
- { errorMessage && }
-
- );
- } ) }
+
+ { errorMessage && }
+ String( item.post_id ) }
+ search
+ />
);
};
diff --git a/src/wizards/newspack/views/settings/emails/index.tsx b/src/wizards/newspack/views/settings/emails/index.tsx
index aea7f446a0..6f6d6cdaec 100644
--- a/src/wizards/newspack/views/settings/emails/index.tsx
+++ b/src/wizards/newspack/views/settings/emails/index.tsx
@@ -2,11 +2,6 @@
* Newspack > Settings > Emails
*/
-/**
- * WordPress dependencies.
- */
-import { __ } from '@wordpress/i18n';
-
/**
* Internal dependencies.
*/
@@ -19,7 +14,7 @@ const { emails } = window.newspackSettings;
function Emails() {
return (
-
+
diff --git a/tests/unit-tests/emails-section.php b/tests/unit-tests/emails-section.php
new file mode 100644
index 0000000000..256219a44a
--- /dev/null
+++ b/tests/unit-tests/emails-section.php
@@ -0,0 +1,253 @@
+ $entry ) {
+ foreach ( $required_keys as $key ) {
+ $this->assertArrayHasKey( $key, $entry, "Entry '$slug' is missing required key '$key'." );
+ }
+ }
+ }
+
+ /**
+ * Test all registry entries have a valid source value.
+ */
+ public function test_registry_entries_have_valid_source() {
+ $registry = Emails_Section::get_email_registry();
+ foreach ( $registry as $slug => $entry ) {
+ $this->assertContains( $entry['source'], [ 'newspack', 'woocommerce' ], "Entry '$slug' has an invalid source value." );
+ }
+ }
+
+ /**
+ * Test all registry entries have a valid recipient value.
+ */
+ public function test_registry_entries_have_valid_recipient() {
+ $registry = Emails_Section::get_email_registry();
+ foreach ( $registry as $slug => $entry ) {
+ $this->assertContains( $entry['recipient'], [ 'reader', 'admin' ], "Entry '$slug' has an invalid recipient value." );
+ }
+ }
+
+ /**
+ * Test all registry entries have non-empty labels and trigger descriptions.
+ */
+ public function test_registry_entries_have_labels_and_triggers() {
+ $registry = Emails_Section::get_email_registry();
+ foreach ( $registry as $slug => $entry ) {
+ $this->assertNotEmpty( $entry['label'], "Entry '$slug' is missing a label." );
+ $this->assertNotEmpty( $entry['trigger_description'], "Entry '$slug' is missing a trigger_description." );
+ }
+ }
+
+ /**
+ * Test recommended is always a boolean.
+ */
+ public function test_registry_recommended_is_boolean() {
+ $registry = Emails_Section::get_email_registry();
+ foreach ( $registry as $slug => $entry ) {
+ $this->assertIsBool( $entry['recommended'], "Entry '$slug' has a non-boolean recommended value." );
+ }
+ }
+
+ /**
+ * Test each entry has either newspack_type or woo_email_id, never both.
+ */
+ public function test_registry_entries_have_exclusive_type_keys() {
+ $registry = Emails_Section::get_email_registry();
+ foreach ( $registry as $slug => $entry ) {
+ $has_newspack = isset( $entry['newspack_type'] );
+ $has_woo = isset( $entry['woo_email_id'] );
+ $this->assertTrue( $has_newspack || $has_woo, "Entry '$slug' has neither newspack_type nor woo_email_id." );
+ $this->assertFalse( $has_newspack && $has_woo, "Entry '$slug' has both newspack_type and woo_email_id." );
+ }
+ }
+
+ /**
+ * Test newspack-source entries have newspack_type and woocommerce-source entries have woo_email_id.
+ */
+ public function test_registry_source_matches_type_key() {
+ $registry = Emails_Section::get_email_registry();
+ foreach ( $registry as $slug => $entry ) {
+ if ( 'newspack' === $entry['source'] ) {
+ $this->assertArrayHasKey( 'newspack_type', $entry, "Newspack-source entry '$slug' is missing newspack_type." );
+ $this->assertNotEmpty( $entry['newspack_type'], "Newspack-source entry '$slug' has empty newspack_type." );
+ }
+ if ( 'woocommerce' === $entry['source'] ) {
+ $this->assertArrayHasKey( 'woo_email_id', $entry, "WooCommerce-source entry '$slug' is missing woo_email_id." );
+ $this->assertNotEmpty( $entry['woo_email_id'], "WooCommerce-source entry '$slug' has empty woo_email_id." );
+ }
+ }
+ }
+
+ /**
+ * Test no duplicate newspack_type or woo_email_id values across entries.
+ */
+ public function test_registry_no_duplicate_type_values() {
+ $registry = Emails_Section::get_email_registry();
+ $newspack_types = [];
+ $woo_ids = [];
+
+ foreach ( $registry as $slug => $entry ) {
+ if ( isset( $entry['newspack_type'] ) ) {
+ $this->assertNotContains( $entry['newspack_type'], $newspack_types, "Duplicate newspack_type '{$entry['newspack_type']}' in entry '$slug'." );
+ $newspack_types[] = $entry['newspack_type'];
+ }
+ if ( isset( $entry['woo_email_id'] ) ) {
+ $this->assertNotContains( $entry['woo_email_id'], $woo_ids, "Duplicate woo_email_id '{$entry['woo_email_id']}' in entry '$slug'." );
+ $woo_ids[] = $entry['woo_email_id'];
+ }
+ }
+ }
+
+ /**
+ * Test api_get_email_settings returns the expected response shape.
+ */
+ public function test_api_get_email_settings_response_shape() {
+ $result = Emails_Section::api_get_email_settings();
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'newspack_emails', $result );
+ $this->assertArrayHasKey( 'post_type', $result );
+ $this->assertIsArray( $result['newspack_emails'] );
+
+ if ( class_exists( 'WooCommerce' ) ) {
+ $this->assertArrayHasKey( 'admin_url', $result );
+ $this->assertArrayHasKey( 'enable_woocommerce_email_editor', $result );
+ }
+
+ // Verify enriched fields on each Newspack email that has a registry_slug.
+ $enriched_keys = [ 'label', 'recommended', 'trigger_description', 'registry_slug', 'recipient', 'source' ];
+ $enriched_count = 0;
+ foreach ( $result['newspack_emails'] as $email ) {
+ if ( empty( $email['registry_slug'] ) ) {
+ // Fallback branch: verify defaults are set.
+ $this->assertArrayHasKey( 'recommended', $email, 'Fallback email is missing recommended.' );
+ $this->assertFalse( $email['recommended'], 'Fallback email should have recommended=false.' );
+ $this->assertArrayHasKey( 'source', $email, 'Fallback email is missing source.' );
+ continue;
+ }
+ ++$enriched_count;
+ foreach ( $enriched_keys as $key ) {
+ $this->assertArrayHasKey( $key, $email, "Email '{$email['label']}' is missing enriched field '$key'." );
+ }
+ }
+ $this->assertGreaterThan( 0, $enriched_count, 'Expected at least one enriched email in the response, but found none.' );
+ }
+
+ /**
+ * Test sort order: reader-revenue first, reader-activation second, other categories last.
+ */
+ public function test_api_get_email_settings_sort_order() {
+ $result = Emails_Section::api_get_email_settings();
+ $categories = array_column( $result['newspack_emails'], 'category' );
+
+ // Build the expected group order: reader-revenue → reader-activation → everything else.
+ $last_group = -1;
+ $group_map = [
+ 'reader-revenue' => 0,
+ 'reader-activation' => 1,
+ ];
+ foreach ( $categories as $i => $cat ) {
+ $group = $group_map[ $cat ] ?? 2;
+ $this->assertGreaterThanOrEqual( $last_group, $group, "Email at index $i (category '$cat') is out of sort order." );
+ $last_group = $group;
+ }
+ }
+
+ /**
+ * Test admin-recipient emails are correctly classified.
+ */
+ public function test_admin_recipient_emails() {
+ $registry = Emails_Section::get_email_registry();
+ $admin_slugs = array_keys(
+ array_filter(
+ $registry,
+ function ( $entry ) {
+ return 'admin' === $entry['recipient'];
+ }
+ )
+ );
+ $this->assertContains( 'woo-new-order', $admin_slugs, 'new_order is an admin email.' );
+ // woo-subscription-cancelled is reader-facing (Newspack notifies the reader);
+ // the separate WC admin notification is handled by WooCommerce core.
+ $this->assertNotContains( 'woo-subscription-cancelled', $admin_slugs );
+ }
+
+ /**
+ * Test that the newspack_emails_registry filter can add entries.
+ */
+ public function test_emails_registry_filter() {
+ $fake_entry = [
+ 'source' => 'newspack',
+ 'newspack_type' => 'test-filter-email',
+ 'recommended' => false,
+ 'plugin_dependency' => null,
+ 'recipient' => 'reader',
+ 'label' => 'Test filter email',
+ 'trigger_description' => 'Added via filter.',
+ ];
+
+ $callback = function ( $registry ) use ( $fake_entry ) {
+ $registry['test-filter-email'] = $fake_entry;
+ return $registry;
+ };
+
+ add_filter( 'newspack_emails_registry', $callback );
+ $registry = Emails_Section::get_email_registry();
+ remove_filter( 'newspack_emails_registry', $callback );
+
+ $this->assertArrayHasKey( 'test-filter-email', $registry, 'Filter-added entry should be present in the registry.' );
+ $this->assertSame( $fake_entry, $registry['test-filter-email'] );
+ }
+
+ /**
+ * Test registry insertion order within source groups.
+ *
+ * The UI relies on registry order to determine display order within
+ * each category group (reader-revenue, reader-activation, woocommerce).
+ */
+ public function test_registry_order_within_groups() {
+ $slugs = array_keys( Emails_Section::get_email_registry() );
+
+ // Reader-revenue group: receipt → welcome → cancellation.
+ $this->assertLessThan(
+ array_search( 'welcome', $slugs, true ),
+ array_search( 'receipt', $slugs, true ),
+ 'receipt should appear before welcome.'
+ );
+ $this->assertLessThan(
+ array_search( 'cancellation', $slugs, true ),
+ array_search( 'welcome', $slugs, true ),
+ 'welcome should appear before cancellation.'
+ );
+
+ // Reader-activation group: verification → login-link → set-new-password.
+ $this->assertLessThan(
+ array_search( 'login-link', $slugs, true ),
+ array_search( 'verification', $slugs, true ),
+ 'verification should appear before login-link.'
+ );
+ $this->assertLessThan(
+ array_search( 'set-new-password', $slugs, true ),
+ array_search( 'login-link', $slugs, true ),
+ 'login-link should appear before set-new-password.'
+ );
+ }
+}