Skip to content

Commit 8cac5e6

Browse files
kmwilkersonclaude
andcommitted
fix(email-preview): address review feedback (NPPD-1525)
- 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>
1 parent 4d00d35 commit 8cac5e6

5 files changed

Lines changed: 158 additions & 36 deletions

File tree

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public static function get_preview_html( int $post_id ) {
4141
return false;
4242
}
4343

44-
return self::apply_sample_substitutions( $html );
44+
return self::apply_sample_substitutions( $html, $post_id );
4545
}
4646

4747
/**
@@ -95,15 +95,26 @@ private static function get_source_html( int $post_id ): string {
9595
* fake values. Action URLs are replaced with anchor placeholders so
9696
* preview iframes don't trigger live navigation.
9797
*
98-
* @param string $html Source HTML containing *TOKEN* placeholders.
98+
* @param string $html Source HTML containing *TOKEN* placeholders.
99+
* @param int $post_id The email post being previewed (passed to the filter).
99100
*
100101
* @return string HTML with tokens substituted.
101102
*/
102-
private static function apply_sample_substitutions( string $html ): string {
103-
foreach ( self::get_sample_substitutions() as $token => $value ) {
104-
$html = str_replace( $token, $value, $html );
105-
}
106-
return $html;
103+
private static function apply_sample_substitutions( string $html, int $post_id = 0 ): string {
104+
$substitutions = self::get_sample_substitutions();
105+
106+
/**
107+
* Filters the sample substitution map used for email previews.
108+
*
109+
* Allows plugins to inject sample values for custom tokens
110+
* (e.g. group-subscription invite tokens, future token additions).
111+
*
112+
* @param array $substitutions Map of `*TOKEN*` => sample value.
113+
* @param int $post_id The email post being previewed (0 if unknown).
114+
*/
115+
$substitutions = apply_filters( 'newspack_email_preview_substitutions', $substitutions, $post_id );
116+
117+
return strtr( $html, $substitutions );
107118
}
108119

109120
/**
@@ -115,7 +126,7 @@ public static function get_sample_substitutions(): array {
115126
$site_logo_url = wp_get_attachment_url( get_theme_mod( 'custom_logo' ) );
116127
$site_title = get_bloginfo( 'name' );
117128
$site_url = get_bloginfo( 'wpurl' );
118-
$reply_to_email = function_exists( 'Newspack\Emails::get_reply_to_email' ) ? Emails::get_reply_to_email() : get_bloginfo( 'admin_email' );
129+
$reply_to_email = Emails::get_reply_to_email();
119130
$site_address = self::get_site_address();
120131
$site_contact = $site_address
121132
? sprintf( '<strong>%s</strong> — %s', $site_title, $site_address )
@@ -128,7 +139,7 @@ public static function get_sample_substitutions(): array {
128139
'*SITE_LOGO*' => $site_logo_url ? esc_url( $site_logo_url ) : '',
129140
'*SITE_ADDRESS*' => $site_address,
130141
'*SITE_CONTACT*' => $site_contact,
131-
'*CONTACT_EMAIL*' => sprintf( '<a href="mailto:%s">%s</a>', $reply_to_email, $reply_to_email ),
142+
'*CONTACT_EMAIL*' => sprintf( '<a href="%s">%s</a>', esc_url( 'mailto:' . $reply_to_email ), esc_html( $reply_to_email ) ),
132143

133144
// Reader identity — stable sample values.
134145
'*BILLING_FIRST_NAME*' => 'Sample',
@@ -141,7 +152,7 @@ public static function get_sample_substitutions(): array {
141152
'*PAYMENT_METHOD*' => 'Visa ending in 4242',
142153
'*PRODUCT_NAME*' => 'Monthly Membership',
143154
'*BILLING_FREQUENCY*' => 'monthly',
144-
'*DATE*' => gmdate( get_option( 'date_format', 'F j, Y' ) ),
155+
'*DATE*' => wp_date( get_option( 'date_format', 'F j, Y' ) ),
145156
'*CANCELLATION_TITLE*' => __( 'Subscription Cancelled', 'newspack-plugin' ),
146157
'*CANCELLATION_TYPE*' => __( 'subscription', 'newspack-plugin' ),
147158

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@use "~@wordpress/base-styles/colors" as wp-colors;
2+
13
// Email preview thumbnail container.
24
.newspack-email-preview {
35
width: 100%;
@@ -33,6 +35,6 @@
3335
justify-content: center;
3436
width: 100%;
3537
height: 100%;
36-
color: #949494;
38+
color: wp-colors.$gray-400;
3739
}
3840
}

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

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* External dependencies
33
*/
4-
import { render, screen, waitFor } from '@testing-library/react';
4+
import { render, screen, waitFor, act } from '@testing-library/react';
55

66
/**
77
* WordPress dependencies
@@ -77,7 +77,7 @@ describe( 'EmailPreview', () => {
7777

7878
render( <EmailPreview postId={ 123 } /> );
7979

80-
expect( screen.getByRole( 'presentation' ) ).toBeTruthy();
80+
expect( document.querySelector( '.newspack-email-preview__placeholder' ) ).toBeInTheDocument();
8181
} );
8282

8383
it( 'renders iframe on successful fetch and gains is-ready after load', async () => {
@@ -94,14 +94,12 @@ describe( 'EmailPreview', () => {
9494
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Sample Reader' );
9595
} );
9696

97-
// Before onLoad the container should NOT have is-ready.
98-
const container = document.querySelector( '.newspack-email-preview' );
99-
expect( container.classList.contains( 'is-ready' ) ).toBe( false );
100-
101-
// Simulate iframe load.
97+
// Simulate iframe load (also fires automatically in jsdom, but explicit
98+
// call ensures the contentDocument stub is in place for assertion).
10299
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
103100
simulateIframeLoad( iframe );
104101

102+
const container = document.querySelector( '.newspack-email-preview' );
105103
await waitFor( () => {
106104
expect( container.classList.contains( 'is-ready' ) ).toBe( true );
107105
} );
@@ -169,16 +167,56 @@ describe( 'EmailPreview', () => {
169167

170168
rerender( <EmailPreview postId={ 2 } /> );
171169

172-
// is-ready should be removed during re-fetch.
170+
// New iframe should appear with updated content.
173171
await waitFor( () => {
174-
expect( document.querySelector( '.newspack-email-preview' ).classList.contains( 'is-ready' ) ).toBe( false );
172+
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
173+
expect( iframe ).toBeTruthy();
174+
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' );
175175
} );
176+
} );
176177

177-
// New iframe should appear with updated content.
178+
it( 'cancelled fetch does not update state when postId changes mid-flight', async () => {
179+
// First fetch: controlled promise that resolves AFTER the second.
180+
let resolveFirst;
181+
const firstPromise = new Promise( resolve => {
182+
resolveFirst = resolve;
183+
} );
184+
apiFetch.mockReturnValueOnce( firstPromise );
185+
186+
const { rerender } = render( <EmailPreview postId={ 1 } /> );
187+
188+
// Change postId before first fetch resolves.
189+
apiFetch.mockResolvedValueOnce( {
190+
html: '<html><body><p>Second email</p></body></html>',
191+
post_id: 2,
192+
} );
193+
194+
rerender( <EmailPreview postId={ 2 } /> );
195+
196+
// Wait for second fetch to render.
178197
await waitFor( () => {
179198
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
180199
expect( iframe ).toBeTruthy();
181200
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' );
182201
} );
202+
203+
// Now resolve the first (stale) fetch — it should NOT overwrite the iframe.
204+
await act( async () => {
205+
resolveFirst( {
206+
html: '<html><body><p>First email (stale)</p></body></html>',
207+
post_id: 1,
208+
} );
209+
} );
210+
211+
// The iframe should still show the second email, not the stale first.
212+
const iframe = document.querySelector( '.newspack-email-preview__iframe' );
213+
expect( iframe.getAttribute( 'srcdoc' ) ).toContain( 'Second email' );
214+
expect( iframe.getAttribute( 'srcdoc' ) ).not.toContain( 'First email' );
183215
} );
216+
217+
// Note: The safety timeout (8s fallback for slow assets) and the iframe
218+
// onError handler are not tested here because jsdom automatically fires
219+
// the iframe load event when srcDoc is set, which prevents us from
220+
// simulating pending-asset scenarios. These defensive measures work in
221+
// real browsers but require an integration/e2e test environment.
184222
} );

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

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@ const IFRAME_WIDTH = 848;
3232
const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
3333
const containerRef = useRef< HTMLDivElement >( null );
3434
const iframeRef = useRef< HTMLIFrameElement >( null );
35+
const safetyTimerRef = useRef< ReturnType< typeof setTimeout > | null >( null );
3536
const [ isVisible, setIsVisible ] = useState( false );
3637
const [ html, setHtml ] = useState< string | null >( null );
3738
const [ isLoading, setIsLoading ] = useState( false );
3839
const [ hasError, setHasError ] = useState( false );
39-
const [ scale, setScale ] = useState( 0 );
40+
const [ scale, setScale ] = useState< number | null >( null );
4041
const [ iframeHeight, setIframeHeight ] = useState< number | null >( null );
4142
const [ isReady, setIsReady ] = useState( false );
4243

@@ -86,12 +87,20 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
8687
return () => ro.disconnect();
8788
}, [] );
8889

89-
// Fetch preview HTML once visible. Reset state on postId change.
90+
// Fetch preview HTML once visible. Cancel on postId change or unmount.
9091
useEffect( () => {
9192
if ( ! isVisible ) {
9293
return;
9394
}
9495

96+
let cancelled = false;
97+
98+
// Clear any lingering safety timer from a previous postId.
99+
if ( safetyTimerRef.current ) {
100+
clearTimeout( safetyTimerRef.current );
101+
safetyTimerRef.current = null;
102+
}
103+
95104
setIsLoading( true );
96105
setIsReady( false );
97106
setIframeHeight( null );
@@ -101,14 +110,28 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
101110
path: `/newspack/v1/wizard/newspack-settings/emails/${ postId }/preview`,
102111
} )
103112
.then( response => {
104-
setHtml( response.html );
113+
if ( ! cancelled ) {
114+
setHtml( response.html );
115+
}
105116
} )
106117
.catch( () => {
107-
setHasError( true );
118+
if ( ! cancelled ) {
119+
setHasError( true );
120+
}
108121
} )
109122
.finally( () => {
110-
setIsLoading( false );
123+
if ( ! cancelled ) {
124+
setIsLoading( false );
125+
}
111126
} );
127+
128+
return () => {
129+
cancelled = true;
130+
if ( safetyTimerRef.current ) {
131+
clearTimeout( safetyTimerRef.current );
132+
safetyTimerRef.current = null;
133+
}
134+
};
112135
}, [ isVisible, postId ] );
113136

114137
// Handle iframe load: wait for stylesheets and images, then measure height and reveal.
@@ -131,22 +154,31 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
131154
.filter( img => ! img.complete )
132155
.map( awaitLoad );
133156

134-
// 8 s safety so a slow asset never strands the spinner.
135-
const safety = setTimeout( () => {
157+
let finalized = false;
158+
const finalize = () => {
159+
if ( finalized ) {
160+
return;
161+
}
162+
finalized = true;
163+
if ( safetyTimerRef.current ) {
164+
clearTimeout( safetyTimerRef.current );
165+
safetyTimerRef.current = null;
166+
}
136167
setIframeHeight( doc.body.scrollHeight );
137168
setIsReady( true );
138-
}, 8000 );
169+
};
139170

140-
Promise.all( [ ...linkPromises, ...imgPromises ] ).then( () => {
141-
clearTimeout( safety );
142-
setIframeHeight( doc.body.scrollHeight );
143-
setIsReady( true );
144-
} );
171+
// 8 s safety so a slow asset never strands the spinner.
172+
safetyTimerRef.current = setTimeout( finalize, 8000 );
173+
174+
Promise.all( [ ...linkPromises, ...imgPromises ] ).then( finalize );
145175
}, [] );
146176

177+
const showSpinner = ! hasError && ! isReady && ( isLoading || Boolean( html ) );
178+
147179
return (
148180
<div ref={ containerRef } className={ `newspack-email-preview${ isReady ? ' is-ready' : '' }` }>
149-
{ ( isLoading || ( html && ! isReady ) ) && ! hasError && (
181+
{ showSpinner && (
150182
<div className="newspack-email-preview__placeholder">
151183
<Spinner />
152184
</div>
@@ -156,15 +188,22 @@ const EmailPreview: React.FC< EmailPreviewProps > = ( { postId } ) => {
156188
<Icon icon={ envelope } size={ 48 } />
157189
</div>
158190
) }
159-
{ html && ! hasError && scale > 0 && (
191+
{ html && ! hasError && scale !== null && scale > 0 && (
160192
<iframe
161193
ref={ iframeRef }
162194
className="newspack-email-preview__iframe"
163195
srcDoc={ html }
196+
/* sandbox: allow-same-origin is required so handleIframeLoad can
197+
* read contentDocument (body.scrollHeight, stylesheet load state).
198+
* allow-scripts is NOT present, so JS cannot execute.
199+
* Residual risk: a <form> in the HTML could submit with admin
200+
* cookies, but this is admin-only code and the email HTML is
201+
* publisher-controlled post meta. */
164202
sandbox="allow-same-origin"
165203
tabIndex={ -1 }
166204
title="Email preview"
167205
onLoad={ handleIframeLoad }
206+
onError={ () => setHasError( true ) }
168207
style={ {
169208
transform: `scale(${ scale })`,
170209
height: iframeHeight ? `${ iframeHeight }px` : undefined,

tests/unit-tests/email-preview.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@ public function test_sample_substitutions_map() {
8989
}
9090
}
9191

92+
/**
93+
* CONTACT_EMAIL resolves through Emails::get_reply_to_email() — not admin_email.
94+
*
95+
* Pins the fix for the function_exists() bug: function_exists() cannot
96+
* test class methods, so the guard was always false and the token fell
97+
* back to admin_email regardless of the Reader Activation contact setting.
98+
*/
99+
public function test_contact_email_uses_reply_to_email() {
100+
$custom_email = 'reply@example.org';
101+
add_filter( 'newspack_reply_to_email', fn() => $custom_email );
102+
103+
$source_html = '<html><body>Contact us at *CONTACT_EMAIL*</body></html>';
104+
$post_id = $this->create_email_post( $source_html );
105+
106+
$result = Email_Preview::get_preview_html( $post_id );
107+
108+
self::assertStringContainsString( $custom_email, $result, 'CONTACT_EMAIL should resolve to the filtered reply-to address.' );
109+
self::assertStringNotContainsString( '*CONTACT_EMAIL*', $result, 'Raw CONTACT_EMAIL token should not remain.' );
110+
111+
remove_all_filters( 'newspack_reply_to_email' );
112+
}
113+
92114
/**
93115
* Tests that get_preview_html() substitutes tokens in stored EMAIL_HTML_META.
94116
*/
@@ -127,6 +149,15 @@ public function test_get_preview_html_returns_false_for_nonexistent() {
127149
self::assertFalse( $result );
128150
}
129151

152+
/**
153+
* Permission check rejects non-admin users.
154+
*/
155+
public function test_api_permissions_check_rejects_non_admin() {
156+
wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) );
157+
$result = Email_Preview::api_permissions_check();
158+
self::assertInstanceOf( 'WP_Error', $result );
159+
}
160+
130161
/**
131162
* REST API returns 404 for a post that is not of the email post type.
132163
*/
@@ -140,6 +171,7 @@ public function test_api_get_preview_returns_404_for_wrong_post_type() {
140171

141172
self::assertInstanceOf( 'WP_Error', $response );
142173
self::assertEquals( 'newspack_email_preview_not_found', $response->get_error_code() );
174+
self::assertEquals( 404, $response->get_error_data()['status'] );
143175
}
144176

145177
/**

0 commit comments

Comments
 (0)