Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public static function init() {
include_once __DIR__ . '/class-woocommerce-cli.php';
include_once __DIR__ . '/class-woocommerce-cover-fees.php';
include_once __DIR__ . '/class-woocommerce-emails.php';
include_once __DIR__ . '/class-woocommerce-email-style-sync.php';
include_once __DIR__ . '/class-woocommerce-order-utm.php';
include_once __DIR__ . '/class-woocommerce-products.php';
include_once __DIR__ . '/class-woocommerce-checkout.php';
Expand Down
113 changes: 113 additions & 0 deletions includes/plugins/woocommerce/class-woocommerce-email-style-sync.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php
/**
* Sync site brand styles into WooCommerce classic email template options.
*
* Writes the site's primary color and logo into WC's classic email options
* on first run, then keeps them in sync when theme colors change.
*
* @package Newspack
*/

namespace Newspack;

defined( 'ABSPATH' ) || exit;

/**
* WooCommerce Email Style Sync class.
*/
class WooCommerce_Email_Style_Sync {

/**
* Option name to track the sync version.
*
* @var string
*/
const SYNCED_VERSION_OPTION = 'newspack_wc_email_style_sync_version';

/**
* Current sync version. Bump this to re-trigger sync on all sites.
*
* @var string
*/
const CURRENT_VERSION = 'v1';

/**
* Initialize hooks.
*/
public static function init(): void {
// First-run sync on admin_init (after WC is loaded).
add_action( 'admin_init', [ __CLASS__, 'maybe_sync_on_first_run' ] );

// Re-sync when Customizer colors change or when themes are switched.
// Using theme-agnostic hooks (not update_option_theme_mods_{theme})
// so the hooks survive a theme switch.
add_action( 'customize_save_after', [ __CLASS__, 'sync_styles' ] );
add_action( 'after_switch_theme', [ __CLASS__, 'sync_styles' ] );
}

/**
* Run the sync if this is the first time (or the version has been bumped).
*/
public static function maybe_sync_on_first_run(): void {
if ( ! class_exists( 'WooCommerce' ) ) {
return;
}
if ( self::CURRENT_VERSION === get_option( self::SYNCED_VERSION_OPTION ) ) {
return;
}
self::sync_styles();
update_option( self::SYNCED_VERSION_OPTION, self::CURRENT_VERSION );
}

/**
* Write site brand colors and logo into WC classic email options.
*/
public static function sync_styles(): void {
if ( ! class_exists( 'WooCommerce' ) ) {
return;
}
$colors = self::get_site_colors();
foreach ( $colors as $option_name => $value ) {
update_option( $option_name, $value );
}
$logo_url = self::get_site_logo_url();
update_option( 'woocommerce_email_header_image', $logo_url );
}

/**
* Get the site brand colors mapped to WC email options.
*
* Only syncs woocommerce_email_base_color (header background, links, accents).
* We intentionally do NOT sync:
* - woocommerce_email_text_color: the theme's primary_text_color is contrast-computed
* against the primary brand color, not against white, so mapping it to body text
* would break readability on sites with dark primaries.
* - woocommerce_email_background_color: WC's #f7f7f7 surround works across all palettes.
* - woocommerce_email_body_background_color: WC's #ffffff body works across all palettes.
*
* @return array<string, string> WC option name => color hex value.
*/
private static function get_site_colors(): array {
if ( ! function_exists( 'newspack_get_theme_colors' ) ) {
return [];
}
return [
'woocommerce_email_base_color' => newspack_get_theme_colors()['primary_color'],
];
}

/**
* Get the site logo URL for WC email header image.
*
* @return string Logo URL, or empty string if no logo is set.
*/
private static function get_site_logo_url(): string {
$custom_logo_id = get_theme_mod( 'custom_logo' );
if ( $custom_logo_id ) {
$url = wp_get_attachment_url( $custom_logo_id );
return $url ? $url : '';
}
return '';
}
}
WooCommerce_Email_Style_Sync::init();
131 changes: 123 additions & 8 deletions includes/wizards/newspack/class-email-preview.php
Original file line number Diff line number Diff line change
Expand Up @@ -404,36 +404,99 @@ public static function init(): void {
/**
* Register the email-preview REST endpoint.
*
* Accepts both numeric post IDs (Newspack emails, WC block-editor emails)
* and wc:{email_id} strings (WC classic-template emails).
*
* @codeCoverageIgnore
*/
public static function register_rest_routes(): void {
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/newspack-settings/emails/(?P<post_id>\d+)/preview',
'wizard/newspack-settings/emails/(?P<id>\d+|wc:[\w-]+)/preview',
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ __CLASS__, 'api_get_preview' ],
'permission_callback' => [ __CLASS__, 'api_permissions_check' ],
'args' => [
'post_id' => [
'id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]
);
}

/**
* REST handler: return preview HTML for an email post.
* REST handler: return preview HTML for an email.
*
* Handles two identifier shapes:
* - Numeric: resolves to a post (newspack_rr_email or woo_email).
* - wc:{email_id}: renders a WC classic-template email via WC's
* legacy EmailPreview, validated against the email registry.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_REST_Response|\WP_Error
*/
public static function api_get_preview( $request ) {
$post_id = (int) $request->get_param( 'post_id' );
$id = $request->get_param( 'id' );

// WC classic email preview (e.g. "wc:customer_payment_retry").
if ( str_starts_with( $id, 'wc:' ) ) {
$wc_email_id = substr( $id, 3 );

// Validate against the registry before any resolution.
$registry = Emails_Section::get_email_registry();
$valid = false;
foreach ( $registry as $entry ) {
if ( isset( $entry['woo_email_id'] ) && $entry['woo_email_id'] === $wc_email_id ) {
$valid = true;
break;
}
}
if ( ! $valid ) {
return new \WP_Error(
'newspack_email_preview_not_found',
__( 'Email not found in registry.', 'newspack-plugin' ),
[ 'status' => 404 ]
);
}

// If a block-editor template post exists, use the block render path.
$template_post_id = Emails_Section::get_wc_email_template_post_id( $wc_email_id );
if ( $template_post_id ) {
$html = self::get_wc_preview_html( $template_post_id );
} else {
$html = self::get_wc_classic_preview_html( $wc_email_id );
}

if ( false === $html || empty( $html ) ) {
return new \WP_Error(
'newspack_email_preview_unavailable',
__( 'Email preview is unavailable.', 'newspack-plugin' ),
[ 'status' => 500 ]
);
}

return rest_ensure_response(
[
'html' => $html,
'id' => $id,
]
);
}

// Numeric post ID path (Newspack emails, WC block-editor emails).
$post_id = absint( $id );
if ( ! $post_id ) {
return new \WP_Error(
'newspack_email_preview_not_found',
__( 'Email not found.', 'newspack-plugin' ),
[ 'status' => 404 ]
);
}
Comment thread
kmwilkerson marked this conversation as resolved.

$post = get_post( $post_id );
if ( ! $post ) {
Expand Down Expand Up @@ -466,12 +529,64 @@ public static function api_get_preview( $request ) {

return rest_ensure_response(
[
'html' => $html,
'post_id' => $post_id,
'html' => $html,
'id' => $post_id,
]
);
}

/**
* Render a WC classic-template email via WC's legacy EmailPreview.
*
* Used for WC emails that have no block-editor template post (e.g.
* WC Subs emails). The rendered HTML uses woocommerce_email_* option
* colors which WooCommerce_Email_Style_Sync keeps in sync with the
* site's brand.
*
* Not cached — classic render is fast (~30-50 ms) and
* woocommerce_email_* options change without a post_modified timestamp
* to key against. The lazy-loading IntersectionObserver in the frontend
* already limits concurrent requests.
*
* @param string $wc_email_id The WC_Email ID (e.g. 'customer_payment_retry').
*
* @return string|false Rendered HTML, or false if unavailable.
*/
public static function get_wc_classic_preview_html( string $wc_email_id ) {
// WC's EmailPreview lives in the Internal namespace — no BC guarantee.
// Guard with class_exists() so we degrade gracefully if WC changes it.
$preview_class = 'Automattic\\WooCommerce\\Internal\\Admin\\EmailPreview\\EmailPreview';

if ( ! class_exists( 'WooCommerce' ) || ! class_exists( $preview_class ) ) {
return false;
}

// Resolve email ID → class name via the mailer's registered emails.
$wc_email_class = null;
foreach ( \WC()->mailer()->get_emails() as $class_name => $instance ) {
if ( $instance->id === $wc_email_id ) {
$wc_email_class = $class_name;
break;
}
}
if ( ! $wc_email_class ) {
return false;
}

try {
$preview = $preview_class::instance();
$preview->set_email_type( $wc_email_class );
return $preview->render();
} catch ( \Throwable $e ) {
Logger::log(
"WC EmailPreview::render() failed for '$wc_email_id' ($wc_email_class): " . $e->getMessage(),
'NEWSPACK-EMAILS',
'warning'
);
return false;
}
}

/**
* Permissions check for the preview endpoint. Mirrors other Newspack email endpoints.
*
Expand Down
9 changes: 6 additions & 3 deletions includes/wizards/newspack/class-emails-section.php
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ public static function api_get_email_settings(): array {
Logger::log( "WC email '$wc_email_id' not found for registry '$slug'.", 'NEWSPACK-EMAILS', 'warning' );
continue;
}
$preview_post_id = self::get_wc_email_template_post_id( $wc_email_id );
$block_template_post_id = self::get_wc_email_template_post_id( $wc_email_id );
// When a block-editor template exists, preview via the post ID.
// Otherwise use the wc:{email_id} identifier for classic preview.
$preview_id = $block_template_post_id ?? ( 'wc:' . $wc_email_id );
// Read enabled state from the option rather than the in-memory
// WC_Email::$enabled property, which can be stale after first-run
// or toggle updates within the same request.
Expand All @@ -424,7 +427,7 @@ public static function api_get_email_settings(): array {
$newspack_emails[] = [
'label' => $entry['label'],
'post_id' => 'wc:' . $wc_email_id,
'preview_post_id' => $preview_post_id,
'preview_id' => $preview_id,
'edit_link' => self::get_wc_email_edit_link( $wc_email_id, $wc_email_class ),
'status' => $is_enabled ? 'publish' : 'draft',
'type' => $wc_email_id,
Expand Down Expand Up @@ -476,7 +479,7 @@ function ( $a, $b ) use ( $category_order, $slug_order ) {
* @param string $wc_email_id The WC_Email ID (e.g. 'new_order').
* @return int|null Template post ID, or null.
*/
private static function get_wc_email_template_post_id( string $wc_email_id ): ?int {
public static function get_wc_email_template_post_id( string $wc_email_id ): ?int {
if ( 'yes' !== get_option( 'woocommerce_feature_block_email_editor_enabled' ) ) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,33 @@ describe( 'EmailPreview', () => {
expect( iframe.getAttribute( 'srcdoc' ) ).not.toContain( 'First email' );
} );

it( 'fetches the correct endpoint path for a wc: string postId', async () => {
apiFetch.mockResolvedValue( { html: '<p>WC Preview</p>', id: 'wc:customer_payment_retry' } );

render( <EmailPreview postId="wc:customer_payment_retry" /> );

await waitFor( () => {
expect( apiFetch ).toHaveBeenCalledWith( {
path: '/newspack/v1/wizard/newspack-settings/emails/wc:customer_payment_retry/preview',
} );
} );
} );

it( 'renders iframe for a wc: string postId', async () => {
apiFetch.mockResolvedValue( {
html: '<html><body><p>Classic WC email</p></body></html>',
id: 'wc:expired_subscription',
} );

render( <EmailPreview postId="wc:expired_subscription" /> );

await waitFor( () => {
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
expect( iframe ).toBeTruthy();
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Classic WC email' );
} );
} );

// Note: The safety timeout (8s fallback for slow assets) and the iframe
// onError handler are not tested here because jsdom automatically fires
// the iframe load event when srcDoc is set, which prevents us from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { Icon, envelope } from '@wordpress/icons';
import './email-preview.scss';

interface EmailPreviewProps {
postId: number;
postId: number | string;
}

const IFRAME_WIDTH = 848;
Expand Down Expand Up @@ -107,7 +107,7 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
setIframeHeight( null );
setHasError( false );
setHtml( null );
apiFetch< { html: string; post_id: number } >( {
apiFetch< { html: string; id: number | string } >( {
path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`,
} )
.then( response => {
Expand Down
2 changes: 2 additions & 0 deletions src/wizards/newspack/views/settings/emails/emails.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const mockEmails = [
{
label: 'New order',
post_id: 'wc:new_order',
preview_id: 'wc:new_order',
edit_link: '/wc-settings/email/new_order',
status: 'publish',
type: 'new_order',
Expand All @@ -152,6 +153,7 @@ const mockEmails = [
{
label: 'Renewal reminder',
post_id: 'wc:customer_notification_auto_renewal',
preview_id: 'wc:customer_notification_auto_renewal',
edit_link: '/wc-settings/email/renewal',
status: 'draft',
type: 'customer_notification_auto_renewal',
Expand Down
Loading
Loading