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 }
+
+