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
1 change: 1 addition & 0 deletions includes/class-newspack.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ private function includes() {
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-newspack-settings.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-custom-events-section.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-emails-section.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-email-preview.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-syndication-section.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-seo-section.php';
include_once NEWSPACK_ABSPATH . 'includes/wizards/newspack/class-pixels-section.php';
Expand Down
313 changes: 313 additions & 0 deletions includes/wizards/newspack/class-email-preview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
<?php
/**
* Email preview rendering for the Settings → Emails screen.
*
* Renders a publisher-facing preview of a transactional email by substituting
* known template tokens with realistic sample values. Used by the unified
* email management UI to display a per-card thumbnail.
*
* @package Newspack
*/

namespace Newspack\Wizards\Newspack;

use Newspack\Emails;
use Newspack_Newsletters;

defined( 'ABSPATH' ) || exit;

/**
* Email Preview Class.
*/
class Email_Preview {

/**
* Get the rendered HTML for an email post, with sample token values substituted.
*
* Falls back to the registered template file's HTML when the post's saved
* EMAIL_HTML_META is empty (i.e. the email has never been customized).
*
* @param int $post_id ID of the email post.
*
* @return string|false Rendered HTML, or false if the email can't be resolved.
*/
public static function get_preview_html( int $post_id ) {
if ( ! self::is_supported() ) {
return false;
}

$html = self::get_source_html( $post_id );
if ( empty( $html ) ) {
return false;
}

return self::apply_sample_substitutions( $html, $post_id );
}

/**
* Get the source HTML for an email post.
*
* Reads the saved EMAIL_HTML_META first; falls back to the registered
* template's default email_html when that meta is empty.
*
* @param int $post_id ID of the email post.
*
* @return string Source HTML, or empty string if unavailable.
*/
private static function get_source_html( int $post_id ): string {
$html = get_post_meta( $post_id, Newspack_Newsletters::EMAIL_HTML_META, true );
if ( ! empty( $html ) ) {
return $html;
}

// Fallback: look up the registered template for this post type and use its default HTML.
$type = get_post_meta( $post_id, Emails::EMAIL_CONFIG_NAME_META, true );
if ( empty( $type ) ) {
return '';
}

// Trigger the template load by requesting the email config. This is the
// same path Emails::get_email_config_by_type() uses; we just want the
// raw template HTML, which is available via reflection on the loaded config.
$configs = apply_filters( 'newspack_email_configs', [] );
if ( ! isset( $configs[ $type ], $configs[ $type ]['template'] ) ) {
return '';
}

$template_path = $configs[ $type ]['template'];
if ( ! is_readable( $template_path ) ) {
return '';
}

$template_data = include $template_path;
if ( ! is_array( $template_data ) || empty( $template_data['email_html'] ) ) {
return '';
}

return $template_data['email_html'];
}

/**
* Apply sample-value substitutions to email HTML.
*
* Site/branding tokens use the publisher's real site config (so the preview
* reflects their actual branding). Reader/transaction tokens use stable
* fake values. Action URLs are replaced with anchor placeholders so
* preview iframes don't trigger live navigation.
*
* @param string $html Source HTML containing *TOKEN* placeholders.
* @param int $post_id The email post being previewed (passed to the filter).
*
* @return string HTML with tokens substituted.
*/
private static function apply_sample_substitutions( string $html, int $post_id = 0 ): string {
$substitutions = self::get_sample_substitutions();

/**
* Filters the sample substitution map used for email previews.
*
* Allows plugins to inject sample values for custom tokens
* (e.g. group-subscription invite tokens, future token additions).
*
* @param array $substitutions Map of `*TOKEN*` => sample value.
* @param int $post_id The email post being previewed (0 if unknown).
*/
$substitutions = apply_filters( 'newspack_email_preview_substitutions', $substitutions, $post_id );

return strtr( $html, $substitutions );
}

/**
* Get the substitution map of email-template tokens to sample values.
*
* @return array Map of `*TOKEN*` => sample value.
*/
public static function get_sample_substitutions(): array {
$site_logo_url = wp_get_attachment_url( get_theme_mod( 'custom_logo' ) );
$site_title = get_bloginfo( 'name' );
$site_url = get_bloginfo( 'wpurl' );
$reply_to_email = Emails::get_reply_to_email();
$site_address = self::get_site_address();
$site_contact = $site_address
? sprintf( '<strong>%s</strong> — %s', $site_title, $site_address )
: $site_title;

return [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Non-blocking: static token map could get out-of-sync

This is probably okay for the initial implementation due to how we've added placeholder tokens sort of ad hoc for each email template as needed, but the static map here has potential for easily getting out-of-sync if we add/remove placeholders or make any changes to existing ones in the future.

To properly address this, we'd likely need to make some refactors to how we're adding placeholder tokens in each email template's settings object. We'd want some kind of API to register placeholders in a single shared map that would include config such as the sample value and maybe a data replacement type + sanitization callback. Then, email template configs would reference placeholders from this shared map so we could be sure all email templates are using the same placeholders in the same way.

I'd say this is non-blocking because it would balloon the scope of this PR, but I'd strongly consider a separate follow-up PR to revisit and standardize how we handle all the different placeholders.

// Site / branding — real values from the publisher's config.
'*SITE_TITLE*' => $site_title,
'*SITE_URL*' => $site_url,
'*SITE_LOGO*' => $site_logo_url ? esc_url( $site_logo_url ) : '',
'*SITE_ADDRESS*' => $site_address,
'*SITE_CONTACT*' => $site_contact,
Comment on lines +137 to +141
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Escape raw text tokens

These sample strings come from options or theme mods and are interpolated raw into HTML before reaching srcDoc. Escape all output for tighter security:

Suggested change
'*SITE_TITLE*' => $site_title,
'*SITE_URL*' => $site_url,
'*SITE_LOGO*' => $site_logo_url ? esc_url( $site_logo_url ) : '',
'*SITE_ADDRESS*' => $site_address,
'*SITE_CONTACT*' => $site_contact,
'*SITE_TITLE*' => esc_html( $site_title ),
'*SITE_URL*' => esc_url( $site_url ),
'*SITE_LOGO*' => $site_logo_url ? esc_url( $site_logo_url ) : '',
'*SITE_ADDRESS*' => esc_html( $site_address ),
'*SITE_CONTACT*' => esc_html( $site_contact ),

'*CONTACT_EMAIL*' => sprintf( '<a href="%s">%s</a>', esc_url( 'mailto:' . $reply_to_email ), esc_html( $reply_to_email ) ),

// Reader identity — stable sample values.
'*BILLING_FIRST_NAME*' => 'Sample',
'*BILLING_LAST_NAME*' => 'Reader',
'*BILLING_NAME*' => 'Sample Reader',
'*PENDING_EMAIL_ADDRESS*' => 'sample.reader@example.com',

// Transaction / subscription details — stable sample values.
'*AMOUNT*' => '$25.00',
'*PAYMENT_METHOD*' => 'Visa ending in 4242',
'*PRODUCT_NAME*' => 'Monthly Membership',
'*BILLING_FREQUENCY*' => 'monthly',
'*DATE*' => wp_date( get_option( 'date_format', 'F j, Y' ) ),
'*CANCELLATION_TITLE*' => __( 'Subscription Cancelled', 'newspack-plugin' ),
'*CANCELLATION_TYPE*' => __( 'subscription', 'newspack-plugin' ),
Comment on lines +145 to +157
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Make all sample strings translatable

Some sample strings here are translatable while others aren't. Let's put them all through __() for consistency.

Suggested change
'*BILLING_FIRST_NAME*' => 'Sample',
'*BILLING_LAST_NAME*' => 'Reader',
'*BILLING_NAME*' => 'Sample Reader',
'*PENDING_EMAIL_ADDRESS*' => 'sample.reader@example.com',
// Transaction / subscription details — stable sample values.
'*AMOUNT*' => '$25.00',
'*PAYMENT_METHOD*' => 'Visa ending in 4242',
'*PRODUCT_NAME*' => 'Monthly Membership',
'*BILLING_FREQUENCY*' => 'monthly',
'*DATE*' => wp_date( get_option( 'date_format', 'F j, Y' ) ),
'*CANCELLATION_TITLE*' => __( 'Subscription Cancelled', 'newspack-plugin' ),
'*CANCELLATION_TYPE*' => __( 'subscription', 'newspack-plugin' ),
'*BILLING_FIRST_NAME*' => __( 'Sample', 'newspack-plugin' ),
'*BILLING_LAST_NAME*' => __( 'Reader', 'newspack-plugin' ),
'*BILLING_NAME*' => __( 'Sample Reader', 'newspack-plugin' ),
'*PENDING_EMAIL_ADDRESS*' => __( 'sample.reader@example.com', 'newspack-plugin' ),
// Transaction / subscription details — stable sample values.
'*AMOUNT*' => __( '$25.00', 'newspack-plugin' ),
'*PAYMENT_METHOD*' => __( 'Visa ending in 4242', 'newspack-plugin' ),
'*PRODUCT_NAME*' => __( 'Monthly Membership', 'newspack-plugin' ),
'*BILLING_FREQUENCY*' => __( 'monthly', 'newspack-plugin' ),
'*DATE*' => wp_date( get_option( 'date_format', 'F j, Y' ) ),
'*CANCELLATION_TITLE*' => __( 'Subscription Cancelled', 'newspack-plugin' ),
'*CANCELLATION_TYPE*' => __( 'subscription', 'newspack-plugin' ),


// Action URLs — anchors so preview clicks don't navigate.
'*ACCOUNT_URL*' => '#',
'*CANCELLATION_URL*' => '#',
'*EMAIL_CANCELLATION_URL*' => '#',
'*EMAIL_VERIFICATION_URL*' => '#',
'*VERIFICATION_URL*' => '#',
'*RECEIPT_URL*' => '#',
'*MAGIC_LINK_URL*' => '#',
'*PASSWORD_RESET_LINK*' => '#',
'*SET_PASSWORD_LINK*' => '#',
'*DELETION_LINK*' => '#',
'*WP_LOGIN_URL*' => '#',

// OTP code — stable sample value.
'*MAGIC_LINK_OTP*' => '123456',
];
}

/**
* Get the site's store address as a formatted string.
*
* Mirrors the logic in Emails::get_email_payload() so the preview
* shows the same address format the real email would use.
*
* @return string Formatted site address, or empty string.
*/
private static function get_site_address(): string {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Non-blocking suggestion: extract formatted site address string to a shared helper method

This method replicates some logic in the Emails::get_email_payload() method almost exactly (the docblock even says so!). This creates some redundancy and could result in mismatching output if the Emails::get_email_payload() logic ever changes. If we move the logic into a new helper method (e.g. Emails::get_site_address()) that both Email_Preview and Emails classes can use, this simplifies things and prevents future drift.

if ( class_exists( 'WC' ) ) {
$base_address = WC()->countries->get_base_address();
$base_city = WC()->countries->get_base_city();
$base_postcode = WC()->countries->get_base_postcode();
} else {
$base_address = get_option( 'woocommerce_store_address', '' );
$base_city = get_option( 'woocommerce_store_city', '' );
$base_postcode = get_option( 'woocommerce_store_postcode', '' );
}

if ( ! $base_address ) {
return '';
}

if ( ! $base_city && ! $base_postcode ) {
return $base_address;
}

return sprintf(
/* translators: 1: street address, 2: city, 3: postcode. */
__( '%1$s, %2$s %3$s', 'newspack-plugin' ),
$base_address,
$base_city,
$base_postcode
);
}

/**
* Is email preview supported on this install?
*
* Requires Newspack Newsletters (the same dependency that gates
* email management in general).
*
* @return bool
*/
private static function is_supported(): bool {
return class_exists( 'Newspack_Newsletters' );
}

/**
* Initialize the class. Hooked from class-newspack.php inclusion.
*
* @codeCoverageIgnore
*/
public static function init(): void {
add_action( 'rest_api_init', [ __CLASS__, 'register_rest_routes' ] );
}

/**
* Register the email-preview REST endpoint.
*
* @codeCoverageIgnore
*/
public static function register_rest_routes(): void {
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/newspack-settings/emails/(?P<post_id>\d+)/preview',
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ __CLASS__, 'api_get_preview' ],
'permission_callback' => [ __CLASS__, 'api_permissions_check' ],
'args' => [
'post_id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
],
],
]
);
}

/**
* REST handler: return preview HTML for an email post.
*
* @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' );

$post = get_post( $post_id );
if ( ! $post || Emails::POST_TYPE !== $post->post_type ) {
return new \WP_Error(
'newspack_email_preview_not_found',
__( 'Email not found.', 'newspack-plugin' ),
[ 'status' => 404 ]
);
}

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

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

/**
* Permissions check for the preview endpoint. Mirrors other Newspack email endpoints.
*
* @codeCoverageIgnore
* @return bool|\WP_Error
*/
public static function api_permissions_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new \WP_Error(
'newspack_rest_forbidden',
esc_html__( 'You cannot use this resource.', 'newspack-plugin' ),
[ 'status' => 403 ]
);
}
return true;
}
}


Email_Preview::init();
40 changes: 40 additions & 0 deletions src/wizards/newspack/views/settings/emails/email-preview.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
@use "~@wordpress/base-styles/colors" as wp-colors;

// Email preview thumbnail container.
.newspack-email-preview {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
position: relative;
background: transparent;
display: flex;
align-items: center;
justify-content: center;

&__iframe {
width: 848px;
height: auto;
border: 0;
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
pointer-events: none;
opacity: 0;
transition: opacity 200ms ease-out;
}

&.is-ready &__iframe {
opacity: 1;
}

// Loading / error placeholder overlay.
&__placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: wp-colors.$gray-400;
}
}
Loading
Loading