Skip to content

feat(emails): inline EmailPreview component#4730

Open
kmwilkerson wants to merge 13 commits into
nppd-945-unified-emails-newspack-slicefrom
nppd-1525-email-preview-component
Open

feat(emails): inline EmailPreview component#4730
kmwilkerson wants to merge 13 commits into
nppd-945-unified-emails-newspack-slicefrom
nppd-1525-email-preview-component

Conversation

@kmwilkerson
Copy link
Copy Markdown

@kmwilkerson kmwilkerson commented May 14, 2026

What this PR does

Adds an inline email preview component (NPPD-1525) that renders a live thumbnail of each email's HTML inside the DataViews grid built in #4727. Replaces the envelope-icon placeholder with real previews — readers see the actual rendered template at a glance.

image

⚠️ Stacked on #4727

Base branch is nppd-945-unified-emails-newspack-slice, not trunk.

The diff in this PR shows only the NPPD-1525 work. The slice 1 changes (#4727) are excluded from the diff by virtue of the base branch.

Code changes

Backend (includes/wizards/newspack/class-email-preview.php)

  • New Email_Preview class registers GET /wizard/newspack-settings/emails/{post_id}/preview
  • Returns the cached EMAIL_HTML_META post meta with sample-value token substitution applied
  • Falls back to rendering the template file if no cached HTML is stored
  • Substitutes 29 template tokens: reader/transaction values use stable fakes (e.g. "Sample Reader", "$25.00", "May 14, 2026"); site/branding values use real publisher values from get_option; action URLs use # anchors

Frontend (src/wizards/newspack/views/settings/emails/)

  • New EmailPreview React component
  • IntersectionObserver lazy-load via isVisible state bridge — cards only fetch when scrolled into view, avoiding 24 simultaneous requests on initial render
  • Iframe srcDoc rendering with empty sandbox="" for security (scripts in email HTML cannot execute)
  • Spinner loading state, envelope icon fallback on render error
  • ResizeObserver-based proportional scaling: source iframe rendered at 1200×900, scaled via inline transform to match container width while preserving 4:3 aspect ratio
  • Wired into emails.tsx to replace the envelope-icon placeholder in the Preview column

Loader fix (includes/class-newspack.php)

  • Fixed missing tab indentation on the new class-email-preview.php include line

Tests

  • 6 PHPUnit tests (tests/unit-tests/email-preview.php): substitution map, preview HTML with stored meta, template fallback, nonexistent post, wrong post type returns 404, successful API response
  • 5 Jest tests (src/wizards/newspack/views/settings/emails/email-preview.test.js): loading state, successful iframe render, error fallback, lazy-load gating, correct endpoint path

Manual testing

Tested locally on katie.local (Homebrew-served Apache + PHP, symlinked plugin) with the slice 1 grid view. Verified:

  • ✅ Each card renders a live iframe thumbnail of the rendered email HTML
  • ✅ Token substitution working (publisher name, dates, sample amounts visible in rendered previews)
  • ✅ IntersectionObserver gates fetching — initial page load doesn't trigger all 24 requests
  • ✅ Iframe scales proportionally as container width changes
  • ✅ Fallback envelope icon renders when REST returns an error

All Submissions:

@kmwilkerson kmwilkerson added the [Status] Needs Review The issue or pull request needs to be reviewed label May 14, 2026
@kmwilkerson kmwilkerson force-pushed the nppd-1525-email-preview-component branch from 2460fae to 10b84df Compare May 14, 2026 21:29
@kmwilkerson kmwilkerson marked this pull request as ready for review May 14, 2026 21:33
@kmwilkerson kmwilkerson requested a review from a team as a code owner May 14, 2026 21:33
Copy link
Copy Markdown
Contributor

@thomasguillot thomasguillot left a comment

Choose a reason for hiding this comment

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

Nice work on the lazy-load + scaling plumbing. Before this lands, please align the rendering contract with our existing NewsletterPreview in newspack-newsletters so the two grids feel like the same surface side-by-side. These are separate plugins — copy the relevant logic into newspack-plugin rather than extracting a shared package. Use newspack-newsletters as a read-only reference for the shape.

Reference (read-only, in newspack-newsletters)

  • Component: src/components/newsletter-preview/index.js — see lines 43–215, particularly the useInlineStyles hook's iframe-readiness detection (lines 70–193) and the is-ready/fade-in pattern (lines 195–214).
  • Styles: src/components/newsletter-preview/style.scss — note the .block-editor-block-preview__container { opacity: 0; transition: opacity 200ms ease-out; }.is-ready & fade.
  • Grid wrapper: src/admin-shell/screens/layouts-list/fields.js PreviewCard (lines 197–242) — uses aspectRatio: '1', viewportWidth={ 848 }, and rootMargin: "200px".
  • Lazy helper: src/admin-shell/screens/layouts-list/lazy-preview.js.

Concrete changes to make in this PR

All paths below are in newspack-plugin.

1. Aspect ratio: 1 : 1

File: src/wizards/newspack/views/settings/emails/email-preview.scss

-.newspack-email-preview {
-    width: 100%;
-    aspect-ratio: 4 / 3;
-    overflow: hidden;
-    ...
-    background: #f6f7f7;
+.newspack-email-preview {
+    width: 100%;
+    aspect-ratio: 1;
+    overflow: hidden;
+    ...
+    background: transparent;

Rationale: newsletters layouts grid uses 1 : 1 (<LazyPreview placeholderStyle={{ aspectRatio: '1' }} />). Cards sit side-by-side in the same DataView shell, so matching makes them visually coherent. Transparent background lets the card chrome show through consistently.

2. Source viewport: 848 px wide, intrinsic height

File: src/wizards/newspack/views/settings/emails/email-preview.tsx

Replace the IFRAME_WIDTH = 600 constant and the fixed-size iframe with width 848, height measured from the loaded document. Concretely:

const IFRAME_WIDTH = 848; // matches NewsletterPreview's `viewportWidth={ 848 }`

Update the SCSS so __iframe no longer hardcodes height:

&__iframe {
    width: 848px;
    height: auto; // set dynamically after load
    border: 0;
    position: absolute;
    top: 0;
    left: 0;
    transform-origin: top left;
    pointer-events: none;
}

Then measure the natural document height after the iframe loads and set it inline. Add an onLoad handler:

const iframeRef = useRef<HTMLIFrameElement>( null );
const [ iframeHeight, setIframeHeight ] = useState< number | null >( null );
const [ isReady, setIsReady ] = useState( false );

const handleIframeLoad = () => {
    const doc = iframeRef.current?.contentDocument;
    if ( ! doc ) {
        return;
    }
    // Mirror NewsletterPreview's readiness detection: wait for stylesheets
    // and images before measuring + revealing, otherwise the height we read
    // is mid-layout and the iframe pops in unstyled.
    const awaitLoad = ( el: HTMLLinkElement | HTMLImageElement ) =>
        new Promise< void >( resolve => {
            el.addEventListener( 'load', () => resolve(), { once: true } );
            el.addEventListener( 'error', () => resolve(), { once: true } );
        } );
    const linkPromises = Array.from(
        doc.querySelectorAll< HTMLLinkElement >( 'link[rel="stylesheet"]' )
    )
        .filter( link => ! link.sheet )
        .map( awaitLoad );
    const imgPromises = Array.from( doc.querySelectorAll< HTMLImageElement >( 'img' ) )
        .filter( img => ! img.complete )
        .map( awaitLoad );
    // 8s safety so a slow asset never strands the spinner — matches NewsletterPreview line 81.
    const safety = setTimeout( () => {
        setIframeHeight( doc.body.scrollHeight );
        setIsReady( true );
    }, 8000 );
    Promise.all( [ ...linkPromises, ...imgPromises ] ).then( () => {
        clearTimeout( safety );
        setIframeHeight( doc.body.scrollHeight );
        setIsReady( true );
    } );
};

And on the <iframe> element:

<iframe
    ref={ iframeRef }
    className="newspack-email-preview__iframe"
    srcDoc={ html }
    sandbox=""
    tabIndex={ -1 }
    title="Email preview"
    onLoad={ handleIframeLoad }
    style={ {
        transform: `scale(${ scale })`,
        height: iframeHeight ? `${ iframeHeight }px` : undefined,
    } }
/>

Scale stays containerWidth / IFRAME_WIDTH — but since IFRAME_WIDTH is now 848, the math changes accordingly.

Also: the PR description says 1200 × 900 but the SCSS as written is 600 × 1800. Please remove that line from the PR description once 848 × auto is the actual value.

3. Ready-state fade-in

Add the is-ready class pattern from NewsletterPreview.

Component:

<div
    ref={ containerRef }
    className={ `newspack-email-preview${ isReady ? ' is-ready' : '' }` }
>

SCSS:

.newspack-email-preview {
    ...
    &__iframe {
        ...
        opacity: 0;
        transition: opacity 200ms ease-out;
    }

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

Keep the existing spinner placeholder mounted while ! isReady so the transition is spinner → fade-in iframe (no white flash).

4. Render gating

The current condition is html && ! hasError && ! isLoading && scale > 0. After the changes above, also render the iframe as soon as html arrives (so onLoad can fire and measure) — the iframe itself is hidden by opacity: 0 until isReady flips, and the spinner placeholder stays mounted in the meantime. Concretely:

{ html && ! hasError && scale > 0 && (
    <iframe ... />
) }
{ ( isLoading || ( html && ! isReady ) ) && ! hasError && (
    <div className="newspack-email-preview__placeholder">
        <Spinner />
    </div>
) }

5. Iframe-state reset on postId change

When postId changes, reset html, isReady, iframeHeight, hasError so the next preview doesn't show stale content while loading. Add to the fetch effect:

useEffect( () => {
    if ( ! isVisible ) {
        return;
    }
    setIsLoading( true );
    setIsReady( false );
    setIframeHeight( null );
    setHasError( false );
    setHtml( null );
    apiFetch...
}, [ isVisible, postId ] );

6. Tests

Update src/wizards/newspack/views/settings/emails/email-preview.test.js:

  • The "renders iframe on successful fetch" test will need to simulate onLoad. After srcdoc is set, fire iframe.dispatchEvent(new Event('load')) and assert the container gains the is-ready class.
  • Stub iframe.contentDocument to return an object with querySelectorAll returning [] and body.scrollHeight returning a number, so handleIframeLoad resolves immediately.
  • Add a test for the reset-on-postId-change behaviour.

Out of scope (don't do)

  • Don't extract a shared component into a package — these are separate plugins, duplication is fine.
  • Don't honour meta.background_color / fonts here (system emails don't carry that meta; the newsletters version reads it from layout meta).
  • Don't reuse LazyPreview from newsletters — your inline IntersectionObserver already matches its rootMargin: '200px' contract, which is what matters.

Acceptance check

When done, an Email card and a Newsletter Layout card placed side-by-side should:

  • Be the same shape (1 : 1).
  • Show a comparable amount of vertical content (header + first content block roughly fills the square).
  • Fade in from spinner with no FOUC.
  • Show transparent background behind the rendered HTML.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a live “thumbnail” preview for each transactional email in the Newspack Settings → Emails DataViews UI by introducing a REST endpoint that returns rendered email HTML (with sample token substitution) and a React component that lazy-loads and displays that HTML in a sandboxed iframe.

Changes:

  • Backend: Introduces Email_Preview with a GET /wizard/newspack-settings/emails/{post_id}/preview REST route to return cached email HTML (or template fallback) with token substitutions.
  • Frontend: Adds an EmailPreview component (IntersectionObserver + ResizeObserver) and wires it into the Emails DataViews “Preview” column.
  • Tests: Adds PHPUnit coverage for preview HTML/token substitution and Jest coverage for the EmailPreview component behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
includes/wizards/newspack/class-email-preview.php Adds REST route + preview HTML rendering/token substitution logic.
includes/class-newspack.php Includes the new class-email-preview.php loader.
src/wizards/newspack/views/settings/emails/emails.tsx Replaces preview placeholder with <EmailPreview /> in the DataViews grid.
src/wizards/newspack/views/settings/emails/email-preview.tsx New React component that lazy-loads and renders sandboxed iframe previews.
src/wizards/newspack/views/settings/emails/email-preview.scss Styling for the preview thumbnail container/iframe/placeholder states.
src/wizards/newspack/views/settings/emails/emails.scss Removes now-obsolete preview placeholder styles.
tests/unit-tests/email-preview.php New PHPUnit tests for backend preview/substitution and REST handler behavior.
src/wizards/newspack/views/settings/emails/email-preview.test.js New Jest tests for lazy-load gating, success/error states, and endpoint path.
src/wizards/newspack/views/settings/emails/emails.test.js Adjusts Emails tests to mock EmailPreview and provide ResizeObserver.
includes/reader-activation/integrations/README.md Adds documentation for the reader activation integrations framework.
Comments suppressed due to low confidence (3)

includes/wizards/newspack/class-email-preview.php:114

  • get_sample_substitutions() uses function_exists( 'Newspack\Emails::get_reply_to_email' ), but function_exists does not work for class methods, so this condition will always be false and the preview will ignore Emails::get_reply_to_email() (and any Reader Activation override). Use method_exists( Emails::class, 'get_reply_to_email' ) or is_callable( [ Emails::class, 'get_reply_to_email' ] ) instead, or just call Emails::get_reply_to_email() directly since this class already depends on Newspack\Emails.
		$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  = function_exists( 'Newspack\Emails::get_reply_to_email' ) ? Emails::get_reply_to_email() : get_bloginfo( 'admin_email' );
		$site_address    = self::get_site_address();

includes/wizards/newspack/class-email-preview.php:127

  • The *CONTACT_EMAIL* substitution builds an <a href="mailto:..."> string using $reply_to_email without escaping/sanitizing. Even if the value is expected to be an email, it should be passed through sanitize_email() and escaped for both the href attribute and link text to avoid HTML injection issues and to keep output consistent with WP escaping practices.
			'*SITE_ADDRESS*'           => $site_address,
			'*SITE_CONTACT*'           => $site_contact,
			'*CONTACT_EMAIL*'          => sprintf( '<a href="mailto:%s">%s</a>', $reply_to_email, $reply_to_email ),

src/wizards/newspack/views/settings/emails/email-preview.tsx:71

  • EmailPreview assumes ResizeObserver exists. In environments without ResizeObserver, this will throw and prevent the iframe from rendering. Add a feature check with a safe fallback (e.g. compute scale from node.getBoundingClientRect().width once, or default scale to 1 and skip observing).
	// Measure container width and compute iframe scale.
	useEffect( () => {
		const node = containerRef.current;
		if ( ! node ) {
			return;
		}

		const ro = new ResizeObserver( ( [ entry ] ) => {
			setScale( entry.contentRect.width / IFRAME_WIDTH );
		} );

		ro.observe( node );
		return () => ro.disconnect();

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread includes/wizards/newspack/class-email-preview.php Outdated
Comment thread src/wizards/newspack/views/settings/emails/email-preview.tsx
kmwilkerson added a commit that referenced this pull request May 15, 2026
…r APIs (NPPD-1525)

Address Copilot review feedback on PR #4730:
- PHP: guard template include with is_readable() to avoid warnings if
  the registered template path is missing or unreadable.
- JS: add typeof checks for IntersectionObserver and ResizeObserver so
  the component degrades gracefully in browsers without these APIs
  (falls back to eager loading and scale=1 respectively).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson
Copy link
Copy Markdown
Author

Addressed in f889cd6. All six items from the review:

  1. Aspect ratio 1:1aspect-ratio: 1 in SCSS, background set to transparent
  2. Source viewport 848pxIFRAME_WIDTH = 848, iframe width 848px in SCSS with height: auto
  3. Ready-state fade-inis-ready class on container, iframe starts at opacity: 0 with transition: opacity 200ms ease-out, revealed when is-ready is set
  4. Render gating — iframe mounts as soon as HTML arrives (hidden via opacity), spinner stays until isReady
  5. Iframe height measurementonLoad handler waits for stylesheets + images via Promise.all, measures contentDocument.body.scrollHeight, 8s safety timeout
  6. State reset on postId change — resets html, isReady, iframeHeight, hasError at the top of the fetch effect

Tests updated: is-ready class assertion after simulated onLoad, contentDocument stub, and reset-on-postId-change test. Build and 217 tests pass.

@kmwilkerson kmwilkerson force-pushed the nppd-1525-email-preview-component branch from 65d1752 to ebb58fb Compare May 15, 2026 15:05
kmwilkerson added a commit that referenced this pull request May 15, 2026
…r APIs (NPPD-1525)

Address Copilot review feedback on PR #4730:
- PHP: guard template include with is_readable() to avoid warnings if
  the registered template path is missing or unreadable.
- JS: add typeof checks for IntersectionObserver and ResizeObserver so
  the component degrades gracefully in browsers without these APIs
  (falls back to eager loading and scale=1 respectively).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson kmwilkerson reopened this May 15, 2026
@thomasguillot
Copy link
Copy Markdown
Contributor

Code review — EmailPreview component (slice 2 of unified emails)

Reviewed at 2d6fe465b against base branch nppd-945-unified-emails-newspack-slice (PR #4727 head ebb58fbed). Structured handoff for the agent that will pick this up — each finding includes file, line, severity, why, and remediation. There is one real bug (finding 1), one security trade-off worth a comment in code (finding 2), and a handful of resilience / cleanup items.


Bugs

1. function_exists() cannot test a Class::methodEmails::get_reply_to_email() is never called

includes/wizards/newspack/class-email-preview.php:118

$reply_to_email = function_exists( 'Newspack\Emails::get_reply_to_email' )
    ? Emails::get_reply_to_email()
    : get_bloginfo( 'admin_email' );

function_exists() only tests global functions. Verified locally:

php -r 'var_dump(function_exists("DateTime::format"));'   // bool(false)
php -r 'var_dump(method_exists("DateTime", "format"));'    // bool(true)

So the conditional is always falseEmails::get_reply_to_email() is unreachable, and *CONTACT_EMAIL* always falls back to admin_email. On sites where Reader Activation sets a contact_email_address option (which is exactly the publisher-facing reply-to behaviour previews should show), the preview will display the wrong address relative to the real outbound email.

Replace with method_exists:

$reply_to_email = method_exists( Emails::class, 'get_reply_to_email' )
    ? Emails::get_reply_to_email()
    : get_bloginfo( 'admin_email' );

Even simpler: Emails is use-imported at the top of the file and lives in the same plugin, so the guard is unnecessary. Just call Emails::get_reply_to_email() directly.

Pin it with a PHPUnit case: set the newspack_ras_contact_email_address option (or filter newspack_reply_to_email), generate a preview from a template containing *CONTACT_EMAIL*, and assert the rendered HTML contains the configured address rather than admin_email. The current substitution-map test would not have caught this because it doesn't render against a real template.


Security

2. sandbox="allow-same-origin" weakens the iframe sandbox — document the trade-off

src/wizards/newspack/views/settings/emails/email-preview.tsx:164

The commit message (2d6fe465b) explains why the change was made: without allow-same-origin, iframe.contentDocument is null, so the handleIframeLoad measurement logic can't read body.scrollHeight or query stylesheets. That's correct — same-origin is required for the measurement contract borrowed from NewsletterPreview.

But the security analysis isn't in the code. The relevant facts:

  • allow-scripts is not in the sandbox attribute, so scripts in the email HTML cannot execute. Good.
  • allow-same-origin alone (without allow-scripts) does not let the iframe steal cookies or localStorage because it can't run JS.
  • But <form action="…"> submissions still work without scripts, and with allow-same-origin they submit from the same origin (i.e. with the admin's cookies). Email HTML rarely contains forms, but the preview reads HTML from EMAIL_HTML_META, which is publisher-editable. A malicious editor (or a compromised plugin writing to that meta) could embed a form pointing at an admin endpoint and trigger it on preview render.

Realistic risk: low — this is admin-only code and the attacker would already need write access to email post meta. But the safest hardening that preserves the measurement contract is to inject a <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src *; style-src 'unsafe-inline' *; font-src *;"> into the srcDoc, which blocks form action / link navigation from the iframe entirely. This is also where you'd want <base target="_blank"> in case any anchor escapes the sandbox.

Minimum bar: add a comment above the sandbox attribute explaining (a) why allow-same-origin is required, (b) why scripts are still blocked, and (c) the residual form-action risk. The next reader should not need this review thread to reach the same conclusion.


Resilience / lifecycle

3. In-flight fetch isn't cancelled on postId change or unmount

src/wizards/newspack/views/settings/emails/email-preview.tsx:90-112

useEffect( () => {
    if ( ! isVisible ) return;
    setIsLoading( true );
    // ...resets...
    apiFetch( { /* ... */ } )
        .then( response => setHtml( response.html ) )
        .catch( () => setHasError( true ) )
        .finally( () => setIsLoading( false ) );
}, [ isVisible, postId ] );

If postId changes (DataViews re-uses rows when scrolling) or the component unmounts, the old promise still resolves and writes to state. The state-reset at the top of the new run happens before the next fetch, but the OLD then callback can still fire after the new fetch's then, leaving the iframe showing stale HTML.

Fix:

useEffect( () => {
    if ( ! isVisible ) return;
    let cancelled = false;
    setIsLoading( true );
    setIsReady( false );
    setIframeHeight( null );
    setHasError( false );
    setHtml( null );
    apiFetch< { html: string; post_id: number } >( {
        path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`,
    } )
        .then( response => { if ( ! cancelled ) setHtml( response.html ); } )
        .catch( () => { if ( ! cancelled ) setHasError( true ); } )
        .finally( () => { if ( ! cancelled ) setIsLoading( false ); } );
    return () => { cancelled = true; };
}, [ isVisible, postId ] );

Same cancelled pattern already exists in NewsletterPreview (repos/newspack-newsletters/src/components/newsletter-preview/index.js:80) — worth aligning.

4. Safety timeout in handleIframeLoad is not cleared on unmount or postId change

src/wizards/newspack/views/settings/emails/email-preview.tsx:135-144

const safety = setTimeout( () => { setIframeHeight( ... ); setIsReady( true ); }, 8000 );
Promise.all( [...] ).then( () => { clearTimeout( safety ); /* ... */ } );

clearTimeout( safety ) only fires when Promise.all resolves. If the user scrolls the row out of view, switches tabs, or the component re-mounts before 8 seconds, the timer still ticks and calls setIframeHeight/setIsReady on a stale render. React will warn about state updates on an unmounted component (or silently overwrite state on the same postId, depending on timing).

Hoist the timeout into a ref or attach it via an effect with a cleanup:

const handleIframeLoad = useCallback( () => {
    // ... build promises ...
    const safetyId = setTimeout( finalize, 8000 );
    Promise.all( [...] ).then( () => {
        clearTimeout( safetyId );
        finalize();
    } );
    // No way to clean from here — either ref it, or track `cancelled` via the parent effect.
}, [] );

Cleaner: drive the load tracking from the same useEffect that fetches HTML, so a single cancelled flag covers both fetch and post-load measurement.

5. Promise.all().then() runs even after the safety timeout fires — redundant state writes

Same block as finding (4). If Promise.all resolves after 8 s (slow asset), both code paths fire setIframeHeight and setIsReady. React batches the second call, but it's still wasted work and obscures intent. The fix from (4) (single cancellation source) resolves this too.

6. No onError handler on the iframe element

src/wizards/newspack/views/settings/emails/email-preview.tsx:160-172

Network-level iframe failures (rare with srcDoc, but not impossible) leave the spinner spinning until the 8 s safety. Add onError={ () => setHasError( true ) }.

7. Initial paint shows nothing for a frame

src/wizards/newspack/views/settings/emails/email-preview.tsx:39, 159

const [ scale, setScale ] = useState( 0 );
// ...
{ html && ! hasError && scale > 0 && <iframe ... /> }

scale === 0 until ResizeObserver fires (one tick after mount). For that tick, neither the spinner nor the iframe is rendered (the spinner is gated by isLoading || (html && !isReady), and right after fetch resolves both flags can be false simultaneously for a frame). Cleanest fix: initialise scale to null and use scale !== null && scale > 0 in the guard, OR always render the spinner placeholder when ! isReady.


PHP — quality & correctness

8. *CONTACT_EMAIL* substitution embeds raw $reply_to_email into HTML

includes/wizards/newspack/class-email-preview.php:131

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

This mirrors Emails::get_email_payload() (includes/emails/class-emails.php:215-218), which has the same issue. If the reply-to address ever contains characters that need escaping (it shouldn't for a sanitised email, but defence in depth), this becomes an HTML/URL injection vector. Recommend:

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

Calling out the parallel bug in class-emails.php so this slice can either fix both or file a follow-up.

9. *DATE* uses gmdate() rather than site time

includes/wizards/newspack/class-email-preview.php:144

'*DATE*' => gmdate( get_option( 'date_format', 'F j, Y' ) ),

Real transactional emails use the publisher's timezone (wp_date() or date_i18n()). For a preview, a UTC date may show "May 14" while the publisher's locale shows "May 15". Use wp_date( get_option( 'date_format', 'F j, Y' ) ) — same call signature, but applies the site timezone and locale.

10. Template fallback uses raw include

includes/wizards/newspack/class-email-preview.php:82

$template_data = include $template_path;

Email templates today return arrays, but include runs arbitrary PHP. If a template file ever runs side-effecting code (action hooks, option writes), the preview request will trigger it. The is_readable() guard added in commit 92d6a42 protects against missing files but not against malicious or stateful templates.

Mitigations:

  • Tighten the template path to a known safe directory: if ( 0 !== strpos( realpath( $template_path ), realpath( NEWSPACK_ABSPATH . 'includes/templates' ) ) ) return '';.
  • Or: load the template via Emails::get_email_config_by_type() instead of include-ing the path yourself. The class already has the loading semantics worked out (class-emails.php:340 load_email_template).

11. No caching — every grid render does 12-24 substitution passes per preview

includes/wizards/newspack/class-email-preview.php:103-107

foreach ( self::get_sample_substitutions() as $token => $value ) {
    $html = str_replace( $token, $value, $html );
}

29 str_replace passes over the HTML, called per post_id, with no transient cache. For the visible grid (12-24 cards depending on viewport), that's 12-24 × 29 passes per page view. On a publisher's slow shared host this adds up.

Two cheap wins:

  1. Use strtr( $html, $map ) — single pass over the string instead of N passes:
    return strtr( $html, self::get_sample_substitutions() );
  2. Cache per post_id keyed on post_modified_gmt of the email post (transient or wp_cache_*). Invalidates automatically when the publisher edits the email.

12. Substitution map has no filter hook

includes/wizards/newspack/class-email-preview.php:114-164

get_sample_substitutions() is public static, which suggests it's extension-ready, but there's no apply_filters( 'newspack_email_preview_substitutions', $substitutions, $post_id ) to let other plugins inject sample values for their own tokens (e.g. group-subscription invite tokens, future tokens from PR follow-ups). Without this, every new token will require editing this file.

Add the filter, and have apply_sample_substitutions() accept $post_id so the filter can vary by email type.

13. *SITE_ADDRESS* / *SITE_CONTACT* logic duplicates Emails::get_email_payload

includes/wizards/newspack/class-email-preview.php:174-200 vs includes/emails/class-emails.php:186-211

The address-formatting logic is copy-pasted from Emails::get_email_payload. The docblock acknowledges this ("Mirrors the logic in Emails::get_email_payload()") but doesn't reference the line range, so the next refactor of either will silently diverge. Two options:

  • Extract Emails::get_site_address(): string (or similar) as a public static helper and call it from both.
  • At minimum, expand the docblock to "Mirror of Emails::get_email_payload() lines 186-211; keep in sync."

14. function_exists/class_exists checks for Newspack_Newsletters

includes/wizards/newspack/class-email-preview.php:210-212, 14-15

use Newspack_Newsletters;
// ...
return class_exists( 'Newspack_Newsletters' );

Importing a class via use doesn't trigger autoloading, so the class_exists check works. But the file unconditionally reads Newspack_Newsletters::EMAIL_HTML_META at line 58 — if Newspack_Newsletters is missing, get_source_html() runs before is_supported() is called from get_preview_html(), and the constant access would fail.

Trace: get_preview_html() (line 34) → is_supported() (line 35) returns false → returns false. Before reaching get_source_html(). So the order is safe.

But get_source_html() is private static and only called from get_preview_html() which guards, so it's defensive enough. Worth a one-line comment saying so.

15. *MAGIC_LINK_OTP* is a single fixed string

includes/wizards/newspack/class-email-preview.php:162

'*MAGIC_LINK_OTP*' => '123456' — a 6-digit fixed sample. If *MAGIC_LINK_OTP* ever lives in HTML that uses character-count constraints (very tight inline styles), 123456 is a reasonable typical width. No issue, but worth mentioning that if the real OTP format changes (e.g. 8 chars), this sample needs to track it.


Tests

16. The PHP tests don't cover the function_exists bug surface

tests/unit-tests/email-preview.php:69-90

The substitution-map test asserts keys exist; it doesn't verify that *CONTACT_EMAIL*'s value resolves through Emails::get_reply_to_email() when the option is set. Add a case that:

  1. Sets update_option( 'newspack_ras_contact_email_address', 'reply@example.org' ) (or uses the filter).
  2. Generates a preview of a template containing *CONTACT_EMAIL*.
  3. Asserts the rendered HTML contains reply@example.org, not admin_email.

This pins finding (1).

17. Permission-check path isn't asserted

tests/unit-tests/email-preview.php

api_permissions_check() is annotated @codeCoverageIgnore, but a negative test (non-admin user → 403) round-trips the security contract. Two lines:

public function test_api_permissions_check_rejects_non_admin() {
    wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) );
    $result = Email_Preview::api_permissions_check();
    self::assertInstanceOf( 'WP_Error', $result );
}

18. 404 test should assert the HTTP status, not just the error code

tests/unit-tests/email-preview.php:133-143

self::assertEquals( 'newspack_email_preview_not_found', $response->get_error_code() );

Add:

self::assertEquals( 404, $response->get_error_data()['status'] );

19. Jest tests don't cover the race condition (finding 3) or the safety-timeout (finding 4)

src/wizards/newspack/views/settings/emails/email-preview.test.js

Specifically missing:

  • postId changes mid-fetch: old fetch resolves after new fetch. Assert the displayed srcDoc reflects the new postId. Easiest setup: two mockImplementationOnce returning controlled promises; resolve them out of order.
  • 8 s safety timeout: mock setTimeout, advance Jest's fake timers, assert is-ready flips even when no load event fires.
  • iframe onError (after finding 6 is added).

20. simulateIframeLoad stubs contentDocument.querySelectorAll to always return []

src/wizards/newspack/views/settings/emails/email-preview.test.js:60

That means the Promise.all path always resolves with zero promises (synchronously). The real iframe will have an asynchronous-load story. The test never exercises the link/img load-wait path. Add a test that stubs querySelectorAll to return a fake link with no .sheet and an unresolved load event, then asserts the spinner stays visible until the load event fires.

21. renders loading state while fetching relies on Spinner's role=presentation

src/wizards/newspack/views/settings/emails/email-preview.test.js:74-81

expect( screen.getByRole( 'presentation' ) ).toBeTruthy();

This works because <svg role="presentation"> lives inside <Spinner>. If the Spinner implementation in @wordpress/components ever drops that role, the test breaks for the wrong reason. Prefer expect( document.querySelector( '.newspack-email-preview__placeholder' ) ).toBeInTheDocument().


CSS

22. Placeholder colour is a hardcoded hex

src/wizards/newspack/views/settings/emails/email-preview.scss:36

color: #949494;

Per Newspack's design conventions (see packages/colors/DEVELOPMENT.md), admin surfaces should use WordPress core colour tokens or the Newspack colour palette. Replace with:

@use "~@wordpress/base-styles/colors" as wp-colors;
// ...
color: wp-colors.$gray-400;

(Or the equivalent Newspack neutral. The existing emails.scss already imports wp-colors — same pattern.)

23. pointer-events: none on the iframe blocks user-initiated focus

src/wizards/newspack/views/settings/emails/email-preview.scss:20

Combined with tabIndex={ -1 } on the iframe element, this correctly makes the preview a non-interactive thumbnail. Good. No change.

24. transition: opacity 200ms on the iframe means the spinner can briefly overlap

src/wizards/newspack/views/settings/emails/email-preview.scss:22

The container shows the spinner while ! isReady, and the iframe is opacity: 0 until is-ready is added. During the 200ms transition both can be visible. If the spinner has a solid background-coloured placeholder, no flash; if it's transparent, the iframe shows through. Worth a manual smoke test on slow connections.


Repo / process

25. PR diff includes a 354-line README that's unrelated to this PR

includes/reader-activation/integrations/README.md

The README addition comes from commit dcb057df5 docs(integrations): add framework README (#4722), which is an unrelated merged PR. It shows in this diff because the base branch (nppd-945-unified-emails-newspack-slice) was cut before #4722 merged to trunk. When PR #4727 rebases on / merges into trunk, this file will fall out of the diff naturally.

Nothing to fix here, but flagging so the reviewer agent doesn't waste cycles auditing it. The base branch could be rebased onto current trunk to clean up, but rebasing a stacked-PR base mid-review is usually more disruptive than the noise it removes.

26. PR is stacked on PR #4727 — review order matters

baseRefName: nppd-945-unified-emails-newspack-slice

This PR's correctness depends on:

If PR #4727 merges with any changes to those contracts (e.g. the duplicate-fetch consolidation tracked as NPPD-1531), the preview component may need a touchup. Keep the stack tight and merge in order.

27. Preview component is not exposed via useWizardData store

src/wizards/newspack/views/settings/emails/email-preview.tsx:100

Same pattern as the slice 1 review (PR #4727 finding 5): apiFetch directly bypasses the @wordpress/data store. For 24 cards this is fine — the cardinality is bounded. But if the preview is ever reused in a wizard with paginated emails, the store would dedupe requests across cards with the same post_id. Note for future consolidation.


Verified / non-issues

  • IntersectionObserver and ResizeObserver guards correctly fall back to "eager load" and "scale=1" (email-preview.tsx:48-51, 72-75). Addresses Copilot finding 3247899534.
  • is_readable() guard before include (class-email-preview.php:78) addresses Copilot finding 3247899474.
  • tabIndex={ -1 } plus pointer-events: none correctly takes the iframe out of focus/click reach for screen readers and pointer users — the row is the actionable unit.
  • The is-ready opacity-fade contract is borrowed cleanly from NewsletterPreview (repos/newspack-newsletters/src/components/newsletter-preview/index.js:80-106). Pattern is sound.
  • permission_callback uses manage_options — matches every other Newspack settings endpoint.
  • POST_TYPE 404 check in api_get_preview() (class-email-preview.php:258) means non-email posts can't be probed for HTML content.

Suggested split for the agent picking this up

  1. Fix-up commit (must-have): finding (1) function_exists bug + finding (16) test that pins it. Two-line code change plus a short test.
  2. Security-comment commit (must-have): finding (2) — comment block above the sandbox attribute explaining the trade-off, optionally plus a CSP <meta> injected into the srcDoc.
  3. Resilience commit: findings (3), (4), (5), (6), (7) — unify under a single cancelled flag in the fetch effect, clear the safety timeout, add onError, defend initial paint. About 30 lines.
  4. PHP polish commit: findings (8), (9), (11), (12) — escape mailto, use wp_date, strtr instead of foreach str_replace, add the filter hook. About 15 lines.
  5. Test fill-in commit: findings (17), (18), (19), (20), (21). About 50 lines of test code.
  6. Follow-up tickets: (10) template-path hardening, (13) extract Emails::get_site_address, (27) data-store consolidation alongside NPPD-1531.

Findings (1), (2), and (3) are the ones most likely to bite real users. Everything else is polish or defensive.

@kmwilkerson
Copy link
Copy Markdown
Author

Addressed in 6b8552b. All actionable findings from the review:

Bugs

  • Finding 1 — function_exists() → call Emails::get_reply_to_email() directly (it's use-imported, guard unnecessary). Pinned with PHPUnit test that filters newspack_reply_to_email and asserts the preview resolves to the custom address.

Security

  • Finding 2 — Added inline comment above sandbox="allow-same-origin" documenting: why it's required (contentDocument access), why scripts are blocked, and the residual form-action risk.

Resilience

  • Finding 3 — Added cancelled flag to the fetch effect with cleanup function, matching the NewsletterPreview pattern.
  • Finding 4 — Safety timeout tracked via safetyTimerRef, cleared in the fetch effect's cleanup and when finalize() runs.
  • Finding 5 — Single finalize() function with a finalized guard prevents double state writes from both the Promise.all path and the safety timeout.
  • Finding 6 — Added onError={ () => setHasError( true ) } on the iframe element.
  • Finding 7 — Init scale to null (not 0). Spinner now shows via a unified showSpinner variable that covers loading, fetched-but-not-ready, and the initial-paint frame.

PHP polish

  • Finding 8 — *CONTACT_EMAIL* now uses esc_url( 'mailto:' . $email ) and esc_html( $email ).
  • Finding 9 — Replaced gmdate() with wp_date() so preview dates match the site timezone.
  • Finding 11 — Replaced the foreach str_replace loop with a single strtr() call.
  • Finding 12 — Added apply_filters( 'newspack_email_preview_substitutions', $substitutions, $post_id ) so plugins can inject sample values for custom tokens.

Tests

  • Finding 16 — PHPUnit: test_contact_email_uses_reply_to_email() pins the function_exists bug fix.
  • Finding 17 — PHPUnit: test_api_permissions_check_rejects_non_admin() asserts subscriber gets WP_Error.
  • Finding 18 — PHPUnit: 404 test now also asserts $response->get_error_data()['status'] === 404.
  • Finding 19 — Jest: cancelled fetch does not update state when postId changes mid-flight — two controlled promises resolved out of order, asserts stale HTML is discarded. Safety-timeout and iframe-onError tests are noted as not testable in jsdom (auto-load behavior prevents pending-asset simulation).
  • Finding 21 — Jest: Replaced screen.getByRole('presentation') with document.querySelector('.newspack-email-preview__placeholder').

CSS

  • Finding 22 — Replaced hardcoded #949494 with wp-colors.$gray-400.

Deferred (follow-up tickets)

  • Finding 10 — Template-path hardening (restrict include to known safe directory)
  • Finding 13 — Extract Emails::get_site_address() shared helper
  • Finding 27 — Data-store consolidation alongside NPPD-1531

kmwilkerson added a commit that referenced this pull request May 15, 2026
…r APIs (NPPD-1525)

Address Copilot review feedback on PR #4730:
- PHP: guard template include with is_readable() to avoid warnings if
  the registered template path is missing or unreadable.
- JS: add typeof checks for IntersectionObserver and ResizeObserver so
  the component degrades gracefully in browsers without these APIs
  (falls back to eager loading and scale=1 respectively).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson kmwilkerson force-pushed the nppd-1525-email-preview-component branch from 6b8552b to 8cac5e6 Compare May 15, 2026 16:23
@thomasguillot
Copy link
Copy Markdown
Contributor

Re-review at 6b8552b37

Verified the fixup against the earlier review. Reading the actual code — every actionable finding is addressed correctly, with appropriate test coverage. Deferred items are reasonable. Two small notes worth a glance.

Verified resolved

# Finding Where it landed Notes
1 function_exists() → call Emails::get_reply_to_email() directly class-email-preview.php:129 $reply_to_email = Emails::get_reply_to_email(); — guard removed. Pinned by test_contact_email_uses_reply_to_email (tests/unit-tests/email-preview.php:99-112), which filters newspack_reply_to_email and asserts the custom address resolves into the preview HTML.
2 Document the allow-same-origin trade-off email-preview.tsx:196-201 Multi-line comment block above the sandbox attribute explains: (a) why same-origin is needed (contentDocument access), (b) why scripts are still blocked, (c) the residual form-action risk. Reads exactly as I'd want a future reader to encounter it.
3 Cancel in-flight fetch on postId change / unmount email-preview.tsx:96-134 let cancelled = false; checked in .then / .catch / .finally. Effect returns a cleanup that flips it. Mirrors the pattern in NewsletterPreview.
4 Clear safety timeout on cleanup email-preview.tsx:35, 99-102, 128-134, 163-166, 172 safetyTimerRef = useRef<…>(null) — cleared in the fetch-effect cleanup AND inside finalize(). Belt and suspenders.
5 Single source of truth for finalize email-preview.tsx:157-169 let finalized = false; const finalize = () => { if ( finalized ) return; finalized = true; … }. No double state writes.
6 onError on iframe email-preview.tsx:206 onError={ () => setHasError( true ) }.
7 Initial-paint blank-frame email-preview.tsx:40, 177, 191 scale defaults to null; iframe is gated by scale !== null && scale > 0; spinner gated by a unified showSpinner = ! hasError && ! isReady && ( isLoading || Boolean( html ) ). Covers loading + fetched-but-not-ready + the post-ResizeObserver pre-scale frame.
8 Escape *CONTACT_EMAIL* class-email-preview.php:142 sprintf( '<a href="%s">%s</a>', esc_url( 'mailto:' . $reply_to_email ), esc_html( $reply_to_email ) ).
9 wp_date() instead of gmdate() class-email-preview.php:155 wp_date( get_option( 'date_format', 'F j, Y' ) ) — site timezone + locale.
11 strtr instead of 29 str_replace passes class-email-preview.php:117 Single-pass substitution.
12 Filter hook on substitutions class-email-preview.php:106-115 apply_filters( 'newspack_email_preview_substitutions', $substitutions, $post_id ) with a real docblock explaining when to use it. apply_sample_substitutions() signature widened to accept $post_id so the filter has context.
16 PHPUnit test for finding (1) tests/unit-tests/email-preview.php:99-112 See finding (1) above.
17 Permission-check negative test tests/unit-tests/email-preview.php:155-159 Subscriber → WP_Error.
18 404 test asserts the status tests/unit-tests/email-preview.php:174 assertEquals( 404, $response->get_error_data()['status'] ) added next to the error-code assertion.
19 Race-condition Jest test email-preview.test.js:178-215 Excellent — controlled firstPromise, second fetch resolves first, then the stale first resolves and is not allowed to overwrite the iframe. The exact race I described. Clean assertion: srcDoc must contain "Second email" and NOT "First email".
21 getByRole('presentation').newspack-email-preview__placeholder querySelector email-preview.test.js:80, 114 Replaced in both the loading and error tests. No longer depends on the implementation detail of WordPress Spinner's role.
22 Hardcoded #949494 → design token email-preview.scss:1, 38 @use "~@wordpress/base-styles/colors" as wp-colors; import added; color: wp-colors.$gray-400.

Deferred (acknowledged in author response)

  • (10) Template-path hardeninginclude $template_path is gated by is_readable() only; the attacker bar is "compromise the newspack_email_configs filter," which already implies PHP execution capability, so a follow-up realpath()-within-known-dir check is defense-in-depth rather than load-bearing. Reasonable to defer.
  • (13) Extract Emails::get_site_address() shared helper — cosmetic dedupe with Emails::get_email_payload() lines 186-211. Drift risk exists but is small.
  • (27) Data-store consolidation alongside NPPD-1531 — same scope as the slice 1 follow-up.

Two small new observations

A. apply_sample_substitutions $post_id = 0 default is unreachable

includes/wizards/newspack/class-email-preview.php:103

private static function apply_sample_substitutions( string $html, int $post_id = 0 ): string {

The method is private static and only called from get_preview_html() (line 44), which always passes the actual $post_id. The = 0 default never fires. Tiny nit — either drop the default, or make the method public so the filter can be invoked standalone for ad-hoc previews. Whichever, it'd be more honest about intent.

B. test_contact_email_uses_reply_to_email cleans up via remove_all_filters at the end

tests/unit-tests/email-preview.php:111

remove_all_filters( 'newspack_reply_to_email' );

If the assertions above it fail, the filter leaks to subsequent tests in the class — every later Emails::get_reply_to_email() call would still return reply@example.org. Belt-and-suspenders: move the removal into a tear_down() override, or wrap the test body in try { … } finally { remove_all_filters( … ); }. Minor — the test passes today.

C. Jest skip note for safety-timeout / iframe onError is the right call

src/wizards/newspack/views/settings/emails/email-preview.test.js:217-221

The comment at the bottom explains exactly why these two defensive paths aren't unit-tested:

jsdom automatically fires the iframe load event when srcDoc is set, which prevents us from simulating pending-asset scenarios.

That's correct — and it's the kind of transparency a future maintainer needs. If the project ever spins up Playwright/Cypress for the wizards screen, this is the place to add the two missing scenarios. No action for this PR.

D. README cherry-pick still present in the diff

includes/reader-activation/integrations/README.md

Same as last round — falls out when PR #4727 merges. No action.


Verdict

Ship-ready. The fixup is comprehensive: every behavioural fix lands cleanly, the race-condition test is genuinely good (catches the exact bug it's named for, not a proxy), and the security comment is the kind of inline rationale a future reader needs. The three deferred items are all reasonable for follow-up tickets.

Original review for the audit trail: #4730 (comment)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

tests/unit-tests/email-preview.php:112

  • remove_all_filters( 'newspack_reply_to_email' ) is very broad and can break unrelated tests (or even plugin bootstrap behavior) that rely on that filter later in the same PHPUnit process. Prefer removing only the callback added in this test (e.g. keep a reference to the closure and call remove_filter with it).
		$custom_email = 'reply@example.org';
		add_filter( 'newspack_reply_to_email', fn() => $custom_email );

		$source_html = '<html><body>Contact us at *CONTACT_EMAIL*</body></html>';
		$post_id     = $this->create_email_post( $source_html );

		$result = Email_Preview::get_preview_html( $post_id );

		self::assertStringContainsString( $custom_email, $result, 'CONTACT_EMAIL should resolve to the filtered reply-to address.' );
		self::assertStringNotContainsString( '*CONTACT_EMAIL*', $result, 'Raw CONTACT_EMAIL token should not remain.' );

		remove_all_filters( 'newspack_reply_to_email' );
	}

Comment thread tests/unit-tests/email-preview.php
Comment thread src/wizards/newspack/views/settings/emails/email-preview.tsx
Comment thread src/wizards/newspack/views/settings/emails/emails.test.js Outdated
Comment thread includes/reader-activation/integrations/README.md
miguelpeixe and others added 7 commits May 15, 2026 12:02
Backend:
- New Email_Preview class registers GET /wizard/newspack-settings/emails/{id}/preview
- Renders cached EMAIL_HTML_META with sample-value token substitution
- Template fallback for emails with no rendered HTML stored
- Reader/transaction tokens use stable fake values; site/branding tokens use
  real publisher values; action URLs use # anchors

Frontend:
- React component with IntersectionObserver lazy-loading via isVisible bridge
- Iframe srcDoc rendering with sandbox="" for security
- Spinner loading state, envelope icon fallback on error
- Component exported; not yet integrated into emails.tsx

Tests:
- 6 PHPUnit tests
- 5 Jest tests

Also fixes a missing tab indentation in includes/class-newspack.php on the
new class-email-preview.php include line.
Replaces the envelope-icon preview placeholder with the live EmailPreview
component built in 49b5bae1e. Each card now renders a lazy-loaded iframe
thumbnail of the rendered email HTML with sample-value token substitution.
…-1525)

CSS container query units (cqw) produce length values, but scale()
requires a unitless number. The browser silently discarded the invalid
transform, rendering the 600px iframe at full size with only the logo
visible through overflow:hidden. Switch to ResizeObserver to measure
the container width and set the scale as an inline style.

Also removes the dead .newspack-emails__preview-placeholder rule
replaced by the EmailPreview component, and restructures the SCSS
to use BEM nesting with responsive container sizing (aspect-ratio 4/3).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PHP lint:
- class-email-preview.php line 139: auto-fix array double arrow alignment
- tests/email-preview.php lines 93/109/123: capitalize docblock summaries

Jest:
- email-preview.tsx: fix IntersectionObserver TDZ crash. The newspack-scripts
  Jest mock invokes the observer callback synchronously inside the constructor,
  before the const observer assignment completes. Moved disconnect logic out
  of the callback and into the effect cleanup, gated by isVisible dependency.
- email-preview.test.js, emails.test.js: add ResizeObserver mock for JSDOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lint:
- email-preview.tsx line 47: Prettier wants single arrow param without parens

Test:
- emails.test.js: mock EmailPreview to render null. The slice 1 test renders
  the full DataViews grid, which now includes EmailPreview in every card via
  the wire-in commit 5898082. EmailPreview's fetch effect hangs in the test
  because the slice 1 test mocks apiFetch only for the settings endpoint, not
  the preview endpoint. Mocking the component is the right pattern — its own
  behavior is covered by email-preview.test.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…r APIs (NPPD-1525)

Address Copilot review feedback on PR #4730:
- PHP: guard template include with is_readable() to avoid warnings if
  the registered template path is missing or unreadable.
- JS: add typeof checks for IntersectionObserver and ResizeObserver so
  the component degrades gracefully in browsers without these APIs
  (falls back to eager loading and scale=1 respectively).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kmwilkerson and others added 3 commits May 15, 2026 12:02
…act (NPPD-1525)

Address thomasguillot review feedback: 848px source viewport, 1:1 aspect
ratio, fade-in via is-ready class with opacity transition, iframe height
measured from contentDocument after stylesheets/images load (8s safety
timeout), state reset on postId change. Adds tests for is-ready class
and reset-on-postId-change behaviour.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…is accessible (NPPD-1525)

With sandbox="" the iframe is treated as a unique opaque origin, making
contentDocument null. The onLoad handler returned early and never set
isReady, leaving the spinner visible indefinitely. allow-same-origin is
safe here because allow-scripts is not set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix function_exists bug: call Emails::get_reply_to_email() directly
  since function_exists() cannot test class methods
- Add security comment on sandbox="allow-same-origin" explaining why
  it is needed and the residual risk profile
- Add cancelled flag to fetch effect so stale responses from a
  previous postId don't overwrite current state
- Unify safety timeout via finalized flag to prevent double state
  writes, and clear timer on unmount via ref
- Add onError handler on iframe for network-level failures
- Fix initial paint gap: init scale to null so spinner shows until
  ResizeObserver fires
- Escape CONTACT_EMAIL with esc_url/esc_html for defense in depth
- Use wp_date() instead of gmdate() so preview dates match site
  timezone
- Use strtr() for single-pass token substitution instead of N
  str_replace calls
- Add newspack_email_preview_substitutions filter hook so plugins can
  inject sample values for custom tokens
- Replace hardcoded #949494 with wp-colors.$gray-400
- Add PHPUnit tests: contact email resolution, permission check
  rejection, 404 HTTP status assertion
- Add Jest tests: cancelled fetch race condition, adjust selectors
  to use class names instead of fragile role queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kmwilkerson kmwilkerson force-pushed the nppd-1525-email-preview-component branch from 8cac5e6 to 38c03ce Compare May 15, 2026 17:03
kmwilkerson and others added 3 commits May 15, 2026 12:06
Copy link
Copy Markdown
Contributor

@dkoo dkoo left a comment

Choose a reason for hiding this comment

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

@kmwilkerson Great work—tests well and looks great in-browser! Commented inline with some minor blockers and a handful of non-blocking suggestions. The ones with a red 🔴 are the most important comments; others are less important and could be deferred if necessary.

Will review #4727 separately.

*
* @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.

? 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.

Comment on lines +145 to +157
'*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' ),
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' ),

}, [ isVisible, postId ] );

// Handle iframe load: wait for stylesheets and images, then measure height and reveal.
const handleIframeLoad = useCallback( () => {
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.

🟡 Stale iframe load handler (flagged by 2/6)

The fetch effect cancels stale network responses, but handleIframeLoad() has no generation guard. If postId changes mid-load, the previous iframe's Promise.all([...linkPromises, ...imgPromises]) resolves later and finalize() sets isReady/iframeHeight against the new preview, resulting in visual flash and wrong dimensions.

Suggested fix: Add a per-load ref and bail in finalize if it doesn't match the current load.

Comment on lines +137 to +141
'*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,
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 ),

Comment on lines +41 to +42
'label' => __( 'Test preview config', 'newspack' ),
'description' => __( 'Email for testing preview.', 'newspack' ),
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.

🔴 Wrong text domain

Suggested change
'label' => __( 'Test preview config', 'newspack' ),
'description' => __( 'Email for testing preview.', 'newspack' ),
'label' => __( 'Test preview config', 'newspack-plugin' ),
'description' => __( 'Email for testing preview.', 'newspack-plugin' ),

setIframeHeight( null );
setHasError( false );
setHtml( null );
apiFetch< { html: string; post_id: number } >( {
Copy link
Copy Markdown
Contributor

@dkoo dkoo May 18, 2026

Choose a reason for hiding this comment

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

🟡 Performance: redundant fetches on DataView re-renders

Currently, most DataViews remounts (pagination/sort/search) will unnecessarily re-fetch the same postIds. Also, changing the view style to "Table" instead of "Grid" will trigger preview re-fetches even though this view doesn't show any thumbnails.

Suggested fix: Cache fetched preview HTML in a map by postId (maybe in the parent component so it persists without a memo/ref) and load from the cache if available to avoid redundant REST calls.

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: consider useWizardApiFetch instead

Newer wizard views use this shared hook instead of calling apiFetch directly; consider updating to match.

const fetchData = useCallback( () => {
setIsLoading( true );
setError( null );
apiFetch< EmailSettings >( {
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: consider useWizardApiFetch instead

Same as above: newer wizard views use this shared hook instead of calling apiFetch directly; consider updating to match.

* publisher-controlled post meta. */
sandbox="allow-same-origin"
tabIndex={ -1 }
title="Email preview"
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.

🟡 Untranslated string

Let's make sure raw strings are translatable. Note: will need to import __ from @wordpress/i18n

Suggested change
title="Email preview"
title={ __( 'Email preview', 'newspack-plugin' ) }

@dkoo dkoo added the [Status] Needs Changes or Feedback The issue or pull request needs action from the original creator label May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Needs Changes or Feedback The issue or pull request needs action from the original creator [Status] Needs Review The issue or pull request needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants