Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 84 additions & 17 deletions includes/API/Resources/Posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>, 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<string, mixed>, 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,
];
}

Expand All @@ -106,32 +108,35 @@ 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,
];
}

$status_code = Client::get_last_status_code();

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,
];
}

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<int, mixed> $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.
*
Expand Down
9 changes: 9 additions & 0 deletions includes/Editor/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
6 changes: 6 additions & 0 deletions includes/Editor/PostSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '',
Expand Down Expand Up @@ -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,
Expand Down
201 changes: 201 additions & 0 deletions includes/Newsletter/EmailPreview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php
/**
* Builds the in-editor preview of the real beehiiv email for a saved post.
*
* @package beehiiv
*/

namespace Beehiiv\Newsletter;

use Beehiiv\API\Resources\Posts;
use Beehiiv\Editor\Meta;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
* Brings the post's linked beehiiv draft up to date with the saved post, then fetches
* beehiiv's rendered email. The draft lifecycle belongs to {@see Sender}; a failed
* preview never touches the post's newsletter error, link, toggle or send date.
*
* @link https://developers.beehiiv.com/api-reference/posts/show
* @since x.x.x
*/
final class EmailPreview {

/**
* Sync the draft from the saved post and fetch the rendered email once.
*
* A new email can still be in preparation at beehiiv, so this never waits: when the email
* is not ready it returns `processing`, and the editor polls with `$fetch_only` (no new
* sync) until it is ready or it gives up. No PHP request is ever held open.
*
* @param int $post_id Post ID.
* @param bool $fetch_only Only fetch the email of the already linked draft; skip the sync.
* @return array{html: string, subject: string, preview_text: string, from_name: string}
* |array{processing: bool}|WP_Error
* @since x.x.x
*/
public static function render( int $post_id, bool $fetch_only = false ) {
if ( $fetch_only ) {
// A full sync checks eligibility itself; a fetch-only call checks it here.
$eligibility = PreviewEligibility::check( $post_id );

if ( is_wp_error( $eligibility ) ) {
return $eligibility;
}

// Another request is creating or replacing the draft: ask the editor to wait.
if ( Sender::is_preview_in_progress( $post_id ) ) {
return [ 'processing' => 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 <a>beehiiv settings</a> 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' )
);
}
}
Loading
Loading