diff --git a/includes/emails/class-emails.php b/includes/emails/class-emails.php index 217a29bf50..420e815767 100644 --- a/includes/emails/class-emails.php +++ b/includes/emails/class-emails.php @@ -372,11 +372,43 @@ public static function can_send_email( $type ) { return true; } + /** + * Default values for email config fields shared across all providers. + */ + const EMAIL_CONFIG_DEFAULTS = [ + 'trigger_description' => '', + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'auth-account', + ]; + + /** + * Fill in default values for any email config field a provider omitted. + * + * @param array $config Single email config entry as registered via the + * `newspack_email_configs` filter. + * @return array Config with shared defaults applied for missing fields. + */ + public static function apply_config_defaults( array $config ): array { + return array_merge( self::EMAIL_CONFIG_DEFAULTS, $config ); + } + /** * Get all email configs. + * + * Returns the merged config set from the `newspack_email_configs` + * filter with shared defaults applied to each entry. Public so + * downstream consumers (e.g. the wizard response builder) can read + * the unified set without re-running the filter. + * + * @return array Configs keyed by type, each merged with the shared defaults. */ - private static function get_email_configs() { - return apply_filters( 'newspack_email_configs', [] ); + public static function get_email_configs() { + $configs = apply_filters( 'newspack_email_configs', [] ); + foreach ( $configs as $type => $config ) { + $configs[ $type ] = self::apply_config_defaults( $config ); + } + return $configs; } /** @@ -399,11 +431,15 @@ private static function serialize_email( $type = null, $post_id = 0 ) { } $email_config = $configs[ $type ]; } else { - $email_config = [ - 'label' => '', - 'description' => '', - 'category' => '', - ]; + // Fallback config for the null-type branch. Apply the shared + // defaults so the serialized output shape stays uniform. + $email_config = self::apply_config_defaults( + [ + 'label' => '', + 'description' => '', + 'category' => '', + ] + ); } $html_payload = get_post_meta( $post_id, \Newspack_Newsletters::EMAIL_HTML_META, true ); if ( ! $html_payload || empty( $html_payload ) ) { @@ -416,18 +452,23 @@ private static function serialize_email( $type = null, $post_id = 0 ) { $edit_link = str_replace( site_url(), '', $post_link ); } $serialized_email = [ - 'type' => $type, - 'category' => $email_config['category'], - 'label' => $email_config['label'], - 'description' => $email_config['description'], - 'post_id' => $post_id, - 'edit_link' => $edit_link, - 'subject' => get_the_title( $post_id ), - 'from_name' => isset( $email_config['from_name'] ) ? $email_config['from_name'] : self::get_from_name(), - 'from_email' => isset( $email_config['from_email'] ) ? $email_config['from_email'] : self::get_from_email(), - 'reply_to_email' => isset( $email_config['reply_to_email'] ) ? $email_config['reply_to_email'] : self::get_reply_to_email(), - 'status' => get_post_status( $post_id ), - 'html_payload' => $html_payload, + 'type' => $type, + 'category' => $email_config['category'], + 'label' => $email_config['label'], + 'description' => $email_config['description'], + 'post_id' => $post_id, + 'edit_link' => $edit_link, + 'subject' => get_the_title( $post_id ), + 'from_name' => isset( $email_config['from_name'] ) ? $email_config['from_name'] : self::get_from_name(), + 'from_email' => isset( $email_config['from_email'] ) ? $email_config['from_email'] : self::get_from_email(), + 'reply_to_email' => isset( $email_config['reply_to_email'] ) ? $email_config['reply_to_email'] : self::get_reply_to_email(), + 'status' => get_post_status( $post_id ), + 'html_payload' => $html_payload, + 'trigger_description' => $email_config['trigger_description'], + 'recipient' => $email_config['recipient'], + 'recommended' => $email_config['recommended'], + 'chip' => $email_config['chip'], + 'source' => isset( $email_config['source'] ) ? $email_config['source'] : 'newspack', ]; return $serialized_email; diff --git a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php index 10aab7a2f1..e3bf04925f 100644 --- a/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php +++ b/includes/plugins/woocommerce-subscriptions/group-subscription/class-group-subscription-invite.php @@ -80,6 +80,10 @@ public static function add_email_config( $configs ) { 'description' => __( 'Email sent to invite a reader to join a group subscription.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/group-subscription-invite.php', 'editor_notice' => __( 'This email will be sent when a reader is invited to join a group subscription.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to invite a reader to join a group subscription.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => false, + 'chip' => 'reader-revenue', 'available_placeholders' => [ [ 'label' => __( 'the site title', 'newspack-plugin' ), diff --git a/includes/plugins/woocommerce/class-woocommerce-emails.php b/includes/plugins/woocommerce/class-woocommerce-emails.php index 4bdcf69af8..af1f35bf03 100644 --- a/includes/plugins/woocommerce/class-woocommerce-emails.php +++ b/includes/plugins/woocommerce/class-woocommerce-emails.php @@ -29,12 +29,161 @@ class WooCommerce_Emails { */ const WOOCOMMERCE_EMAILS_UPDATED_OPTION = 'newspack_woocommerce_block_editor_emails_updated_to_latest'; + /** + * Curated metadata for WooCommerce emails surfaced in the unified + * Emails UI, keyed by WC_Email->id. Unrecognized WC emails (those + * WC()->mailer()->get_emails() returns but this table doesn't list) + * are silently skipped at registration time, as are entries whose + * `plugin_dependency` isn't active. + * + * `plugin_dependency` is the plugin slug; the full file path is built + * at check time as "{slug}/{slug}.php". Use null for emails available + * from core WooCommerce. + * + * Note: this method returns the table (rather than a const) so the + * `label` and `trigger_description` strings can use __() — PHP < 8.2 + * does not allow function calls in const arrays. + * + * @return array Metadata table keyed by WC_Email->id. + */ + private static function get_surfaced_wc_emails() { + return [ + // WooCommerce core — available whenever WC is active. + 'customer_new_account' => [ + 'chip' => 'auth-account', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => null, + 'label' => __( 'New account', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a customer creates a new account.', 'newspack-plugin' ), + ], + 'customer_refunded_order' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => false, + 'plugin_dependency' => null, + 'label' => __( 'Order refund', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when an order is refunded.', 'newspack-plugin' ), + ], + 'new_order' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'admin', + 'recommended' => false, + 'plugin_dependency' => null, + 'label' => __( 'New order', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the admin when a new order is placed.', 'newspack-plugin' ), + ], + // WooCommerce Subscriptions. + 'customer_notification_auto_renewal' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'label' => __( 'Renewal reminder', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent before automatic renewal (timing depends on WooCommerce Subscriptions settings).', 'newspack-plugin' ), + ], + 'customer_payment_retry' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'label' => __( 'Failed order retry', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a renewal payment fails, before the retry attempt.', 'newspack-plugin' ), + ], + 'expired_subscription' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'label' => __( 'Subscription expired', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a subscription reaches its expiration date.', 'newspack-plugin' ), + ], + 'customer_completed_switch_order' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'label' => __( 'Subscription switch complete', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader switches their subscription.', 'newspack-plugin' ), + ], + // WooCommerce Subscriptions Gifting (bundled into WC Subs core). + 'WCSG_Email_Customer_New_Account' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'label' => __( 'New giftee account', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the giftee when a gift subscription creates their account.', 'newspack-plugin' ), + ], + 'recipient_completed_order' => [ + 'chip' => 'reader-revenue', + 'recipient' => 'reader', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'label' => __( 'New gift order', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the giftee to notify them of a gift subscription.', 'newspack-plugin' ), + ], + ]; + } + /** * Initialize hooks. */ public static function init() { add_filter( 'option_woocommerce_feature_block_email_editor_enabled', [ __CLASS__, 'override_woocommerce_email_editor_option' ], 10, 2 ); add_action( 'admin_init', [ __CLASS__, 'update_woocommerce_emails_to_latest' ] ); + add_filter( 'newspack_email_configs', [ __CLASS__, 'get_email_configs' ] ); + } + + /** + * Inject surfaced WooCommerce emails into the unified email config set. + * + * Discovery-based: iterates WC()->mailer()->get_emails() and looks up + * each instance's id in self::SURFACED_WC_EMAILS. Unrecognized WC + * emails are silently skipped; that's the contract — only emails we + * explicitly curate appear in the unified UI. Entries whose + * plugin_dependency isn't active are also skipped. + * + * Each registered config carries the live WC_Email instance as + * `wc_email_instance` so downstream code (toggle endpoint, first-run + * auto-enable, response builder) can call instance methods directly + * instead of re-resolving via WC()->mailer(). + * + * @param array $configs Existing email configs from upstream providers. + * @return array Configs with surfaced WC emails added. + */ + public static function get_email_configs( $configs ) { + if ( ! class_exists( 'WooCommerce' ) || ! function_exists( 'WC' ) ) { + return $configs; + } + $surfaced = self::get_surfaced_wc_emails(); + $wc_emails = \WC()->mailer()->get_emails(); + foreach ( $wc_emails as $wc_email ) { + if ( ! isset( $surfaced[ $wc_email->id ] ) ) { + continue; + } + $meta = $surfaced[ $wc_email->id ]; + if ( ! empty( $meta['plugin_dependency'] ) ) { + $plugin_file = $meta['plugin_dependency'] . '/' . $meta['plugin_dependency'] . '.php'; + if ( ! \Newspack\is_plugin_active( $plugin_file ) ) { + continue; + } + } + $configs[ $wc_email->id ] = [ + 'name' => $wc_email->id, + 'category' => 'woocommerce', + 'source' => 'woocommerce', + 'label' => $meta['label'], + 'description' => $meta['trigger_description'], + 'trigger_description' => $meta['trigger_description'], + 'recipient' => $meta['recipient'], + 'recommended' => $meta['recommended'], + 'chip' => $meta['chip'], + 'plugin_dependency' => $meta['plugin_dependency'], + 'wc_email_instance' => $wc_email, + ]; + } + return $configs; } /** diff --git a/includes/reader-activation/class-reader-activation-emails.php b/includes/reader-activation/class-reader-activation-emails.php index 650d30ec88..e6ee1faa2b 100644 --- a/includes/reader-activation/class-reader-activation-emails.php +++ b/includes/reader-activation/class-reader-activation-emails.php @@ -76,6 +76,10 @@ public static function add_email_configs( $configs ) { 'description' => __( "Email sent to the reader after they've registered.", 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/verification.php', 'editor_notice' => __( 'This email will be sent to a reader after they\'ve registered.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader needs to verify their email address.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -93,6 +97,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email sent to users with a login link generated by Admin user or when one-time password is disabled.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/magic-link.php', 'editor_notice' => __( 'This email will be sent to a reader when they request a login link.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader requests a magic login link.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -110,6 +118,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email sent to users with a one-time password and login link.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/otp.php', 'editor_notice' => __( 'This email will be sent to a reader when they request a login link and a one-time password is available.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader logs in with a one-time password.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -135,6 +147,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email with password reset link.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/password-reset.php', 'editor_notice' => __( 'This email will be sent to a reader when they request a password creation or reset.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader requests a password reset.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -152,6 +168,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email with account deletion link.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/delete-account.php', 'editor_notice' => __( 'This email will be sent to a reader when they request an account deletion.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader requests to delete their account.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => false, + 'chip' => 'auth-account', 'available_placeholders' => [ [ 'label' => __( 'the account deletion link', 'newspack-plugin' ), @@ -167,6 +187,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email sent to the reader to confirm or cancel a request to update their email address.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/change-email.php', 'editor_notice' => __( 'This email will be sent to a reader\'s new email address after they update their email address.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the new address to confirm an email change.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => false, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -188,6 +212,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email sent to notify the reader of a request to update their email addresses.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/change-email-cancel.php', 'editor_notice' => __( 'This email will be sent to a reader\'s existing email address after they update their email address.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the old address when a reader changes their email.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => false, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -210,6 +238,10 @@ public static function add_email_configs( $configs ) { 'description' => __( 'Email reminder sent to non-reader accounts instead of reader account-related emails.', 'newspack-plugin' ), 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-activation-emails/non-reader.php', 'editor_notice' => __( 'This email will be sent to non-reader WP user accounts as a reminder to use standard WP login flows.', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a non-reader WordPress user tries to log in as a reader.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => false, + 'chip' => 'auth-account', 'available_placeholders' => array_merge( $available_placeholders, [ diff --git a/includes/reader-revenue/class-reader-revenue-emails.php b/includes/reader-revenue/class-reader-revenue-emails.php index 59b07d8df6..1b1007b37b 100644 --- a/includes/reader-revenue/class-reader-revenue-emails.php +++ b/includes/reader-revenue/class-reader-revenue-emails.php @@ -100,6 +100,10 @@ public static function add_email_configs( $configs ) { 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-revenue-emails/receipt.php', 'editor_notice' => __( 'This email will be sent to a reader after they contribute to your site.', 'newspack-plugin' ), 'from_email' => self::get_from_email(), + 'trigger_description' => __( 'Sent after a successful payment.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'reader-revenue', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -130,6 +134,10 @@ public static function add_email_configs( $configs ) { 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-revenue-emails/welcome.php', 'editor_notice' => __( 'This email will be sent to readers when they register an account during a transaction.', 'newspack-plugin' ), 'from_email' => self::get_from_email(), + 'trigger_description' => __( 'Sent to new supporters after their first payment.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'reader-revenue', 'available_placeholders' => array_merge( $available_placeholders, [ @@ -164,6 +172,10 @@ public static function add_email_configs( $configs ) { 'template' => dirname( NEWSPACK_PLUGIN_FILE ) . '/includes/templates/reader-revenue-emails/cancellation.php', 'editor_notice' => __( 'This email will be sent to a reader after they cancel a recurring donation.', 'newspack-plugin' ), 'from_email' => self::get_from_email(), + 'trigger_description' => __( 'Sent when a reader cancels their subscription.', 'newspack-plugin' ), + 'recipient' => 'reader', + 'recommended' => true, + 'chip' => 'reader-revenue', 'available_placeholders' => array_merge( $available_placeholders, [ diff --git a/includes/wizards/newspack/class-emails-section.php b/includes/wizards/newspack/class-emails-section.php index fd5bd302c0..c34543f46a 100644 --- a/includes/wizards/newspack/class-emails-section.php +++ b/includes/wizards/newspack/class-emails-section.php @@ -60,239 +60,6 @@ public function register_rest_routes() { } } - /** - * 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 ); - } - /** * Get email settings. * @@ -305,61 +72,87 @@ public static function api_get_email_settings(): array { $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 ); + $configs = Emails::get_email_configs(); - // 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-source rows: scope to the configs whose source resolves + // to 'newspack' (defaulting unspecified to 'newspack'), then defer + // to Emails::get_emails() for post resolution + serialization. + // serialize_email() now forwards the four new schema fields, so the + // returned rows already carry trigger_description, recipient, + // recommended, chip, and source. + $newspack_types = []; + foreach ( $configs as $type => $config ) { + $source = isset( $config['source'] ) ? $config['source'] : 'newspack'; + if ( 'newspack' !== $source ) { + continue; + } + if ( ! Reader_Activation::is_enabled() && ! in_array( $type, array_values( Reader_Revenue_Emails::EMAIL_TYPES ), true ) ) { + continue; } + $newspack_types[] = $type; } - + $emails = Emails::get_emails( $newspack_types, false ); $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. + $email['registry_slug'] = $type; + $newspack_emails[] = $email; + } + + // WooCommerce-source rows: walk the unified configs and build rows + // directly from each $config['wc_email_instance']. The WC integration + // (WooCommerce_Emails::get_email_configs()) already gates on plugin + // dependencies and skips entries WC doesn't surface, so reaching this + // loop means the email is supposed to appear. + foreach ( $configs as $type => $config ) { + $source = isset( $config['source'] ) ? $config['source'] : 'newspack'; + if ( 'woocommerce' !== $source ) { + continue; + } + $wc_email = isset( $config['wc_email_instance'] ) ? $config['wc_email_instance'] : null; + if ( ! $wc_email ) { + continue; } - $newspack_emails[] = $email; + // Read enabled state from the option rather than the in-memory + // WC_Email::$enabled property, which can be stale after WC option + // writes within the same request. + $option_key = $wc_email->get_option_key(); + $wc_options = (array) get_option( $option_key, [] ); + $is_enabled = isset( $wc_options['enabled'] ) ? 'yes' === $wc_options['enabled'] : 'yes' === $wc_email->enabled; + $wc_email_class = get_class( $wc_email ); + $newspack_emails[] = [ + 'label' => $config['label'], + 'post_id' => 'wc:' . $type, + 'edit_link' => self::get_wc_email_edit_link( $wc_email_class ), + 'status' => $is_enabled ? 'publish' : 'draft', + 'type' => $type, + 'category' => 'woocommerce', + 'trigger_description' => $config['trigger_description'], + 'registry_slug' => $type, + 'recipient' => $config['recipient'], + 'source' => 'woocommerce', + 'chip' => $config['chip'], + 'recommended' => $config['recommended'], + ]; } // 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. + // Within each group, preserve the order configs were registered in + // (config-insertion order is what the providers control). $category_order = [ 'reader-revenue' => 0, 'reader-activation' => 1, ]; - $slug_order = array_flip( array_keys( $registry ) ); + $type_order = array_flip( array_keys( $configs ) ); usort( $newspack_emails, - function ( $a, $b ) use ( $category_order, $slug_order ) { + function ( $a, $b ) use ( $category_order, $type_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; + $idx_a = $type_order[ $a['type'] ?? '' ] ?? PHP_INT_MAX; + $idx_b = $type_order[ $b['type'] ?? '' ] ?? PHP_INT_MAX; return $idx_a - $idx_b; } ); @@ -370,6 +163,27 @@ function ( $a, $b ) use ( $category_order, $slug_order ) { return $settings; } + /** + * Build the edit link for a WooCommerce email. + * + * Minimal WC classic-settings URL. Slice 2 (nppd-1527) re-adds the + * block-editor branch (when `woocommerce_feature_block_email_editor_enabled` + * is on) and the `preview_post_id` resolution that goes with it. + * + * @param string $wc_email_class Fully-qualified WC_Email subclass name. + * @return string Classic WC settings URL for the email. + */ + private static function get_wc_email_edit_link( string $wc_email_class ): string { + return add_query_arg( + [ + 'page' => 'wc-settings', + 'tab' => 'email', + 'section' => strtolower( $wc_email_class ), + ], + admin_url( 'admin.php' ) + ); + } + /** * API callback to update woocommerce email settings. * diff --git a/tests/unit-tests/emails-section.php b/tests/unit-tests/emails-section.php index 256219a44a..19ad93efac 100644 --- a/tests/unit-tests/emails-section.php +++ b/tests/unit-tests/emails-section.php @@ -1,123 +1,153 @@ $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. + * Every config has the four new schema fields after defaults are applied. */ - 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." ); + public function test_email_configs_have_all_required_fields() { + $configs = Emails::get_email_configs(); + $this->assertNotEmpty( $configs, 'Expected at least one registered email config.' ); + + foreach ( $configs as $type => $config ) { + $this->assertArrayHasKey( 'trigger_description', $config, "Config '$type' is missing trigger_description." ); + $this->assertIsString( $config['trigger_description'], "Config '$type' trigger_description must be a string." ); + + $this->assertArrayHasKey( 'recipient', $config, "Config '$type' is missing recipient." ); + $this->assertContains( + $config['recipient'], + [ 'reader', 'admin' ], + "Config '$type' has an invalid recipient value." + ); + + $this->assertArrayHasKey( 'recommended', $config, "Config '$type' is missing recommended." ); + $this->assertIsBool( $config['recommended'], "Config '$type' recommended must be a bool." ); + + $this->assertArrayHasKey( 'chip', $config, "Config '$type' is missing chip." ); + $this->assertContains( + $config['chip'], + [ 'auth-account', 'reader-revenue' ], + "Config '$type' has an invalid chip value." + ); } } - /** - * Test all registry entries have a valid recipient value. + /* + * ------------------------------------------------------------------ + * 7b — Per-provider content + * ------------------------------------------------------------------ + * Asserts each provider class registers entries with the expected + * field values. Catches data regressions when providers are touched. */ - 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. + * Reader-revenue provider: all three entries chip='reader-revenue' and recommended. */ - 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." ); + public function test_reader_revenue_provider_entries() { + $configs = Emails::get_email_configs(); + + $expected = [ + Reader_Revenue_Emails::EMAIL_TYPES['RECEIPT'] => 'Sent after a successful payment.', + Reader_Revenue_Emails::EMAIL_TYPES['WELCOME'] => 'Sent to new supporters after their first payment.', + Reader_Revenue_Emails::EMAIL_TYPES['CANCELLATION'] => 'Sent when a reader cancels their subscription.', + ]; + + foreach ( $expected as $type => $trigger_description ) { + $this->assertArrayHasKey( $type, $configs, "Reader-revenue type '$type' not registered." ); + $this->assertSame( 'reader-revenue', $configs[ $type ]['chip'], "Type '$type' should chip to reader-revenue." ); + $this->assertSame( 'reader', $configs[ $type ]['recipient'], "Type '$type' should target reader." ); + $this->assertTrue( $configs[ $type ]['recommended'], "Type '$type' should be recommended." ); + $this->assertSame( $trigger_description, $configs[ $type ]['trigger_description'] ); } } /** - * Test recommended is always a boolean. + * Reader-activation provider: all entries chip='auth-account', recipient='reader'. + * Recommended is true for the four core sign-in flows, false for the rest. */ - 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." ); - } - } + public function test_reader_activation_provider_entries() { + $configs = Emails::get_email_configs(); - /** - * 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." ); - } - } + $recommended_types = [ + Reader_Activation_Emails::EMAIL_TYPES['VERIFICATION'], + Reader_Activation_Emails::EMAIL_TYPES['MAGIC_LINK'], + Reader_Activation_Emails::EMAIL_TYPES['OTP_AUTH'], + Reader_Activation_Emails::EMAIL_TYPES['RESET_PASSWORD'], + ]; + $non_recommended_types = [ + Reader_Activation_Emails::EMAIL_TYPES['DELETE_ACCOUNT'], + Reader_Activation_Emails::EMAIL_TYPES['NON_READER'], + ]; - /** - * 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." ); - } + foreach ( array_merge( $recommended_types, $non_recommended_types ) as $type ) { + $this->assertArrayHasKey( $type, $configs, "Reader-activation type '$type' not registered." ); + $this->assertSame( 'auth-account', $configs[ $type ]['chip'], "Type '$type' should chip to auth-account." ); + $this->assertSame( 'reader', $configs[ $type ]['recipient'], "Type '$type' should target reader." ); + $this->assertNotEmpty( $configs[ $type ]['trigger_description'], "Type '$type' should have a trigger description." ); + } + foreach ( $recommended_types as $type ) { + $this->assertTrue( $configs[ $type ]['recommended'], "Type '$type' should be recommended." ); + } + foreach ( $non_recommended_types as $type ) { + $this->assertFalse( $configs[ $type ]['recommended'], "Type '$type' should NOT be recommended." ); } } /** - * Test no duplicate newspack_type or woo_email_id values across entries. + * Group-subscription-invite provider: chip='reader-revenue' (paid product), not recommended. */ - public function test_registry_no_duplicate_type_values() { - $registry = Emails_Section::get_email_registry(); - $newspack_types = []; - $woo_ids = []; + public function test_group_subscription_invite_provider_entry() { + $configs = Emails::get_email_configs(); + $type = 'group-subscription-invite'; - 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']; - } - } + $this->assertArrayHasKey( $type, $configs, 'Group subscription invite config not registered.' ); + $this->assertSame( 'reader-revenue', $configs[ $type ]['chip'] ); + $this->assertSame( 'reader', $configs[ $type ]['recipient'] ); + $this->assertFalse( $configs[ $type ]['recommended'] ); + $this->assertSame( 'Sent to invite a reader to join a group subscription.', $configs[ $type ]['trigger_description'] ); } + /* + * ------------------------------------------------------------------ + * 7c — Response shape of api_get_email_settings() + * ------------------------------------------------------------------ + * Verifies the wizard endpoint response structure after the rewrite: + * top-level keys are correct, each newspack_emails row carries the + * new schema fields, no view_category leakage, and the sort grouping + * holds (reader-revenue → reader-activation → other). + */ + /** - * Test api_get_email_settings returns the expected response shape. + * Response has the expected top-level shape, and rows carry the new fields. */ public function test_api_get_email_settings_response_shape() { $result = Emails_Section::api_get_email_settings(); @@ -132,122 +162,173 @@ public function test_api_get_email_settings_response_shape() { $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; + $required_row_fields = [ + 'label', + 'registry_slug', + 'trigger_description', + 'recipient', + 'recommended', + 'chip', + 'source', + 'category', + 'status', + ]; 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'." ); + foreach ( $required_row_fields as $field ) { + $this->assertArrayHasKey( $field, $email, "Row '{$email['label']}' is missing field '$field'." ); } } - $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. + * `view_category` is dead — it was called out in slice 1 review feedback + * and the response builder no longer emits it. */ - public function test_api_get_email_settings_sort_order() { - $result = Emails_Section::api_get_email_settings(); - $categories = array_column( $result['newspack_emails'], 'category' ); + public function test_api_get_email_settings_omits_view_category() { + $result = Emails_Section::api_get_email_settings(); + foreach ( $result['newspack_emails'] as $email ) { + $this->assertArrayNotHasKey( 'view_category', $email ); + } + } - // Build the expected group order: reader-revenue → reader-activation → everything else. - $last_group = -1; - $group_map = [ + /** + * Sort order: reader-revenue first, then reader-activation, then everything else. + */ + public function test_api_get_email_settings_sort_order() { + $result = Emails_Section::api_get_email_settings(); + $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 = -1; + foreach ( $result['newspack_emails'] as $i => $email ) { + $group = $group_map[ $email['category'] ?? '' ] ?? 2; + $this->assertGreaterThanOrEqual( + $last_group, + $group, + "Email at index $i (category '{$email['category']}') is out of sort order." + ); $last_group = $group; } } + /* + * ------------------------------------------------------------------ + * 7d — WooCommerce integration + * ------------------------------------------------------------------ + * The "WC inactive" branch is the only one we can test deterministically + * without a WC mock in this environment. When WooCommerce is loaded in + * a real environment the active-branch assertions kick in. + */ + /** - * 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 ); + * With WooCommerce not active, the filter returns the input unchanged. + */ + public function test_woocommerce_emails_get_email_configs_with_wc_inactive() { + if ( class_exists( 'WooCommerce' ) ) { + $this->markTestSkipped( 'WooCommerce is loaded in this environment; inactive-branch test does not apply.' ); + } + $input = [ 'some-existing-config' => [ 'name' => 'some-existing-config' ] ]; + $result = WooCommerce_Emails::get_email_configs( $input ); + $this->assertSame( $input, $result, 'When WC is not active, the filter should pass through unchanged.' ); } /** - * Test that the newspack_emails_registry filter can add entries. + * With WooCommerce active, recognized WC email IDs are injected with + * wc_email_instance attached; unrecognized IDs are silently absent. */ - 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.', + public function test_woocommerce_emails_get_email_configs_with_wc_active() { + if ( ! class_exists( 'WooCommerce' ) ) { + $this->markTestSkipped( 'WooCommerce is not loaded in this environment.' ); + } + $configs = Emails::get_email_configs(); + + // At minimum, customer_new_account is core WC (no plugin_dependency). + // If WC is active, we expect it in the unified config set. + $this->assertArrayHasKey( 'customer_new_account', $configs, 'Core WC customer_new_account should be registered.' ); + $this->assertSame( 'woocommerce', $configs['customer_new_account']['source'] ); + $this->assertArrayHasKey( 'wc_email_instance', $configs['customer_new_account'] ); + $this->assertInstanceOf( \WC_Email::class, $configs['customer_new_account']['wc_email_instance'] ); + } + + /* + * ------------------------------------------------------------------ + * 7e — Default-merge mechanism + * ------------------------------------------------------------------ + * Direct coverage of Emails::apply_config_defaults() — the core + * pattern dkoo asked for. Catches changes to the documented defaults + * and regressions in the merge logic. + */ + + /** + * Partial config gets the documented defaults filled in. + */ + public function test_apply_config_defaults_fills_missing_fields() { + $partial = [ + 'name' => 'test-email', + 'label' => 'Test Email', + 'category' => 'reader-activation', ]; + $merged = Emails::apply_config_defaults( $partial ); - $callback = function ( $registry ) use ( $fake_entry ) { - $registry['test-filter-email'] = $fake_entry; - return $registry; - }; + $this->assertSame( '', $merged['trigger_description'] ); + $this->assertSame( 'reader', $merged['recipient'] ); + $this->assertTrue( $merged['recommended'] ); + $this->assertSame( 'auth-account', $merged['chip'] ); - add_filter( 'newspack_emails_registry', $callback ); - $registry = Emails_Section::get_email_registry(); - remove_filter( 'newspack_emails_registry', $callback ); + // Declared fields pass through unchanged. + $this->assertSame( 'test-email', $merged['name'] ); + $this->assertSame( 'Test Email', $merged['label'] ); + $this->assertSame( 'reader-activation', $merged['category'] ); + } - $this->assertArrayHasKey( 'test-filter-email', $registry, 'Filter-added entry should be present in the registry.' ); - $this->assertSame( $fake_entry, $registry['test-filter-email'] ); + /** + * A config that declares the new fields keeps its values — defaults + * do not clobber explicit declarations. + */ + public function test_apply_config_defaults_preserves_declared_fields() { + $full = [ + 'name' => 'test-email', + 'trigger_description' => 'A specific trigger.', + 'recipient' => 'admin', + 'recommended' => false, + 'chip' => 'reader-revenue', + ]; + $merged = Emails::apply_config_defaults( $full ); + + $this->assertSame( 'A specific trigger.', $merged['trigger_description'] ); + $this->assertSame( 'admin', $merged['recipient'] ); + $this->assertFalse( $merged['recommended'] ); + $this->assertSame( 'reader-revenue', $merged['chip'] ); } /** - * Test registry insertion order within source groups. + * A third-party provider can register with no new fields at all and + * still get a complete config back out of Emails::get_email_configs(). * - * 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.' - ); + * This is the contract that makes the schema extension non-breaking + * for downstream integrations. + */ + public function test_email_configs_filter_partial_provider_gets_defaults() { + $type = 'test-partial-third-party-config'; + $callback = function ( $configs ) use ( $type ) { + $configs[ $type ] = [ + 'name' => $type, + 'category' => 'reader-activation', + 'label' => 'Third-party', + ]; + return $configs; + }; + + add_filter( 'newspack_email_configs', $callback ); + $configs = Emails::get_email_configs(); + remove_filter( 'newspack_email_configs', $callback ); + + $this->assertArrayHasKey( $type, $configs ); + $this->assertSame( '', $configs[ $type ]['trigger_description'] ); + $this->assertSame( 'reader', $configs[ $type ]['recipient'] ); + $this->assertTrue( $configs[ $type ]['recommended'] ); + $this->assertSame( 'auth-account', $configs[ $type ]['chip'] ); } }