From 763e69ccb9017fbe4dd91de0f23a798dac069cae Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Wed, 21 Oct 2026 00:32:31 +0800 Subject: [PATCH 1/9] feat(editor-integration): preview the real beehiiv email of a saved post in the editor Adds a Preview email button and modal (desktop and mobile views, from, subject and preview text, sandboxed inert frame), a REST route that syncs a linked beehiiv draft from the saved post and fetches the rendered email, and the draft lifecycle it needs: created on first preview, updated in place, recreated when the publication or template changes, kept across status changes, removed when Send to newsletter is turned off or the post is deleted. --- includes/API/Resources/Posts.php | 88 ++ includes/Editor/Meta.php | 9 + includes/Editor/PostSettings.php | 6 + includes/Newsletter/EmailPreview.php | 275 ++++ includes/Newsletter/PostSettingsBuilder.php | 71 ++ includes/Newsletter/Sender.php | 218 +++- includes/Plugin.php | 1 + includes/REST/EmailPreviewController.php | 99 ++ .../post-settings/components/email-preview.js | 255 ++++ .../post-settings/css/_email-preview.scss | 107 ++ src/js/editor/post-settings/editor.scss | 1 + .../hooks/use-beehiiv-post-meta.js | 10 +- src/js/editor/post-settings/index.js | 6 + src/js/shared/meta.js | 1 + tests/e2e/specs/email-preview.spec.js | 1106 +++++++++++++++++ tests/phpunit/NewsletterEmailPreviewTest.php | 465 +++++++ tests/phpunit/NewsletterPreviewDraftTest.php | 361 ++++++ 17 files changed, 3076 insertions(+), 3 deletions(-) create mode 100644 includes/Newsletter/EmailPreview.php create mode 100644 includes/REST/EmailPreviewController.php create mode 100644 src/js/editor/post-settings/components/email-preview.js create mode 100644 src/js/editor/post-settings/css/_email-preview.scss create mode 100644 tests/e2e/specs/email-preview.spec.js create mode 100644 tests/phpunit/NewsletterEmailPreviewTest.php create mode 100644 tests/phpunit/NewsletterPreviewDraftTest.php diff --git a/includes/API/Resources/Posts.php b/includes/API/Resources/Posts.php index 4b0aa92..e992bba 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -274,6 +274,77 @@ public static function get_creation_state( string $publication_id, string $post_ return $result; } + /** + * Read a post together with its rendered free email content. + * + * @link https://developers.beehiiv.com/api-reference/posts/show + * + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @return array{ + * success: bool, + * email_html: string, + * subject: string, + * preview_text: string, + * error: string, + * status_code: int|null + * } + * @since 1.0.0 + */ + public static function get_email_content( string $publication_id, string $post_id ): array { + $publication_id = trim( $publication_id ); + $post_id = trim( $post_id ); + $result = [ + 'success' => false, + 'email_html' => '', + 'subject' => '', + 'preview_text' => '', + 'error' => '', + 'status_code' => null, + ]; + + if ( '' === $publication_id || '' === $post_id ) { + $result['error'] = 'Publication ID or post ID is empty.'; + return $result; + } + + $path = sprintf( + '/publications/%s/posts/%s', + rawurlencode( $publication_id ), + rawurlencode( $post_id ) + ); + $response = Client::request( $path, [ 'expand' => 'free_email_content' ] ); + + $wp_error = Client::get_last_wp_error(); + + if ( null !== $wp_error ) { + $result['error'] = $wp_error; + return $result; + } + + $status_code = Client::get_last_status_code(); + $result['status_code'] = $status_code; + + if ( null !== $status_code && $status_code > 299 ) { + $result['error'] = self::format_error_message( $response, $status_code ); + return $result; + } + + $data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : []; + $html = $data['content']['free']['email'] ?? ''; + $subject = self::first_non_empty( [ $data['email_settings']['email_subject_line'] ?? '' ] ); + $preview = self::first_non_empty( + [ $data['email_settings']['email_preview_text'] ?? '', $data['subtitle'] ?? '' ] + ); + + $result['success'] = true; + $result['email_html'] = is_string( $html ) ? $html : ''; + $result['subject'] = $subject; + $result['preview_text'] = $preview; + + return $result; + } + /** * Send a test email of a beehiiv post to one or more addresses. * @@ -398,6 +469,23 @@ public static function update( string $publication_id, string $post_id, array $p ]; } + /** + * First value that is a non-empty string, or an empty string. + * + * @param array $values Candidate values in priority order. + * @return string + * @since 1.0.0 + */ + private static function first_non_empty( array $values ): string { + foreach ( $values as $value ) { + if ( is_string( $value ) && '' !== trim( $value ) ) { + return $value; + } + } + + return ''; + } + /** * Build a log-friendly error string from an API error response. * diff --git a/includes/Editor/Meta.php b/includes/Editor/Meta.php index 8dfc86a..a1314b4 100644 --- a/includes/Editor/Meta.php +++ b/includes/Editor/Meta.php @@ -132,4 +132,13 @@ final class Meta { * @since x.x.x */ public const NEWSLETTER_SHOW_TITLE_IN_EMAIL = '_beehiiv_newsletter_show_title_in_email'; + + /** + * Whether the linked beehiiv post is a `draft` (kept for Preview) or `confirmed`. + * + * Empty means the link was made by a normal send and counts as confirmed. + * + * @since x.x.x + */ + public const BEEHIIV_POST_STATE = '_beehiiv_post_state'; } diff --git a/includes/Editor/PostSettings.php b/includes/Editor/PostSettings.php index bc09e81..309c4ab 100644 --- a/includes/Editor/PostSettings.php +++ b/includes/Editor/PostSettings.php @@ -81,6 +81,11 @@ final class PostSettings { 'default' => '', 'readonly' => true, ], + Meta::BEEHIIV_POST_STATE => [ + 'type' => 'string', + 'default' => '', + 'readonly' => true, + ], Meta::NEWSLETTER_ERROR => [ 'type' => 'string', 'default' => '', @@ -359,6 +364,7 @@ public static function prepare_rest_post( $response, $post, $request ) { Meta::BEEHIIV_POST_ID, Meta::BEEHIIV_SCHEDULED_AT, Meta::BEEHIIV_PUBLICATION_ID, + Meta::BEEHIIV_POST_STATE, Meta::SEND_TO_NEWSLETTER, Meta::NEWSLETTER_ERROR, Meta::NEWSLETTER_ERROR_TYPE, diff --git a/includes/Newsletter/EmailPreview.php b/includes/Newsletter/EmailPreview.php new file mode 100644 index 0000000..bb892d0 --- /dev/null +++ b/includes/Newsletter/EmailPreview.php @@ -0,0 +1,275 @@ + $result['email_html'], + 'subject' => self::resolve_subject( $post_id, $result['subject'] ), + 'preview_text' => self::resolve_preview_text( $post_id, $result['preview_text'] ), + 'from_name' => PublicationResolver::get_publication_name( $publication_id ), + ]; + } + + if ( $attempt < self::MAX_ATTEMPTS ) { + $interval = (int) apply_filters( 'beehiiv_email_preview_poll_interval', self::POLL_INTERVAL ); + sleep( $interval ); // phpcs:ignore WordPress.WP.AlternativeFunctions.sleep_sleep + } + } + + if ( null !== $last_error ) { + return self::classify_api_error( (string) $last_error['error'], $last_error['status_code'] ); + } + + return new WP_Error( + 'beehiiv_preview_processing', + __( 'beehiiv is still preparing this email. Try again in a moment.', 'beehiiv' ) + ); + } + + /** + * Subject line: what beehiiv holds, else the post's newsletter title, else the post title. + * + * @param int $post_id Post ID. + * @param string $fetched Subject returned by beehiiv. + * @return string + * @since 1.0.0 + */ + private static function resolve_subject( int $post_id, string $fetched ): string { + if ( '' !== trim( $fetched ) ) { + return $fetched; + } + + $newsletter_title = get_post_meta( $post_id, Meta::NEWSLETTER_TITLE, true ); + + if ( is_string( $newsletter_title ) && '' !== trim( $newsletter_title ) ) { + return html_entity_decode( trim( $newsletter_title ) ); + } + + return html_entity_decode( get_the_title( $post_id ) ); + } + + /** + * Preview text: what beehiiv holds, else the post's newsletter subtitle, else nothing. + * + * @param int $post_id Post ID. + * @param string $fetched Preview text returned by beehiiv. + * @return string + * @since 1.0.0 + */ + private static function resolve_preview_text( int $post_id, string $fetched ): string { + if ( '' !== trim( $fetched ) ) { + return $fetched; + } + + $subtitle = get_post_meta( $post_id, Meta::NEWSLETTER_SUBTITLE, true ); + + return is_string( $subtitle ) ? html_entity_decode( trim( $subtitle ) ) : ''; + } + + /** + * Whether the saved post can be previewed right now. + * + * @param int $post_id Post ID. + * @return true|WP_Error + * @since 1.0.0 + */ + public static function check_eligibility( int $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof WP_Post || 'post' !== $post->post_type ) { + return new WP_Error( + 'beehiiv_post_not_found', + __( 'This post no longer exists. Save or reload the editor and try again.', 'beehiiv' ) + ); + } + + if ( Sender::has_beehiiv_post_id( $post_id ) && ! Sender::is_draft_linked( $post_id ) ) { + return new WP_Error( + 'beehiiv_newsletter_confirmed', + __( 'This newsletter is already scheduled or sent, so it can no longer be previewed.', 'beehiiv' ) + ); + } + + if ( ! rest_sanitize_boolean( get_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ) ) + && ! Sender::is_draft_linked( $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_not_opted_in', + __( 'Turn on "Send to newsletter" to preview this email.', 'beehiiv' ) + ); + } + + if ( ! Manager::is_connected() ) { + return new WP_Error( + 'beehiiv_not_connected', + __( 'Connect your beehiiv account in beehiiv settings to preview.', 'beehiiv' ) + ); + } + + if ( '' === PublicationResolver::resolve_for_post( $post_id ) ) { + return new WP_Error( + 'beehiiv_missing_publication', + __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ) + ); + } + + $publication_id = PublicationResolver::resolve_for_post( $post_id ); + + if ( '' === PostSettingsBuilder::resolve_post_template_id( $post_id, $publication_id ) ) { + return new WP_Error( + 'beehiiv_missing_post_template', + __( 'Choose a default post template in beehiiv settings to preview.', 'beehiiv' ) + ); + } + + if ( ! Workspace::can_write_posts() ) { + return new WP_Error( + 'beehiiv_preview_plan', + __( 'Email preview needs beehiiv Send API access (Max and Enterprise plans).', 'beehiiv' ) + ); + } + + return true; + } + + /** + * Turn a draft sync failure into a classified preview error. + * + * @param WP_Error $error Error from {@see Sender::sync_preview_draft()}. + * @return WP_Error + * @since 1.0.0 + */ + private static function classify_sync_error( WP_Error $error ): WP_Error { + $code = $error->get_error_code(); + + if ( in_array( $code, [ 'beehiiv_draft_create_failed', 'beehiiv_draft_update_failed' ], true ) ) { + return self::classify_api_error( $error->get_error_message(), null ); + } + + if ( 0 === strpos( $code, 'beehiiv_post_' ) || 0 === strpos( $code, 'beehiiv_blocks_' ) + || 0 === strpos( $code, 'beehiiv_advertisement_' ) ) { + return new WP_Error( 'beehiiv_preview_content', Sender::format_save_error_message( $error ) ); + } + + return $error; + } + + /** + * Classify a beehiiv API failure. + * + * @param string $error Error string from the API client. + * @param int|null $status_code HTTP status code when known. + * @return WP_Error + * @since 1.0.0 + */ + private static function classify_api_error( string $error, ?int $status_code ): WP_Error { + if ( null === $status_code && preg_match( '/^HTTP (\d+):/i', trim( $error ), $matches ) ) { + $status_code = (int) $matches[1]; + } + + if ( 401 === $status_code || 403 === $status_code ) { + return new WP_Error( + 'beehiiv_preview_connection', + __( + // phpcs:ignore Generic.Files.LineLength.MaxExceeded,Generic.Files.LineLength.TooLong -- Single string for translators / i18n tools. + 'beehiiv refused the request. Reconnect your account in beehiiv settings and make sure it has access to read posts.', + 'beehiiv' + ) + ); + } + + if ( 402 === $status_code ) { + return new WP_Error( + 'beehiiv_preview_plan', + __( 'Email preview needs beehiiv Send API access (Max and Enterprise plans).', 'beehiiv' ) + ); + } + + if ( preg_match( '/^HTTP (\d+):\s*(.+)$/is', trim( $error ), $matches ) && 429 !== (int) $matches[1] ) { + return new WP_Error( + 'beehiiv_preview_failed', + sprintf( + /* translators: %s: error message from beehiiv. */ + __( "beehiiv couldn't prepare the preview: %s", 'beehiiv' ), + trim( $matches[2] ) + ) + ); + } + + $mapped = Sender::format_api_error_message( $error ); + + return new WP_Error( + 'beehiiv_preview_failed', + '' !== $mapped ? $mapped : __( "Something went wrong and the preview couldn't load. Try again.", 'beehiiv' ) + ); + } +} diff --git a/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php index df74b14..cfdd3ab 100644 --- a/includes/Newsletter/PostSettingsBuilder.php +++ b/includes/Newsletter/PostSettingsBuilder.php @@ -166,6 +166,77 @@ public static function get_post_settings( int $post_id, bool $for_update = false return apply_filters( 'beehiiv_newsletter_post_settings', $settings, $post_id, $post_object ); } + /** + * Build the create payload for a Preview draft from the saved post. + * + * Uses the real send's payload builder without scheduling, then forces draft status + * after the public settings filter so a draft can never become a real send. + * + * @param int $post_id Post ID. + * @param string|null $publication_id Publication the draft is for. Defaults to the post's resolved publication. + * @return array|WP_Error + * @since x.x.x + */ + public static function build_draft_payload( int $post_id, ?string $publication_id = null ) { + $settings = self::get_post_settings( $post_id, true, $publication_id ); + + if ( is_wp_error( $settings ) ) { + return $settings; + } + + if ( ! is_array( $settings ) ) { + return new WP_Error( + 'beehiiv_draft_invalid_settings', + __( 'Something went wrong preparing the email draft. Try again.', 'beehiiv' ) + ); + } + + $settings['status'] = 'draft'; + unset( $settings['scheduled_at'] ); + + return $settings; + } + + /** + * Build the update payload for a linked Preview draft. + * + * Carries the content fields only; never a status, send time or template, so the update + * cannot confirm the draft. + * + * @param int $post_id Post ID. + * @param string|null $publication_id Publication the draft lives in. + * @return array|WP_Error + * @since x.x.x + */ + public static function build_draft_update_payload( int $post_id, ?string $publication_id = null ) { + $settings = self::build_draft_payload( $post_id, $publication_id ); + + if ( is_wp_error( $settings ) ) { + return $settings; + } + + $payload = array_intersect_key( + $settings, + array_flip( + [ + 'title', + 'blocks', + 'thumbnail_image_url', + 'email_settings', + 'web_settings', + 'social_share', + 'content_tags', + 'seo_settings', + ] + ) + ); + + // A subtitle cleared in WordPress is removed from the draft by sending it empty. + $payload['subtitle'] = $settings['subtitle'] ?? ''; + + return $payload; + } + /** * Build a beehiiv update request for a linked newsletter. * diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index accbe8a..57a71ca 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -125,7 +125,9 @@ public static function guard_locked_newsletter_meta( $check, $post_id, $meta_key * @since x.x.x */ public static function is_newsletter_published( int $post_id ): bool { - return self::has_beehiiv_post_id( $post_id ) && self::send_time_has_passed( $post_id ); + return self::has_beehiiv_post_id( $post_id ) + && ! self::is_draft_linked( $post_id ) + && self::send_time_has_passed( $post_id ); } /** @@ -380,10 +382,207 @@ public static function cancel_scheduled_newsletter( int $post_id ): void { self::clear_linked_publication( $post_id ); delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT ); delete_post_meta( $post_id, Meta::BEEHIIV_SYNCED_SUBTITLE ); + delete_post_meta( $post_id, Meta::BEEHIIV_POST_STATE ); update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ); self::clear_error( $post_id ); } + /** + * Create or update the beehiiv draft that Preview shows, from the saved post. + * + * The draft is linked to the post in a distinct draft state, built with the same + * payload builder as a real send, and never confirmed here. A draft whose publication + * or template no longer matches the post's choice is recreated, because the update API + * cannot move a post or change its template. Failures are returned, never stored as + * the post's newsletter error, and the Send toggle is left as is. + * + * @param int $post_id Post ID. + * @return array{beehiiv_post_id: string, created: bool}|WP_Error + * @since x.x.x + */ + public static function sync_preview_draft( int $post_id ) { + $post = get_post( $post_id ); + + if ( ! $post instanceof WP_Post || self::POST_TYPE !== $post->post_type ) { + return new WP_Error( + 'beehiiv_post_not_found', + __( 'This post no longer exists. Save or reload the editor and try again.', 'beehiiv' ) + ); + } + + if ( ! current_user_can( 'publish_posts', $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_forbidden', + __( "You don't have permission to preview this newsletter.", 'beehiiv' ) + ); + } + + if ( ! rest_sanitize_boolean( get_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ) ) + && ! self::has_beehiiv_post_id( $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_not_opted_in', + __( 'Turn on "Send to newsletter" to preview this email.', 'beehiiv' ) + ); + } + + if ( ! Manager::is_connected() ) { + return new WP_Error( 'beehiiv_not_connected', self::not_connected_message() ); + } + + if ( self::has_beehiiv_post_id( $post_id ) && ! self::is_draft_linked( $post_id ) ) { + return new WP_Error( + 'beehiiv_newsletter_confirmed', + __( + 'This newsletter is already scheduled or sent, so it can no longer be previewed from here.', + 'beehiiv' + ) + ); + } + + $target = PublicationResolver::resolve_target( $post_id ); + $publication_id = $target['publication_id']; + + if ( '' === $publication_id ) { + return new WP_Error( 'beehiiv_no_publication', self::no_post_publication_message() ); + } + + if ( self::is_draft_linked( $post_id ) ) { + $linked_publication_id = PublicationResolver::get_linked_publication_id( $post_id ); + + if ( self::draft_needs_recreate( $post_id, $linked_publication_id, $publication_id ) ) { + if ( ! self::delete_linked_draft( $post_id ) ) { + return new WP_Error( + 'beehiiv_draft_update_failed', + 'The previous draft could not be replaced in beehiiv.' + ); + } + } else { + $beehiiv_post_id = self::get_beehiiv_post_id( $post_id ); + $payload = PostSettingsBuilder::build_draft_update_payload( $post_id, $linked_publication_id ); + + if ( is_wp_error( $payload ) ) { + return $payload; + } + + $result = Posts::update( $linked_publication_id, $beehiiv_post_id, $payload ); + + if ( ! $result['success'] ) { + return new WP_Error( 'beehiiv_draft_update_failed', $result['error'] ); + } + + return [ + 'beehiiv_post_id' => $beehiiv_post_id, + 'created' => false, + ]; + } + } + + $payload = PostSettingsBuilder::build_draft_payload( $post_id, $publication_id ); + + if ( is_wp_error( $payload ) ) { + return $payload; + } + + $result = Posts::create( $publication_id, $payload ); + + if ( ! $result['success'] ) { + return new WP_Error( 'beehiiv_draft_create_failed', $result['error'] ); + } + + self::record_linked_publication( $post_id, $publication_id, (string) ( $payload['post_template_id'] ?? '' ) ); + update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] ); + update_post_meta( $post_id, Meta::BEEHIIV_POST_STATE, 'draft' ); + + return [ + 'beehiiv_post_id' => $result['post_id'], + 'created' => true, + ]; + } + + /** + * Whether the post's linked beehiiv post is a Preview draft. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + public static function is_draft_linked( int $post_id ): bool { + return self::has_beehiiv_post_id( $post_id ) + && 'draft' === get_post_meta( $post_id, Meta::BEEHIIV_POST_STATE, true ); + } + + /** + * Whether a linked draft must be recreated to match the post's publication or template. + * + * @param int $post_id Post ID. + * @param string $linked_publication_id Publication the draft lives in. + * @param string $target_publication_id Publication the post now targets. + * @return bool + * @since x.x.x + */ + private static function draft_needs_recreate( + int $post_id, + string $linked_publication_id, + string $target_publication_id + ): bool { + if ( $linked_publication_id !== $target_publication_id ) { + return true; + } + + $linked_template_id = get_post_meta( $post_id, Meta::BEEHIIV_LINKED_POST_TEMPLATE_ID, true ); + $linked_template_id = is_string( $linked_template_id ) ? trim( $linked_template_id ) : ''; + $chosen_template_id = PostSettingsBuilder::resolve_post_template_id( $post_id, $target_publication_id ); + + return '' !== $linked_template_id && '' !== $chosen_template_id && $linked_template_id !== $chosen_template_id; + } + + /** + * Delete the linked Preview draft in beehiiv and clear the link. + * + * Never records a newsletter error; a failed delete leaves the link in place. + * + * @param int $post_id Post ID. + * @return bool Whether the link was cleared. + * @since x.x.x + */ + private static function delete_linked_draft( int $post_id ): bool { + if ( ! self::is_draft_linked( $post_id ) ) { + return false; + } + + $publication_id = PublicationResolver::get_linked_publication_id( $post_id ); + + if ( Manager::is_connected() && '' !== $publication_id ) { + $result = Posts::delete( $publication_id, self::get_beehiiv_post_id( $post_id ) ); + + if ( ! $result['success'] ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'beehiiv draft delete failed for post ID %d: %s', $post_id, $result['error'] ) ); + return false; + } + } + + delete_post_meta( $post_id, Meta::BEEHIIV_POST_ID ); + delete_post_meta( $post_id, Meta::BEEHIIV_POST_STATE ); + delete_post_meta( $post_id, Meta::BEEHIIV_SCHEDULED_AT ); + self::clear_linked_publication( $post_id ); + + return true; + } + + /** + * Linked beehiiv post ID, or an empty string. + * + * @param int $post_id Post ID. + * @return string + * @since x.x.x + */ + private static function get_beehiiv_post_id( int $post_id ): string { + $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + + return is_string( $beehiiv_post_id ) ? trim( $beehiiv_post_id ) : ''; + } + /** * Create or schedule a beehiiv newsletter when post meta and status allow. * @@ -400,6 +599,15 @@ public static function maybe_send_post_newsletter( WP_Post $post, ?WP_REST_Reque return; } + if ( self::is_draft_linked( $post->ID ) ) { + // Turning the toggle off removes the draft; saves never otherwise touch it. + if ( ! self::is_send_to_newsletter_enabled( $post->ID, $request ) ) { + self::delete_linked_draft( $post->ID ); + } + + return; + } + if ( self::has_beehiiv_post_id( $post->ID ) ) { self::maybe_update_post_newsletter( $post ); return; @@ -559,6 +767,10 @@ public static function send( int $post_id ): void { * @since 1.0.0 */ public static function update( int $post_id ): void { + if ( self::is_draft_linked( $post_id ) ) { + return; + } + self::clear_error( $post_id ); if ( ! Manager::is_connected() ) { @@ -1309,7 +1521,9 @@ private static function is_send_to_newsletter_enabled( int $post_id, ?WP_REST_Re * @since 1.0.0 */ private static function should_cancel_newsletter_for_status_change( string $new_status, WP_Post $post ): bool { - return self::has_beehiiv_post_id( $post->ID ) && self::is_non_public_status( $new_status ); + return self::has_beehiiv_post_id( $post->ID ) + && ! self::is_draft_linked( $post->ID ) + && self::is_non_public_status( $new_status ); } /** diff --git a/includes/Plugin.php b/includes/Plugin.php index c15a98f..2b44776 100644 --- a/includes/Plugin.php +++ b/includes/Plugin.php @@ -47,6 +47,7 @@ public static function init(): void { add_action( 'rest_api_init', [ REST\PublicationsController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\AdvertisementOpportunitiesController::class, 'register_routes' ] ); add_action( 'rest_api_init', [ REST\TestSendController::class, 'register_routes' ] ); + add_action( 'rest_api_init', [ REST\EmailPreviewController::class, 'register_routes' ] ); } /** diff --git a/includes/REST/EmailPreviewController.php b/includes/REST/EmailPreviewController.php new file mode 100644 index 0000000..4464794 --- /dev/null +++ b/includes/REST/EmailPreviewController.php @@ -0,0 +1,99 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ self::class, 'preview' ], + 'permission_callback' => [ self::class, 'permissions_check' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + ], + ] + ); + } + + /** + * Requires publish rights and edit access to the referenced post. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request REST request. + * @return bool + */ + public static function permissions_check( WP_REST_Request $request ): bool { + + $post_id = absint( $request->get_param( 'post_id' ) ); + + return $post_id + && current_user_can( 'edit_post', $post_id ) + && current_user_can( 'publish_posts' ); + } + + /** + * Sync the linked draft and return the rendered email HTML. + * + * @since 1.0.0 + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public static function preview( WP_REST_Request $request ): WP_REST_Response { + + $result = EmailPreview::render( absint( $request->get_param( 'post_id' ) ) ); + + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( + [ + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], + 400 + ); + } + + return new WP_REST_Response( $result, 200 ); + } +} diff --git a/src/js/editor/post-settings/components/email-preview.js b/src/js/editor/post-settings/components/email-preview.js new file mode 100644 index 0000000..f1b8f61 --- /dev/null +++ b/src/js/editor/post-settings/components/email-preview.js @@ -0,0 +1,255 @@ +/** + * Preview the real beehiiv email of the saved post in a WordPress modal. + */ +import { __ } from '@wordpress/i18n'; +import { useRef, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { Button, Modal, Spinner } from '@wordpress/components'; +import { update } from '@wordpress/icons'; +import apiFetch from '@wordpress/api-fetch'; + +import { useBeehiivEditorConfig } from '../hooks/use-beehiiv-editor-config'; +import renderSettingsLinkMessage from '../utils/render-settings-link-message'; + +/** + * Make the email view-only: links cannot be followed, even with the keyboard. + * Scripts and forms are already blocked by the empty `sandbox` attribute. + */ +const INERT_HEAD = + ''; + +/** + * @param {string} html Rendered email HTML from beehiiv. + * @return {string} HTML with links made inert. + */ +export function makeEmailInert( html ) { + return ( + html.replace( /]*)>/gi, ( match, attrs ) => { + const stripped = attrs.replace( + /\shref\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/i, + '' + ); + return ``; + } ) + INERT_HEAD + ); +} + +/** + * @param {Object} props + * @param {import('../hooks/use-beehiiv-post-meta').BeehiivPostMeta} props.beehiivMeta + */ +export default function EmailPreview( { beehiivMeta } ) { + const { settingsUrl } = useBeehiivEditorConfig(); + const [ isOpen, setIsOpen ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ email, setEmail ] = useState( null ); + const [ device, setDevice ] = useState( 'desktop' ); + const [ error, setError ] = useState( null ); + const latestRequest = useRef( 0 ); + + const { postId, isDirty, isNew, isSaving } = useSelect( ( select ) => { + const editor = select( editorStore ); + + return { + postId: editor.getCurrentPostId(), + isDirty: editor.isEditedPostDirty(), + isNew: editor.isEditedPostNew(), + isSaving: editor.isSavingPost(), + }; + }, [] ); + + if ( beehiivMeta.newsletterAlreadySent ) { + return ( +

+ { __( + "This newsletter is already scheduled or sent, so it can't be previewed.", + 'beehiiv' + ) } +

+ ); + } + + const needsSave = isNew || isDirty; + + const load = () => { + const requestId = ++latestRequest.current; + + setIsLoading( true ); + setError( null ); + setEmail( null ); + + apiFetch( { + path: '/beehiiv/v1/email-preview', + method: 'POST', + data: { post_id: postId }, + } ) + .then( ( data ) => { + if ( requestId === latestRequest.current ) { + setEmail( + data?.html + ? { + html: data.html, + subject: data.subject ?? '', + previewText: data.preview_text ?? '', + fromName: data.from_name ?? '', + } + : null + ); + } + } ) + .catch( ( apiError ) => { + if ( requestId === latestRequest.current ) { + setError( + apiError?.message || + __( + "Something went wrong and the preview couldn't load. Try again.", + 'beehiiv' + ) + ); + } + } ) + .finally( () => { + if ( requestId === latestRequest.current ) { + setIsLoading( false ); + } + } ); + }; + + const open = () => { + setIsOpen( true ); + load(); + }; + + const close = () => { + // Ignore any request still in flight; reopening runs a fresh sync. + latestRequest.current++; + setIsOpen( false ); + setIsLoading( false ); + setEmail( null ); + setError( null ); + }; + + return ( +
+ { needsSave && ( +

+ { __( + 'Save the post to preview your latest edits.', + 'beehiiv' + ) } +

+ ) } + + + + { isOpen && ( + + { isLoading && ( +

+ { ' ' } + { __( 'Preparing your email preview…', 'beehiiv' ) } +

+ ) } + + { error && ( +
+

+ { renderSettingsLinkMessage( + error, + settingsUrl + ) } +

+ +
+ ) } + + { email && ( +
+
+
+ + +
+
+ +
+ { device === 'desktop' && ( + + ) } +
+
{ __( 'From:', 'beehiiv' ) }
+
{ email.fromName }
+
{ __( 'Subject:', 'beehiiv' ) }
+
{ email.subject }
+
+ { __( 'Preview Text:', 'beehiiv' ) } +
+
{ email.previewText }
+
+