From b35eb8031744399e54b63a466c424308f9985ac6 Mon Sep 17 00:00:00 2001 From: Jeffrey Carandang Date: Mon, 9 Nov 2026 18:03:45 +0800 Subject: [PATCH 1/2] feat(editor-integration): preview the real beehiiv email in the post editor (#195) * 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. * fix(newsletter-publishing): import WP_Error in the sender * chore: revert unrelated whitespace change in the publication send test * fix(newsletter-publishing): replace a previewed draft with a real send on publish or schedule * fix(newsletter-publishing): lock simultaneous previews so only one draft is created * fix(editor-integration): poll for the preview email from the editor instead of waiting in PHP * refactor(editor-integration): harden the preview frame, share the post read, and fix the preview E2E expectations * refactor(editor-integration): share one preview eligibility check, purge stale locks, memoize the inert email, and cover the new paths * refactor(editor-integration): move preview eligibility into its own class and lengthen the preview lock --- includes/API/Resources/Posts.php | 101 +- includes/Editor/Meta.php | 9 + includes/Editor/PostSettings.php | 6 + includes/Newsletter/EmailPreview.php | 201 +++ includes/Newsletter/PostSettingsBuilder.php | 71 ++ includes/Newsletter/PreviewEligibility.php | 102 ++ includes/Newsletter/Sender.php | 323 ++++- includes/Plugin.php | 1 + includes/REST/EmailPreviewController.php | 109 ++ .../post-settings/components/email-preview.js | 290 +++++ .../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 | 1110 +++++++++++++++++ tests/phpunit/NewsletterEmailPreviewTest.php | 509 ++++++++ tests/phpunit/NewsletterPreviewDraftTest.php | 543 ++++++++ 18 files changed, 3479 insertions(+), 21 deletions(-) create mode 100644 includes/Newsletter/EmailPreview.php create mode 100644 includes/Newsletter/PreviewEligibility.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..5091f43 100644 --- a/includes/API/Resources/Posts.php +++ b/includes/API/Resources/Posts.php @@ -84,20 +84,22 @@ public static function create( string $publication_id, array $payload ): array { /** * Fetch a single post from a beehiiv publication. * - * @param string $publication_id Publication ID. - * @param string $post_id beehiiv post ID. - * @return array{success: bool, post: array, error: string} + * @param string $publication_id Publication ID. + * @param string $post_id beehiiv post ID. + * @param string|null $expand Optional `expand` value, for example `free_email_content`. + * @return array{success: bool, post: array, error: string, status_code: int|null} * @since x.x.x */ - public static function get( string $publication_id, string $post_id ): array { + public static function get( string $publication_id, string $post_id, ?string $expand = null ): array { $publication_id = trim( $publication_id ); $post_id = trim( $post_id ); if ( '' === $publication_id || '' === $post_id ) { return [ - 'success' => false, - 'post' => [], - 'error' => 'Publication ID or post ID is empty.', + 'success' => false, + 'post' => [], + 'error' => 'Publication ID or post ID is empty.', + 'status_code' => null, ]; } @@ -106,15 +108,16 @@ public static function get( string $publication_id, string $post_id ): array { rawurlencode( $publication_id ), rawurlencode( $post_id ) ); - $response = Client::request( $path ); + $response = Client::request( $path, null === $expand ? [] : [ 'expand' => $expand ] ); $wp_error = Client::get_last_wp_error(); if ( null !== $wp_error ) { return [ - 'success' => false, - 'post' => [], - 'error' => $wp_error, + 'success' => false, + 'post' => [], + 'error' => $wp_error, + 'status_code' => null, ]; } @@ -122,16 +125,18 @@ public static function get( string $publication_id, string $post_id ): array { if ( null !== $status_code && $status_code > 299 ) { return [ - 'success' => false, - 'post' => [], - 'error' => self::format_error_message( $response, $status_code ), + 'success' => false, + 'post' => [], + 'error' => self::format_error_message( $response, $status_code ), + 'status_code' => $status_code, ]; } return [ - 'success' => true, - 'post' => isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [], - 'error' => '', + 'success' => true, + 'post' => isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : [], + 'error' => '', + 'status_code' => $status_code, ]; } @@ -274,6 +279,51 @@ 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 x.x.x + */ + public static function get_email_content( string $publication_id, string $post_id ): array { + $fetched = self::get( $publication_id, $post_id, 'free_email_content' ); + $result = [ + 'success' => false, + 'email_html' => '', + 'subject' => '', + 'preview_text' => '', + 'error' => $fetched['error'], + 'status_code' => $fetched['status_code'], + ]; + + if ( ! $fetched['success'] ) { + return $result; + } + + $data = $fetched['post']; + $html = $data['content']['free']['email'] ?? ''; + + $result['success'] = true; + $result['email_html'] = is_string( $html ) ? $html : ''; + $result['subject'] = self::first_non_empty( [ $data['email_settings']['email_subject_line'] ?? '' ] ); + $result['preview_text'] = self::first_non_empty( + [ $data['email_settings']['email_preview_text'] ?? '', $data['subtitle'] ?? '' ] + ); + + return $result; + } + /** * Send a test email of a beehiiv post to one or more addresses. * @@ -398,6 +448,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 x.x.x + */ + 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..6bc1718 --- /dev/null +++ b/includes/Newsletter/EmailPreview.php @@ -0,0 +1,201 @@ + true ]; + } + + if ( ! Sender::is_draft_linked( $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_failed', + __( "Something went wrong and the preview couldn't load. Try again.", 'beehiiv' ) + ); + } + + $beehiiv_post_id = (string) get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ); + } else { + $synced = Sender::sync_preview_draft( $post_id ); + + if ( is_wp_error( $synced ) ) { + return self::classify_sync_error( $synced ); + } + + $beehiiv_post_id = $synced['beehiiv_post_id']; + } + + $publication_id = PublicationResolver::get_linked_publication_id( $post_id ); + $result = Posts::get_email_content( $publication_id, $beehiiv_post_id ); + + if ( ! $result['success'] ) { + return self::classify_api_error( (string) $result['error'], $result['status_code'] ); + } + + if ( '' === trim( $result['email_html'] ) ) { + return [ 'processing' => true ]; + } + + return [ + 'html' => $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 ), + ]; + } + + /** + * 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 x.x.x + */ + 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 x.x.x + */ + 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 ) ) : ''; + } + + /** + * 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 x.x.x + */ + 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 x.x.x + */ + 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/PreviewEligibility.php b/includes/Newsletter/PreviewEligibility.php new file mode 100644 index 0000000..72bb6ed --- /dev/null +++ b/includes/Newsletter/PreviewEligibility.php @@ -0,0 +1,102 @@ +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' ) + ); + } + + $is_linked = '' !== trim( (string) get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true ) ); + $is_draft_linked = $is_linked && 'draft' === get_post_meta( $post_id, Meta::BEEHIIV_POST_STATE, true ); + + if ( $is_linked && ! $is_draft_linked ) { + return new WP_Error( + 'beehiiv_newsletter_confirmed', + __( 'This newsletter is already scheduled or sent, so it can no longer be previewed.', 'beehiiv' ) + ); + } + + $is_opted_in = rest_sanitize_boolean( get_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, true ) ); + + if ( ! $is_opted_in && ! $is_draft_linked ) { + 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' ) + ); + } + + $publication_id = PublicationResolver::resolve_for_post( $post_id ); + + if ( '' === $publication_id ) { + return new WP_Error( + 'beehiiv_missing_publication', + __( 'Choose a publication in beehiiv settings, then try again.', 'beehiiv' ) + ); + } + + 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; + } +} diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php index accbe8a..80521d3 100644 --- a/includes/Newsletter/Sender.php +++ b/includes/Newsletter/Sender.php @@ -10,6 +10,7 @@ use Beehiiv\API\Resources\Posts; use Beehiiv\Connection\Manager; use Beehiiv\Editor\Meta; +use WP_Error; use WP_Post; use WP_REST_Request; @@ -44,6 +45,21 @@ final class Sender { */ private const SENT_SAFETY_MARGIN = 60; + /** + * Option name prefix for the per-post preview lock. + * + * @since x.x.x + */ + private const PREVIEW_LOCK_PREFIX = 'beehiiv_preview_lock_'; + + /** + * Seconds after which a preview lock counts as abandoned. Longer than the slowest sync + * (a delete and a create, each with a 30 second request timeout). + * + * @since x.x.x + */ + private const PREVIEW_LOCK_SECONDS = 180; + /** * Meta keys that lock once beehiiv has sent the newsletter. * @@ -125,7 +141,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 +398,274 @@ 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 ) { + if ( ! self::acquire_preview_lock( $post_id ) ) { + return new WP_Error( + 'beehiiv_preview_in_progress', + __( 'A preview of this post is already being prepared. Try again in a moment.', 'beehiiv' ) + ); + } + + try { + return self::sync_preview_draft_locked( $post_id ); + } finally { + self::release_preview_lock( $post_id ); + } + } + + /** + * Take the per-post preview lock so two simultaneous previews cannot both create a draft. + * + * Uses an option insert, which is atomic in the database. A lock older than + * {@see PREVIEW_LOCK_SECONDS} is treated as abandoned and taken over. + * + * @param int $post_id Post ID. + * @return bool Whether the lock was acquired. + * @since x.x.x + */ + private static function acquire_preview_lock( int $post_id ): bool { + $key = self::PREVIEW_LOCK_PREFIX . $post_id; + + self::purge_stale_preview_locks(); + // The purge bypasses the options cache, so drop any cached copy of this lock. + wp_cache_delete( $key, 'options' ); + + if ( add_option( $key, time(), '', false ) ) { + return true; + } + + $taken_at = (int) get_option( $key, 0 ); + + if ( $taken_at > time() - self::PREVIEW_LOCK_SECONDS ) { + return false; + } + + // Abandoned lock: replace it, then re-check that this request holds it. + delete_option( $key ); + + return add_option( $key, time(), '', false ); + } + + /** + * Whether a preview sync of this post is running right now. + * + * @param int $post_id Post ID. + * @return bool + * @since x.x.x + */ + public static function is_preview_in_progress( int $post_id ): bool { + $taken_at = (int) get_option( self::PREVIEW_LOCK_PREFIX . $post_id, 0 ); + + return $taken_at > time() - self::PREVIEW_LOCK_SECONDS; + } + + /** + * Remove preview locks left behind by requests that died before releasing them. + * + * @return void + * @since x.x.x + */ + private static function purge_stale_preview_locks(): void { + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Housekeeping of this feature's own lock rows. + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST( option_value AS UNSIGNED ) < %d", + $wpdb->esc_like( self::PREVIEW_LOCK_PREFIX ) . '%', + time() - self::PREVIEW_LOCK_SECONDS + ) + ); + } + + /** + * Release the per-post preview lock. + * + * @param int $post_id Post ID. + * @return void + * @since x.x.x + */ + private static function release_preview_lock( int $post_id ): void { + delete_option( self::PREVIEW_LOCK_PREFIX . $post_id ); + } + + /** + * Create or update the Preview draft; the caller holds the preview lock. + * + * @param int $post_id Post ID. + * @return array{beehiiv_post_id: string, created: bool}|WP_Error + * @since x.x.x + */ + private static function sync_preview_draft_locked( int $post_id ) { + // One definition of "can this post be previewed" for the whole feature. + $eligibility = PreviewEligibility::check( $post_id ); + + if ( is_wp_error( $eligibility ) ) { + return $eligibility; + } + + $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 +682,35 @@ 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. + if ( ! self::is_send_to_newsletter_enabled( $post->ID, $request ) ) { + self::delete_linked_draft( $post->ID ); + return; + } + + // Saves of a post that is not published or scheduled never touch the draft. + if ( ! self::can_sync_newsletter( $post ) ) { + return; + } + + // Publishing or scheduling replaces the draft with a real send, so the email + // is never left behind as an unsent draft. A failed cleanup is reported and retried. + self::clear_error( $post->ID ); + + if ( ! self::delete_linked_draft( $post->ID ) ) { + self::record_error( + $post->ID, + 'send', + __( + "We couldn't replace the email draft in beehiiv. Try saving the post again.", + 'beehiiv' + ) + ); + return; + } + } + if ( self::has_beehiiv_post_id( $post->ID ) ) { self::maybe_update_post_newsletter( $post ); return; @@ -559,6 +870,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 +1624,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 ); } /** @@ -1326,7 +1643,7 @@ private static function should_resend_newsletter_for_status_change( string $old_status, WP_Post $post ): bool { - if ( self::has_beehiiv_post_id( $post->ID ) ) { + if ( self::has_beehiiv_post_id( $post->ID ) && ! self::is_draft_linked( $post->ID ) ) { return false; } 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..f8dfc22 --- /dev/null +++ b/includes/REST/EmailPreviewController.php @@ -0,0 +1,109 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ self::class, 'preview' ], + 'permission_callback' => [ self::class, 'permissions_check' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ], + 'fetch_only' => [ + 'required' => false, + 'type' => 'boolean', + 'default' => false, + 'sanitize_callback' => 'rest_sanitize_boolean', + ], + ], + ] + ); + } + + /** + * Requires publish rights and edit access to the referenced post. + * + * @since x.x.x + * + * @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 x.x.x + * + * @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' ) ), + (bool) $request->get_param( 'fetch_only' ) + ); + + if ( is_wp_error( $result ) ) { + return new WP_REST_Response( + [ + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + ], + 400 + ); + } + + // 202: beehiiv is still preparing the email; the editor asks again shortly. + return new WP_REST_Response( $result, isset( $result['processing'] ) ? 202 : 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..7461863 --- /dev/null +++ b/src/js/editor/post-settings/components/email-preview.js @@ -0,0 +1,290 @@ +/** + * Preview the real beehiiv email of the saved post in a WordPress modal. + */ +import { __ } from '@wordpress/i18n'; +import { useMemo, 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'; + +// While beehiiv prepares a new email: up to MAX_ATTEMPTS fetches, POLL_INTERVAL_MS apart. +const MAX_ATTEMPTS = 4; +const POLL_INTERVAL_MS = 2000; + +const wait = ( ms ) => new Promise( ( resolve ) => setTimeout( resolve, ms ) ); + +const INERT_STYLE = + 'a,a *,area{pointer-events:none!important;cursor:default!important}'; + +/** + * Make the email view-only: links cannot be followed, even with the keyboard. + * + * Scripts, forms and popups are already blocked by the empty `sandbox` attribute; this + * removes link targets so the frame cannot navigate away from the email either. + * + * @param {string} html Rendered email HTML from beehiiv. + * @return {string} HTML with links made inert. + */ +export function makeEmailInert( html ) { + // DOMParser builds an inert document: nothing in it loads or runs. + const doc = new window.DOMParser().parseFromString( html, 'text/html' ); + + doc.querySelectorAll( 'a[href], area[href]' ).forEach( ( link ) => { + link.removeAttribute( 'href' ); + } ); + + const base = doc.createElement( 'base' ); + base.setAttribute( 'target', '_blank' ); + doc.head.prepend( base ); + + const style = doc.createElement( 'style' ); + style.textContent = INERT_STYLE; + doc.head.append( style ); + + return '' + doc.documentElement.outerHTML; +} + +/** + * @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 ); + // Parsing a large email is costly; redo it only when the email itself changes. + const inertHtml = useMemo( + () => ( email ? makeEmailInert( email.html ) : '' ), + [ email ] + ); + + 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 = async () => { + const requestId = ++latestRequest.current; + const isCurrent = () => requestId === latestRequest.current; + + setIsLoading( true ); + setError( null ); + setEmail( null ); + + try { + // The first call syncs the draft; while beehiiv is still preparing the + // email, later calls only fetch it, so no request is held open. + for ( let attempt = 0; attempt < MAX_ATTEMPTS; attempt++ ) { + if ( attempt > 0 ) { + await wait( POLL_INTERVAL_MS ); + } + + const data = await apiFetch( { + path: '/beehiiv/v1/email-preview', + method: 'POST', + data: { post_id: postId, fetch_only: attempt > 0 }, + } ); + + if ( ! isCurrent() ) { + return; + } + + if ( data?.html ) { + setEmail( { + html: data.html, + subject: data.subject ?? '', + previewText: data.preview_text ?? '', + fromName: data.from_name ?? '', + } ); + return; + } + } + + setError( + __( + 'beehiiv is still preparing this email. Try again in a moment.', + 'beehiiv' + ) + ); + } catch ( apiError ) { + if ( isCurrent() ) { + setError( + apiError?.message || + __( + "Something went wrong and the preview couldn't load. Try again.", + 'beehiiv' + ) + ); + } + } finally { + if ( isCurrent() ) { + 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 }
+
+