Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.06% covered (success)
98.06%
202 / 206
96.43% covered (success)
96.43%
27 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Email_Design_Editor
99.51% covered (success)
99.51%
202 / 203
96.43% covered (success)
96.43%
27 / 28
67
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 add_script_data
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 register_feature_flags
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 filter_flag_for_atomic
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_admin_page
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 on_load
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 record_screen_view
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 screen_view_properties
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 read_source
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 site_type
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 add_fullscreen_body_class
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_assets
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
2
 get_layout_css
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_block_editor_assets
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 get_screen_data
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_tracks_transport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 can_send_tracks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_tracks_data
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 tracks_identity
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 tracks_blog_id
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 preview_post_id
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 exit_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_iframe_asset_settings
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 get_allowed_iframe_style_handles
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 get_resolved_assets
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 render
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The newsletter email design screen.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Connection\Manager as Connection_Manager;
9use Automattic\Jetpack\Constants;
10use Automattic\Jetpack\Feature_Flags\Feature_Flags;
11use Automattic\Jetpack\Status;
12use Automattic\Jetpack\Status\Host;
13use Automattic\Jetpack\Terms_Of_Service;
14use Automattic\Jetpack\Tracking;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Registers an Appearance page that mounts the WooCommerce email editor against the
22 * newsletter template, so a creator sets their email design once for the whole site.
23 *
24 * The design itself lives on the WordPress.com shadow blog: the browser fetches it from
25 * `/wpcom/v2/email-editor-bootstrap` and this page supplies only what describes *this*
26 * installation. See NL-839.
27 */
28class Jetpack_Email_Design_Editor {
29
30    /**
31     * The `page` query arg the screen answers to.
32     */
33    const PAGE_SLUG = 'jetpack-email-design';
34
35    /**
36     * The feature flag gating the screen. Registered off, and on Atomic answered by the filter
37     * below rather than by `wp companion feature-flag enable jetpack-email-design`.
38     */
39    const FEATURE_FLAG = 'jetpack-email-design';
40
41    /**
42     * The WordPress.com blog sticker that turns the screen on. See NL-1040.
43     */
44    const STICKER = 'email-design-editor';
45
46    /**
47     * The script handle, and the id of the element the editor mounts into.
48     */
49    const HANDLE = 'jetpack-email-design-editor';
50
51    /**
52     * Flags for JSON handed to a `<script>` tag.
53     */
54    const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP;
55
56    /**
57     * The event the screen's arrival is recorded under, which reaches Tracks as
58     * `jetpack_newsletter_email_design_view` -- `record_user_event()` adds the prefix itself.
59     */
60    const VIEW_EVENT = 'newsletter_email_design_view';
61
62    /**
63     * The hook suffix `add_theme_page()` returned, or null when the page is not registered.
64     *
65     * @var string|null
66     */
67    private static $hook_suffix = null;
68
69    /**
70     * Wire the screen up.
71     */
72    public static function init() {
73        // Registered here rather than on `admin_menu` so the flag exists under WP-CLI, REST
74        // and cron too — `wp companion feature-flag list` reads it from one of those.
75        self::register_feature_flags();
76
77        // Last, so that on Atomic the sticker cannot be overridden by a filter added later --
78        // a flag that outlives the sticker is not a kill switch.
79        add_filter( 'jetpack_feature_flag_enabled_' . self::FEATURE_FLAG, array( __CLASS__, 'filter_flag_for_atomic' ), PHP_INT_MAX );
80
81        add_action( 'admin_menu', array( __CLASS__, 'add_admin_page' ) );
82
83        // Priority 20, because `Newsletter\Settings::add_script_data()` replaces the whole
84        // `newsletter` key at the default priority on the page this link appears on.
85        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ), 20 );
86    }
87
88    /**
89     * Tell the admin where this screen is, or that there is none.
90     *
91     * The bare URL: each surface that links here adds its own `return` arg, because each one is
92     * somewhere different to go back to. {@see self::exit_url()} reads and validates it.
93     *
94     * @param array $data The script data so far.
95     * @return array The script data, with `newsletter.emailDesignUrl` null when there is no screen.
96     */
97    public static function add_script_data( $data ) {
98        $url = self::get_url();
99
100        $data['newsletter']['emailDesignUrl'] = '' === $url ? null : $url;
101
102        return $data;
103    }
104
105    /**
106     * The screen's own URL, or an empty string when this request has no such screen.
107     *
108     * Read back from the registration, not built from the slug, so a link cannot outlive the page:
109     * `add_theme_page()` registers nothing without `edit_theme_options`, which `manage_options` --
110     * all the Newsletter settings page asks for -- does not imply.
111     *
112     * @return string
113     */
114    public static function get_url() {
115        return function_exists( 'menu_page_url' ) ? (string) menu_page_url( self::PAGE_SLUG, false ) : '';
116    }
117
118    /**
119     * Declare the screen's feature flag.
120     */
121    public static function register_feature_flags() {
122        Feature_Flags::register(
123            self::FEATURE_FLAG,
124            array(
125                'default'     => false,
126                'description' => 'Edit the newsletter email design in wp-admin, under Appearance.',
127                'owner'       => 'jetpack-newsletter',
128            )
129        );
130    }
131
132    /**
133     * Answer the flag from the Atomic sticker, or the a8c proxy, where there is one to read.
134     *
135     * The incoming value is dropped rather than widened: on Atomic the sticker is the whole
136     * switch, so removing it has to close the screen -- for everyone who is not on the proxy --
137     * even where the flag was forced on.
138     *
139     * The proxy clause is the a12 rollout (NL-41), and gates dogfooding rather than access:
140     * wpcom's `/wpcom/v2/email-editor-bootstrap` still decides whether the blog is in, so on a
141     * customer's site the screen errors rather than exposing or saving anything.
142     *
143     * Off Atomic wpcomsh's reader answers false for every sticker, because its
144     * `Atomic_Persistent_Data` is a blank stand-in there -- hence the platform check, or the
145     * flag would be pinned off on a site whose only way in is the flag. The reader is checked
146     * as well: a host can define `IS_ATOMIC` without loading wpcomsh, and calling it then is
147     * a fatal.
148     *
149     * @param bool $enabled Whether the flag is enabled so far.
150     * @return bool
151     */
152    public static function filter_flag_for_atomic( $enabled ) {
153        if ( ! defined( 'IS_ATOMIC' ) || ! IS_ATOMIC || ! function_exists( 'wpcomsh_is_site_sticker_active' ) ) {
154            return (bool) $enabled;
155        }
156
157        // Read through `Constants` rather than `defined()`, as the rest of the monorepo's
158        // `AT_PROXIED_REQUEST` call sites do, so this branch is reachable from tests at all.
159        return (bool) wpcomsh_is_site_sticker_active( self::STICKER )
160            || Constants::is_true( 'AT_PROXIED_REQUEST' );
161    }
162
163    /**
164     * Whether the screen should exist on this site.
165     *
166     * On Atomic the `email-design-editor` sticker and the a8c proxy decide, through the filter
167     * above; on Simple wpcom's mu-plugin turns the flag on alongside the sticker. A self-hosted
168     * site cannot read its own blog's stickers, so the flag is the gate there -- the sticker
169     * still gates the bootstrap.
170     *
171     * @return bool
172     */
173    public static function is_enabled() {
174        return Feature_Flags::is_enabled( self::FEATURE_FLAG );
175    }
176
177    /**
178     * Add the screen under Appearance.
179     */
180    public static function add_admin_page() {
181        if ( ! self::is_enabled() ) {
182            return;
183        }
184
185        self::$hook_suffix = add_theme_page(
186            __( 'Email Design', 'jetpack' ),
187            __( 'Email Design', 'jetpack' ),
188            'edit_theme_options',
189            self::PAGE_SLUG,
190            array( __CLASS__, 'render' )
191        );
192
193        if ( self::$hook_suffix ) {
194            add_action( 'load-' . self::$hook_suffix, array( __CLASS__, 'on_load' ) );
195        }
196    }
197
198    /**
199     * Scope everything else to this one screen.
200     */
201    public static function on_load() {
202        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
203        add_filter( 'admin_body_class', array( __CLASS__, 'add_fullscreen_body_class' ) );
204
205        // On `shutdown` rather than here: the Tracks pixel is a blocking request with a one-second
206        // timeout, so recorded now it would sit in front of the screen's first paint.
207        add_action( 'shutdown', array( __CLASS__, 'record_screen_view' ) );
208    }
209
210    /**
211     * Record the arrival, with the route that brought the creator here.
212     *
213     * Server-side so the three routes are counted the same way: entered from the Appearance menu
214     * there is no click to record, and recorded here it also counts a creator whose editor never
215     * loaded, which the bundle's own events cannot. See NL-1088.
216     */
217    public static function record_screen_view() {
218        // Jetpack's recorder refuses on Simple: it asks the terms of service and the user's
219        // connection, and a Simple site has neither to answer with. WordPress.com records its own
220        // events there instead, and does not prefix the name, so this passes the whole one.
221        if ( ( new Host() )->is_wpcom_simple() ) {
222            if ( function_exists( 'wpcom_record_tracks_event' ) ) {
223                wpcom_record_tracks_event( 'jetpack_' . self::VIEW_EVENT, self::screen_view_properties() );
224            }
225
226            return;
227        }
228
229        $connection = new Connection_Manager( 'jetpack' );
230
231        ( new Tracking( 'jetpack', $connection ) )->record_user_event(
232            self::VIEW_EVENT,
233            self::screen_view_properties()
234        );
235    }
236
237    /**
238     * What the arrival event carries.
239     *
240     * Separate from the call above because that one cannot be asserted: Tracks refuses to record
241     * for a test user, before the event is built, so these are the only part of the contract with
242     * whoever reads the funnel that a test can see.
243     *
244     * @return array
245     */
246    private static function screen_view_properties() {
247        return array(
248            'source'    => self::read_source(),
249            'site_type' => self::site_type(),
250        );
251    }
252
253    /**
254     * Which surface sent the creator here.
255     *
256     * An explicit arg rather than a guess at the others: the previewer's link carries neither
257     * `post_id` nor `return` when the post is unsaved and has no id, which is indistinguishable
258     * from the Appearance menu. Allowlisted because the arg is as hand-editable as any other.
259     *
260     * @return string
261     */
262    private static function read_source() {
263        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read as an attribution label only, and allowlisted below.
264        $source = isset( $_GET['source'] ) ? sanitize_key( wp_unslash( $_GET['source'] ) ) : '';
265
266        if ( '' === $source ) {
267            return 'admin_menu';
268        }
269
270        return in_array( $source, array( 'newsletter_settings', 'preview_modal' ), true )
271            ? $source
272            : 'unknown';
273    }
274
275    /**
276     * The platform, spelled the way `getSiteType()` spells it for the screen's other events.
277     *
278     * @return string
279     */
280    private static function site_type() {
281        $host = new Host();
282
283        if ( $host->is_wpcom_simple() ) {
284            return 'simple';
285        }
286
287        return $host->is_woa_site() ? 'woa' : 'jetpack';
288    }
289
290    /**
291     * Put the screen in fullscreen mode before the bundle has loaded.
292     *
293     * `wp-editor` hides the admin menu on this class, and the editor sets it from a React
294     * component — so it cannot apply until the bundle has loaded and the bootstrap has answered,
295     * and the page visibly reflows a few seconds in. Setting it here makes fullscreen the layout
296     * from first paint. The admin bar is untouched, which keeps a way out if the editor fails.
297     *
298     * @param string $classes Space-separated admin body classes.
299     * @return string
300     */
301    public static function add_fullscreen_body_class( $classes ) {
302        return trim( $classes . ' is-fullscreen-mode' );
303    }
304
305    /**
306     * Enqueue the editor bundle and the block-editor assets it expects to find.
307     */
308    public static function enqueue_assets() {
309        $asset_path = JETPACK__PLUGIN_DIR . '_inc/build/email-design-editor.asset.php';
310
311        if ( ! file_exists( $asset_path ) ) {
312            return;
313        }
314
315        $asset = include $asset_path;
316
317        self::enqueue_block_editor_assets();
318        self::enqueue_tracks_transport();
319
320        // `@woocommerce/email-editor` opts into core's private APIs as `@wordpress/edit-site`,
321        // resolved against the site's `wp-private-apis`. Re-check on a package bump. NL-839 (j).
322        wp_enqueue_script(
323            self::HANDLE,
324            plugins_url( '_inc/build/email-design-editor.js', JETPACK__PLUGIN_FILE ),
325            $asset['dependencies'],
326            $asset['version'],
327            true
328        );
329        wp_set_script_translations( self::HANDLE, 'jetpack' );
330
331        // `wp-editor`, `wp-block-editor` and `wp-preferences` are what lay the editor's frame
332        // out; without them every region stacks into one narrow column. Keep the list to
333        // handles WordPress registers — an unregistered one drops this stylesheet silently,
334        // which is `wp-interface`'s trap.
335        wp_enqueue_style(
336            self::HANDLE,
337            plugins_url( '_inc/build/email-design-editor.css', JETPACK__PLUGIN_FILE ),
338            array(
339                'wp-components',
340                'wp-block-editor',
341                'wp-editor',
342                'wp-edit-blocks',
343                'wp-preferences',
344                'wp-format-library',
345            ),
346            $asset['version']
347        );
348        wp_style_add_data( self::HANDLE, 'rtl', 'replace' );
349        wp_add_inline_style( self::HANDLE, self::get_layout_css() );
350
351        wp_add_inline_script(
352            self::HANDLE,
353            'window.JetpackEmailDesignEditor = ' . wp_json_encode( self::get_screen_data(), self::JSON_FLAGS ) . ';',
354            'before'
355        );
356    }
357
358    /**
359     * Fill the screen with the editor.
360     *
361     * The editor's frame expects a viewport, not the flow of an admin page: inside the usual
362     * wp-admin content column it collapses to a fraction of the height and scrolls its own
363     * regions. Woo avoids this by running on `post.php`, which is already fullscreen.
364     *
365     * @return string
366     */
367    private static function get_layout_css() {
368        return '
369            #wpcontent { padding-inline-start: 0; }
370            #wpfooter { display: none; }
371            #' . self::HANDLE . ' {
372                position: fixed;
373                /* Below #wpadminbar (99999) so the editor\'s own popovers and snackbars, which ask
374                   for 100000, cannot cover it — this caps everything inside. The admin menu is
375                   hidden on this screen, so there is nothing else left to clear. */
376                z-index: 99990;
377                inset-block: var(--wp-admin--admin-bar--height, 32px) 0;
378                inset-inline: 0;
379            }
380            /* wp-edit-post pins this and the screen does not load it, so without this the
381               snackbar renders in flow beside the header instead of over the canvas. */
382            #' . self::HANDLE . ' .components-editor-notices__snackbar {
383                position: absolute;
384                inset-block-end: 20px;
385                inset-inline-start: 20px;
386            }
387            /* Above the editor\'s own popovers and snackbars, which ask for 100000 inside this
388               container, so nothing the editor renders shows through the reset. */
389            #' . self::HANDLE . ' .jetpack-email-design-reset-overlay {
390                position: absolute;
391                inset: 0;
392                z-index: 100001;
393                display: flex;
394                align-items: center;
395                justify-content: center;
396                background-color: rgba(255, 255, 255, 0.82);
397            }
398            /* The screen is styles-only: the root block editing mode is `disabled`, so nothing is
399               insertable and core filters disabled blocks out of List View. Drop when blocks land. NL-1086. */
400            #' . self::HANDLE . ' .editor-document-tools__inserter-toggle,
401            #' . self::HANDLE . ' .editor-document-tools__document-overview-toggle {
402                display: none;
403            }
404        ';
405    }
406
407    /**
408     * Reproduce what the package's `Assets_Manager` does on WooCommerce's own screen.
409     *
410     * None of this happens automatically on a custom admin page: without it the editor
411     * mounts against no block library, no block categories and no server-side block
412     * definitions. See NL-839 (a).
413     *
414     * @todo Firing `enqueue_block_editor_assets` wholesale is the leading suspect for the
415     *       second Styles button — it pulls in core's site-editing global styles UI. NL-839 (e).
416     */
417    private static function enqueue_block_editor_assets() {
418        // Named rather than built from a post: there is no post here, and `get_block_categories()`
419        // hands whatever it gets to filters that type-hint the context.
420        $context = new WP_Block_Editor_Context( array( 'name' => 'jetpack/email-design' ) );
421
422        wp_enqueue_media();
423
424        do_action( 'enqueue_block_assets' );
425        do_action( 'enqueue_block_editor_assets' );
426
427        wp_enqueue_style( 'wp-edit-blocks' );
428        wp_enqueue_style( 'wp-format-library' );
429
430        wp_add_inline_script(
431            'wp-blocks',
432            sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( get_block_categories( $context ), self::JSON_FLAGS ) ),
433            'after'
434        );
435        wp_add_inline_script(
436            'wp-blocks',
437            sprintf(
438                'wp.blocks.unstable__bootstrapServerSideBlockDefinitions( %s );',
439                wp_json_encode( get_block_editor_server_block_settings(), self::JSON_FLAGS )
440            ),
441            'after'
442        );
443    }
444
445    /**
446     * What the page hands the bundle, as `window.JetpackEmailDesignEditor`.
447     *
448     * Every WordPress.com id — the template's, the global-styles record's — comes from the
449     * bootstrap response instead, because they are namespaced to the shadow blog's theme and
450     * a locally computed one is right on Simple and wrong everywhere else. See NL-839 (c).
451     *
452     * @return array
453     */
454    private static function get_screen_data() {
455        $exit_url = self::exit_url();
456
457        return array(
458            'elementId'      => self::HANDLE,
459            'editorSettings' => self::get_iframe_asset_settings(),
460
461            // The editor assigns these to `window.location.href` from its header buttons. Both
462            // leave the editor, and there is one design rather than a list, so both go to the
463            // same place.
464            'urls'           => array(
465                'back'     => $exit_url,
466                'listings' => $exit_url,
467            ),
468            'userEmail'      => wp_get_current_user()->user_email,
469
470            // The post the canvas should preview, where the previewer sent one. The design itself
471            // stays site-wide. {@see self::preview_post_id()}
472            'postId'         => self::preview_post_id(),
473
474            // What the bundle needs to record its own events. {@see self::get_tracks_data()}
475            'tracks'         => self::get_tracks_data(),
476        );
477    }
478
479    /**
480     * Enqueue the Tracks sender, where this site may send at all.
481     *
482     * `Jetpack_Gutenberg` enqueues `jp-tracks` for the block editor and then dequeues it whenever
483     * `wp_should_load_block_editor_scripts_and_styles()` is false, which this screen is -- it is an
484     * Appearance page, so `is_block_editor()` answers no. Without this the queue the analytics
485     * package pushes onto is never drained and every event is silently lost.
486     *
487     * Simple already has it: `w.js` arrives with the masterbar there.
488     */
489    private static function enqueue_tracks_transport() {
490        if ( ! self::can_send_tracks() || ( new Host() )->is_wpcom_simple() ) {
491            return;
492        }
493
494        wp_enqueue_script( 'jp-tracks', '//stats.wp.com/w.js', array(), gmdate( 'YW' ), true );
495    }
496
497    /**
498     * Whether this site and this creator may be recorded.
499     *
500     * @return bool
501     */
502    private static function can_send_tracks() {
503        // The gate below asks about the terms of service and the user's connection, which are both
504        // things a Simple site has none of, so it answers no there for a site that records
505        // everything else it does. WordPress.com loads the sender and identifies the user itself.
506        if ( ( new Host() )->is_wpcom_simple() ) {
507            return true;
508        }
509
510        $tracking = new Tracking( 'jetpack', new Connection_Manager( 'jetpack' ) );
511
512        return $tracking->should_enable_tracking( new Terms_Of_Service(), new Status() );
513    }
514
515    /**
516     * The identity the bundle records against, or the refusal.
517     *
518     * Narrowed to the two fields the analytics package takes, so the email address and locale that
519     * `get_connected_user_tracks_identity()` also returns stay out of the page's HTML. A site can
520     * be allowed to send with no connected user, which records anonymously, so `canSend` is asked
521     * separately rather than inferred from having an identity.
522     *
523     * @return array
524     */
525    private static function get_tracks_data() {
526        if ( ! self::can_send_tracks() ) {
527            return array(
528                'canSend'  => false,
529                'userData' => null,
530                'blogId'   => null,
531            );
532        }
533
534        return array(
535            'canSend'  => true,
536            'userData' => self::tracks_identity(),
537            'blogId'   => self::tracks_blog_id(),
538        );
539    }
540
541    /**
542     * Who the bundle records against, or nobody.
543     *
544     * Narrowed to the two fields the analytics package takes, so the email address and locale that
545     * `get_connected_user_tracks_identity()` also returns stay out of the page's HTML.
546     *
547     * Nobody on Simple: WordPress.com has already identified the logged-in user to the sender it
548     * loaded, and ours would replace that with an identity derived from a connection Simple does
549     * not have. Elsewhere a site can still record with no connected user, anonymously, which is
550     * why `canSend` is asked separately from this.
551     *
552     * @return array|null
553     */
554    private static function tracks_identity() {
555        if ( ( new Host() )->is_wpcom_simple() ) {
556            return null;
557        }
558
559        $identity = Jetpack_Tracks_Client::get_connected_user_tracks_identity();
560
561        if ( ! $identity ) {
562            return null;
563        }
564
565        return array(
566            'userid'   => $identity['userid'],
567            'username' => $identity['username'],
568        );
569    }
570
571    /**
572     * The WordPress.com blog the events name.
573     *
574     * Read the way the connected user's identity reads it, so both name one blog -- except on
575     * Simple, where the blog is a WordPress.com blog already and the Jetpack option that names it
576     * everywhere else is not what identifies it.
577     *
578     * @return int
579     */
580    private static function tracks_blog_id() {
581        if ( ( new Host() )->is_wpcom_simple() ) {
582            return get_current_blog_id();
583        }
584
585        return (int) Jetpack_Options::get_option( 'id', 0 );
586    }
587
588    /**
589     * The post the canvas should preview, or null to leave the default.
590     *
591     * The previewer's link carries the post the reader was looking at, so changing the design does
592     * not replace it with the sample post. See NL-1051.
593     *
594     * Capability-checked here as well as on WordPress.com, so a hand-edited URL cannot name a post
595     * this user may not read. `absint()` is as forgiving as `exit_url()`'s validation -- `12abc`
596     * arrives as 12 -- which only ever yields an id shape `edit_post` then has the final word on.
597     *
598     * @return int|null
599     */
600    private static function preview_post_id() {
601        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read as a preview target only, and capability-checked below.
602        $post_id = isset( $_GET['post_id'] ) ? absint( wp_unslash( $_GET['post_id'] ) ) : 0;
603
604        if ( $post_id > 0 && current_user_can( 'edit_post', $post_id ) ) {
605            return $post_id;
606        }
607
608        return null;
609    }
610
611    /**
612     * Where the editor's header buttons leave to.
613     *
614     * The `return` arg, where the surface that linked here sent one, so the editor goes back the
615     * way the reader came. Validated like core's Customizer validates its own
616     * (`wp-admin/customize.php`), and narrowed to wp-admin so the button cannot be aimed off it.
617     *
618     * Without a usable one -- Appearance, a bookmark, a reload, or the previewer's new tab, which
619     * sends none on purpose -- the dashboard, as the site editor does from the same menu
620     * (`__experimentalDashboardLink`).
621     *
622     * @return string
623     */
624    private static function exit_url() {
625        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read as a navigation target only, and validated below.
626        $return = isset( $_GET['return'] ) ? wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['return'] ) ), '' ) : '';
627
628        if ( '' !== $return && 0 === strpos( $return, admin_url() ) ) {
629            return $return;
630        }
631
632        return admin_url( '/' );
633    }
634
635    /**
636     * The two editor settings that describe this installation rather than the design.
637     *
638     * WordPress.com strips both from the bootstrap bundle, because there they would name
639     * WordPress.com's own asset URLs and push them into the site's canvas.
640     *
641     * @return array
642     */
643    private static function get_iframe_asset_settings() {
644        // Absent before WP 6.3, and private, but it is what core's own block editors call to
645        // resolve the assets an iframed canvas needs.
646        if ( ! function_exists( '_wp_get_iframed_editor_assets' ) ) {
647            return array();
648        }
649
650        $handles = self::get_allowed_iframe_style_handles();
651
652        return array(
653            '__unstableResolvedAssets'  => self::get_resolved_assets( $handles ),
654            'allowedIframeStyleHandles' => $handles,
655        );
656    }
657
658    /**
659     * The stylesheet handles the canvas is allowed to keep.
660     *
661     * Mirrors the package's `Settings_Controller::get_allowed_iframe_style_handles()`. An empty
662     * list is not a no-op: the client strips every stylesheet not named here, so omitting this
663     * leaves the canvas painted in the site's own styles rather than the email's.
664     *
665     * @return string[]
666     */
667    private static function get_allowed_iframe_style_handles() {
668        $handles = array(
669            'wp-components-css',
670            'wp-reset-editor-styles-css',
671            'wp-block-library-css',
672            'wp-block-editor-content-css',
673            'wp-edit-blocks-css',
674        );
675
676        // Registration args reach the block type verbatim — `WP_Block_Type::set_props()` normalizes
677        // only `attributes`, and `register_block_type_args` can rewrite the rest — so a block
678        // declaring a bare string here would otherwise fatal the screen inside `array_merge()`.
679        foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $block ) {
680            if ( ! is_array( $block->supports ) || empty( $block->supports['email'] ) ) {
681                continue;
682            }
683
684            foreach ( array_merge( (array) $block->style_handles, (array) $block->editor_style_handles ) as $handle ) {
685                if ( is_string( $handle ) ) {
686                    $handles[] = $handle . '-css';
687                }
688            }
689        }
690
691        return $handles;
692    }
693
694    /**
695     * The iframe assets, trimmed to the allowed handles.
696     *
697     * @param string[] $allowed Handles to keep.
698     * @return array The `_wp_get_iframed_editor_assets()` shape, with `styles` filtered.
699     */
700    private static function get_resolved_assets( array $allowed ) {
701        // Core collects the canvas's assets by buffering `wp_print_footer_scripts()`, one of the two
702        // actions `wp_auth_check_load()` puts its "Session expired" dialog on. Core's own editor is
703        // built before `admin_enqueue_scripts` registers that; this screen is not. NL-957.
704        $had_auth_check = remove_action( 'wp_print_footer_scripts', 'wp_auth_check_html', 5 );
705
706        $assets = _wp_get_iframed_editor_assets();
707
708        if ( $had_auth_check ) {
709            add_action( 'wp_print_footer_scripts', 'wp_auth_check_html', 5 );
710        }
711
712        $kept = array();
713
714        foreach ( explode( "\n", (string) $assets['styles'] ) as $asset ) {
715            foreach ( $allowed as $handle ) {
716                if ( str_contains( $asset, $handle ) ) {
717                    $kept[] = $asset;
718                    break;
719                }
720            }
721        }
722
723        $assets['styles'] = implode( "\n", $kept );
724
725        return $assets;
726    }
727
728    /**
729     * Render the container the editor mounts into.
730     */
731    public static function render() {
732        printf( '<div id="%s" class="jetpack-email-design-editor"></div>', esc_attr( self::HANDLE ) );
733    }
734}
735
736Jetpack_Email_Design_Editor::init();