From cc4198c0641f37ad0767ebac52705f6240e4ec17 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 19 Oct 2026 14:17:45 +0800 Subject: [PATCH 1/5] Broadcasts: Export: Use Post Excerpt as Preview Text --- .../class-convertkit-broadcasts-exporter.php | 36 +++- tests/Integration/BroadcastsExportTest.php | 197 ++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/BroadcastsExportTest.php diff --git a/includes/class-convertkit-broadcasts-exporter.php b/includes/class-convertkit-broadcasts-exporter.php index 15b871911..310e78462 100644 --- a/includes/class-convertkit-broadcasts-exporter.php +++ b/includes/class-convertkit-broadcasts-exporter.php @@ -239,7 +239,15 @@ public function export_post_to_broadcast( $post_id ) { $result = $api->create_broadcast( $post->post_title, $content, - $post->post_excerpt + $post->post_excerpt, + false, + null, + null, + '', + '', + '', + '', + $this->get_preview_text( $post ) ); // If an error occurred, return it now. @@ -255,4 +263,30 @@ public function export_post_to_broadcast( $post_id ) { } + /** + * Returns the preview text for the Broadcast, using the Post's excerpt if one was written. + * + * @since 3.4.8 + * + * @param WP_Post $post WordPress Post. + * @return string Preview text + */ + private function get_preview_text( $post ) { + + $preview_text = ( has_excerpt( $post ) ? $post->post_excerpt : '' ); + + /** + * Define the preview text for the Broadcast created from the WordPress Post. + * + * @since 3.4.8 + * + * @param string $preview_text Preview text. + * @param WP_Post $post WordPress Post. + */ + $preview_text = apply_filters( 'convertkit_broadcasts_exporter_preview_text', $preview_text, $post ); + + return (string) $preview_text; + + } + } diff --git a/tests/Integration/BroadcastsExportTest.php b/tests/Integration/BroadcastsExportTest.php new file mode 100644 index 000000000..d28cd9d3c --- /dev/null +++ b/tests/Integration/BroadcastsExportTest.php @@ -0,0 +1,197 @@ +save( + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + ] + ); + + // Initialize the class we want to test. + $this->exporter = new \ConvertKit_Broadcasts_Exporter(); + + // Mock the API response when creating a Broadcast. + add_filter( 'pre_http_request', array( $this, 'mockCreateBroadcastResponse' ), 10, 3 ); + } + + /** + * Performs actions after each test. + * + * @since 3.4.8 + */ + public function tearDown(): void + { + remove_filter( 'pre_http_request', array( $this, 'mockCreateBroadcastResponse' ) ); + + // Destroy the class we tested. + unset($this->exporter); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that the Post's excerpt is used as the Broadcast's preview text. + * + * @since 3.4.8 + */ + public function testExportPreviewTextUsesExcerpt() + { + // Create Post with an excerpt. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Preview Text', + 'post_content' => '

Post content.

', + 'post_excerpt' => 'Post excerpt.', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm the excerpt is used as the preview text. + $this->assertEquals( 'Post excerpt.', $this->request['preview_text'] ); + } + + /** + * Test that no preview text is sent when the Post has no excerpt. + * + * @since 3.4.8 + */ + public function testExportPreviewTextWhenNoExcerpt() + { + // Create Post without an excerpt. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Preview Text: No Excerpt', + 'post_content' => '

Post content.

', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm no preview text is sent, so Kit doesn't use an auto-generated excerpt. + $this->assertArrayNotHasKey( 'preview_text', $this->request ); + } + + /** + * Test that the convertkit_broadcasts_exporter_preview_text filter defines the preview text. + * + * @since 3.4.8 + */ + public function testExportPreviewTextFilter() + { + // Define preview text using the filter. + add_filter( + 'convertkit_broadcasts_exporter_preview_text', + function ( $preview_text, $post ) { + return 'Filtered preview text for ' . $post->post_title; + }, + 10, + 2 + ); + + // Create Post with an excerpt. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Preview Text: Filter', + 'post_content' => '

Post content.

', + 'post_excerpt' => 'Post excerpt.', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm the filtered preview text is used. + $this->assertEquals( 'Filtered preview text for Kit: Export: Preview Text: Filter', $this->request['preview_text'] ); + } + + /** + * Mocks the API response when creating a Broadcast, storing the request body. + * + * @since 3.4.8 + * + * @param mixed $response HTTP Response. + * @param array $parsed_args Request arguments. + * @param string $url Request URL. + * @return mixed + */ + public function mockCreateBroadcastResponse( $response, $parsed_args, $url ) + { + // Only mock requests made to create a Broadcast. + if ( strpos( $url, 'https://api.kit.com/v4/broadcasts' ) === false || $parsed_args['method'] !== 'POST' ) { + return $response; + } + + // Store the request body. + $this->request = json_decode( $parsed_args['body'], true ); + + return array( + 'headers' => array(), + 'body' => wp_json_encode( + array( + 'broadcast' => array( + 'id' => 123, + 'subject' => $this->request['subject'], + 'content' => $this->request['content'], + 'public' => false, + ), + ) + ), + 'response' => array( + 'code' => 201, + 'message' => 'Created', + ), + 'cookies' => array(), + 'http_response' => null, + ); + } +} From cfc6f157902368cc4234faac77a777586e67209d Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 20 Oct 2026 10:37:45 +0800 Subject: [PATCH 2/5] Fix failing integration test --- tests/Integration/BroadcastsExportTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Integration/BroadcastsExportTest.php b/tests/Integration/BroadcastsExportTest.php index d28cd9d3c..a1b815eee 100644 --- a/tests/Integration/BroadcastsExportTest.php +++ b/tests/Integration/BroadcastsExportTest.php @@ -109,6 +109,7 @@ public function testExportPreviewTextWhenNoExcerpt() [ 'post_title' => 'Kit: Export: Preview Text: No Excerpt', 'post_content' => '

Post content.

', + 'post_excerpt' => '', ] ); From aba06b0597dc92d18e8ed83a993898fc27930526 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 26 Oct 2026 12:19:19 +0800 Subject: [PATCH 3/5] Broadcasts: Export: Greeting, Sign-off and Read on the Web settings --- ...ss-convertkit-admin-section-broadcasts.php | 116 ++++++++++++++++++ .../class-convertkit-broadcasts-exporter.php | 54 ++++++++ .../class-convertkit-settings-broadcasts.php | 56 +++++++++ .../js/settings-conditional-display.js | 4 +- .../BroadcastsToPostsSettingsCest.php | 33 +++++ tests/Integration/BroadcastsExportTest.php | 98 +++++++++++++++ 6 files changed, 359 insertions(+), 2 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-broadcasts.php b/admin/section/class-convertkit-admin-section-broadcasts.php index 6afa9f133..fcf879807 100644 --- a/admin/section/class-convertkit-admin-section-broadcasts.php +++ b/admin/section/class-convertkit-admin-section-broadcasts.php @@ -353,6 +353,45 @@ public function register_fields() { ) ); + add_settings_field( + 'export_greeting', + __( 'Greeting', 'convertkit' ), + array( $this, 'export_greeting_callback' ), + $this->settings_key, + $this->name . '-export', + array( + 'name' => 'export_greeting', + 'label_for' => 'export_greeting', + 'description' => __( 'Optional. Added to the start of draft broadcasts. Supports HTML and Liquid, for example: Hi {{ subscriber.first_name | default: "there" }},', 'convertkit' ), + ) + ); + + add_settings_field( + 'export_sign_off', + __( 'Sign-off', 'convertkit' ), + array( $this, 'export_sign_off_callback' ), + $this->settings_key, + $this->name . '-export', + array( + 'name' => 'export_sign_off', + 'label_for' => 'export_sign_off', + 'description' => __( 'Optional. Added to the end of draft broadcasts. Supports HTML and Liquid.', 'convertkit' ), + ) + ); + + add_settings_field( + 'export_web_link', + __( 'Read on the Web', 'convertkit' ), + array( $this, 'export_web_link_callback' ), + $this->settings_key, + $this->name . '-export', + array( + 'name' => 'export_web_link', + 'label_for' => $this->settings_key . '_export_web_link', + 'description' => __( 'Adds a link to read the post on your site to draft broadcasts. Only added once the post is published.', 'convertkit' ), + ) + ); + add_settings_field( 'no_styles', __( 'Disable Styles', 'convertkit' ), @@ -720,6 +759,76 @@ public function export_public_callback( $args ) { } + /** + * Renders the input for the Greeting setting. + * + * @since 3.4.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function export_greeting_callback( $args ) { + + // Output field. + $this->output_textarea_field( + $args['name'], + esc_textarea( $this->settings->export_greeting() ), + $args['description'], + array( + 'large-text', + 'enabled_export', + ) + ); + + } + + /** + * Renders the input for the Sign-off setting. + * + * @since 3.4.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function export_sign_off_callback( $args ) { + + // Output field. + $this->output_textarea_field( + $args['name'], + esc_textarea( $this->settings->export_sign_off() ), + $args['description'], + array( + 'large-text', + 'enabled_export', + ) + ); + + } + + /** + * Renders the input for the Read on the Web setting. + * + * @since 3.4.8 + * + * @param array $args Setting field arguments (name,description). + */ + public function export_web_link_callback( $args ) { + + // Output field. + $this->output_select_field( + $args['name'], + $this->settings->export_web_link(), + array( + '' => esc_html__( 'None', 'convertkit' ), + 'top' => esc_html__( 'Top of broadcast', 'convertkit' ), + 'bottom' => esc_html__( 'Bottom of broadcast', 'convertkit' ), + ), + $args['description'], + array( + 'enabled_export', + ) + ); + + } + /** * Renders the input for the No Styles setting. * @@ -763,6 +872,13 @@ public function sanitize_settings( $settings ) { $settings['import_images'] = ''; } + // Remove disallowed HTML from the greeting and sign-off. + foreach ( array( 'export_greeting', 'export_sign_off' ) as $key ) { + if ( array_key_exists( $key, $settings ) ) { + $settings[ $key ] = wp_kses_post( $settings[ $key ] ); + } + } + // Merge settings with defaults. $settings = wp_parse_args( $settings, $this->settings->get_defaults() ); diff --git a/includes/class-convertkit-broadcasts-exporter.php b/includes/class-convertkit-broadcasts-exporter.php index 6eddf7385..d253a4cd8 100644 --- a/includes/class-convertkit-broadcasts-exporter.php +++ b/includes/class-convertkit-broadcasts-exporter.php @@ -248,6 +248,9 @@ public function export_post_to_broadcast( $post_id ) { // Remove responsive image attributes, which email clients don't support. $content = $this->remove_responsive_image_attributes( $content ); + // Add the greeting, sign-off and link to read the Post on the web. + $content = $this->add_greeting_sign_off_and_web_link( $content, $post ); + // Convert relative URLs to absolute URLs, as emails have no site context. $content = links_add_base_url( $content, get_permalink( $post ) ); @@ -430,6 +433,57 @@ private function get_subscriber_filter() { } + /** + * Adds the greeting, sign-off and link to read the Post on the web to the given content, + * based on the Plugin's settings. + * + * @since 3.4.8 + * + * @param string $content HTML Content. + * @param WP_Post $post WordPress Post. + * @return string HTML Content + */ + private function add_greeting_sign_off_and_web_link( $content, $post ) { + + // Add the greeting and sign-off, converting any line breaks to paragraphs. + if ( $this->broadcasts_settings->export_greeting() ) { + $content = wpautop( $this->broadcasts_settings->export_greeting() ) . $content; + } + if ( $this->broadcasts_settings->export_sign_off() ) { + $content .= wpautop( $this->broadcasts_settings->export_sign_off() ); + } + + // Bail if no link to read the Post on the web is required, or the Post isn't published, as its link won't work. + if ( ! $this->broadcasts_settings->export_web_link() || $post->post_status !== 'publish' ) { + return $content; + } + + /** + * Define the text for the link to read the Post on the web, in Broadcasts created from WordPress Posts. + * + * @since 3.4.8 + * + * @param string $text Link text. + * @param WP_Post $post WordPress Post. + */ + $text = apply_filters( 'convertkit_broadcasts_exporter_web_link_text', __( 'Read this on the web', 'convertkit' ), $post ); + + // Build link. + $link = sprintf( + '

%s

', + esc_url( get_permalink( $post ) ), + esc_html( $text ) + ); + + // Add link to the top or bottom of the content. + if ( $this->broadcasts_settings->export_web_link() === 'top' ) { + return $link . $content; + } + + return $content . $link; + + } + /** * Removes `srcset` and `sizes` attributes from images in the given content. * diff --git a/includes/class-convertkit-settings-broadcasts.php b/includes/class-convertkit-settings-broadcasts.php index 27339bb06..657a55518 100644 --- a/includes/class-convertkit-settings-broadcasts.php +++ b/includes/class-convertkit-settings-broadcasts.php @@ -203,6 +203,46 @@ public function export_public() { } + /** + * Returns the greeting to add to the start of Broadcasts created from WordPress Posts. + * + * @since 3.4.8 + * + * @return string HTML, which may include Liquid + */ + public function export_greeting() { + + return wp_kses_post( $this->settings['export_greeting'] ); + + } + + /** + * Returns the sign-off to add to the end of Broadcasts created from WordPress Posts. + * + * @since 3.4.8 + * + * @return string HTML, which may include Liquid + */ + public function export_sign_off() { + + return wp_kses_post( $this->settings['export_sign_off'] ); + + } + + /** + * Returns where to add a link to read the WordPress Post on the web, in Broadcasts + * created from WordPress Posts. + * + * @since 3.4.8 + * + * @return string Position (blank = none, top, bottom) + */ + public function export_web_link() { + + return ( in_array( $this->settings['export_web_link'], array( 'top', 'bottom' ), true ) ? $this->settings['export_web_link'] : '' ); + + } + /** * Returns whether Broadcasts should have their styles imported. * @@ -318,6 +358,19 @@ public function get_schema() { 'enum' => array( '', 'on' ), 'description' => __( 'Whether Broadcasts created from WordPress Posts are published to the web (Kit Creator Profile and Landing Pages).', 'convertkit' ), ), + 'export_greeting' => array( + 'type' => 'string', + 'description' => __( 'HTML to add to the start of Broadcasts created from WordPress Posts. Supports Liquid e.g. {{ subscriber.first_name }}.', 'convertkit' ), + ), + 'export_sign_off' => array( + 'type' => 'string', + 'description' => __( 'HTML to add to the end of Broadcasts created from WordPress Posts. Supports Liquid.', 'convertkit' ), + ), + 'export_web_link' => array( + 'type' => 'string', + 'enum' => array( '', 'top', 'bottom' ), + 'description' => __( 'Where to add a link to read the published WordPress Post on the web, in Broadcasts created from WordPress Posts. Blank for none.', 'convertkit' ), + ), 'no_styles' => array( 'type' => 'string', 'enum' => array( '', 'on' ), @@ -352,6 +405,9 @@ public function get_defaults() { 'enabled_export' => '', 'export_send_to' => '', 'export_public' => '', + 'export_greeting' => '', + 'export_sign_off' => '', + 'export_web_link' => '', 'no_styles' => '', ); diff --git a/resources/backend/js/settings-conditional-display.js b/resources/backend/js/settings-conditional-display.js index 26027b946..04ede10f2 100644 --- a/resources/backend/js/settings-conditional-display.js +++ b/resources/backend/js/settings-conditional-display.js @@ -46,10 +46,10 @@ function convertKitConditionallyDisplaySettings(input) { return; } - // Show or hide this row if the input, select, link or span element within the row has the CSS class of the setting ID. + // Show or hide this row if the input, select, textarea, link or span element within the row has the CSS class of the setting ID. if ( row.querySelector( - `input.${input.id}, select.${input.id}, a.${input.id}, span.${input.id}` + `input.${input.id}, select.${input.id}, textarea.${input.id}, a.${input.id}, span.${input.id}` ) ) { row.style.display = input.checked ? '' : 'none'; diff --git a/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php b/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php index 664354f50..cbbcdaaea 100644 --- a/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php +++ b/tests/EndToEnd/broadcasts/import-export/BroadcastsToPostsSettingsCest.php @@ -265,6 +265,39 @@ public function testSaveSendToSegment(EndToEndTester $I) $I->seeInField('_wp_convertkit_settings_broadcasts[export_send_to]', $_ENV['CONVERTKIT_API_SEGMENT_NAME']); } + /** + * Tests that saving the Greeting, Sign-off and Read on the Web settings works. + * + * @since 3.4.8 + * + * @param EndToEndTester $I Tester. + */ + public function testSaveGreetingSignOffAndWebLink(EndToEndTester $I) + { + // Go to the Plugin's Broadcasts Screen. + $I->loadKitSettingsBroadcastsScreen($I); + + // Enable export, and define the Greeting, Sign-off and Read on the Web settings. + $I->checkOption('#enabled_export'); + $I->fillField('_wp_convertkit_settings_broadcasts[export_greeting]', 'Hi {{ subscriber.first_name | default: "there" }},'); + $I->fillField('_wp_convertkit_settings_broadcasts[export_sign_off]', 'Thanks'); + $I->selectOption('_wp_convertkit_settings_broadcasts[export_web_link]', 'Bottom of broadcast'); + + // Click the Save Changes button. + $I->click('Save Changes'); + + // Wait for the settings to save. + $I->waitForElementVisible('#setting-error-settings_updated'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Confirm that the settings saved, keeping Liquid and permitted HTML, and removing scripts. + $I->seeInField('_wp_convertkit_settings_broadcasts[export_greeting]', 'Hi {{ subscriber.first_name | default: "there" }},'); + $I->seeInField('_wp_convertkit_settings_broadcasts[export_sign_off]', 'Thanksalert(1)'); + $I->seeOptionIsSelected('_wp_convertkit_settings_broadcasts[export_web_link]', 'Bottom of broadcast'); + } + /** * Deactivate and reset Plugin(s) after each test, if the test passes. * We don't use _after, as this would provide a screenshot of the Plugin diff --git a/tests/Integration/BroadcastsExportTest.php b/tests/Integration/BroadcastsExportTest.php index 2e6ddab95..e87402f3d 100644 --- a/tests/Integration/BroadcastsExportTest.php +++ b/tests/Integration/BroadcastsExportTest.php @@ -314,6 +314,104 @@ public function testExportPublicDraftPost() $this->assertArrayNotHasKey( 'published_at', $this->request ); } + /** + * Test that the Greeting and Sign-off settings are added to the start and end of the Broadcast, + * keeping Liquid tags. + * + * @since 3.4.8 + */ + public function testExportGreetingAndSignOff() + { + // Define Greeting and Sign-off settings. + $this->saveBroadcastsSettings( + [ + 'export_greeting' => 'Hi {{ subscriber.first_name | default: "there" }},', + 'export_sign_off' => 'Thanks', + ] + ); + + // Create Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Greeting and Sign-off', + 'post_content' => '

Post content.

', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm the Greeting, content and Sign-off are in order. + $this->assertStringStartsWith( '

Hi {{ subscriber.first_name | default: "there" }},

', $this->request['content'] ); + $this->assertStringContainsString( '

Post content.

', $this->request['content'] ); + $this->assertStringEndsWith( '

Thanks

' . "\n", $this->request['content'] ); + } + + /** + * Test that a link to read the Post on the web is added to the bottom or top of the Broadcast, + * with link text that can be changed using a filter. + * + * @since 3.4.8 + */ + public function testExportWebLink() + { + // Create Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Web Link', + 'post_content' => '

Post content.

', + ] + ); + $link = '

Read this on the web

'; + + // Confirm the link is added to the bottom. + $this->saveBroadcastsSettings( [ 'export_web_link' => 'bottom' ] ); + $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertStringEndsWith( $link, $this->request['content'] ); + + // Confirm the link is added to the top. + $this->saveBroadcastsSettings( [ 'export_web_link' => 'top' ] ); + $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertStringStartsWith( $link, $this->request['content'] ); + + // Confirm the link text can be changed. + add_filter( + 'convertkit_broadcasts_exporter_web_link_text', + function () { + return 'View online'; + } + ); + $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertStringStartsWith( '

View online

', $this->request['content'] ); + } + + /** + * Test that a link to read the Post on the web isn't added when the Post isn't published. + * + * @since 3.4.8 + */ + public function testExportWebLinkNotAddedToDraftPost() + { + // Define Read on the Web setting. + $this->saveBroadcastsSettings( [ 'export_web_link' => 'bottom' ] ); + + // Create draft Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Web Link: Draft', + 'post_content' => '

Post content.

', + 'post_status' => 'draft', + ] + ); + + // Export Post. + $this->exporter->export_post_to_broadcast( $post_id ); + + // Confirm no link was added. + $this->assertStringNotContainsString( 'Read this on the web', $this->request['content'] ); + } + /** * Test that exporting a Post creates a Broadcast, storing its ID in the Post's meta. * From b982695cb009d6aa9fe395d4c8b539f58fd538f2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 26 Oct 2026 12:25:37 +0800 Subject: [PATCH 4/5] Broadcasts: Export: Use Featured Image as Thumbnail --- .../class-convertkit-broadcasts-exporter.php | 47 +++++++++++++- tests/Integration/BroadcastsExportTest.php | 65 +++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/includes/class-convertkit-broadcasts-exporter.php b/includes/class-convertkit-broadcasts-exporter.php index d253a4cd8..5d10ab6f7 100644 --- a/includes/class-convertkit-broadcasts-exporter.php +++ b/includes/class-convertkit-broadcasts-exporter.php @@ -273,7 +273,8 @@ public function export_post_to_broadcast( $post_id ) { } // Define the Broadcast's parameters. - $args = array( + $thumbnail = $this->get_thumbnail( $post ); + $args = array( $post->post_title, $content, $post->post_excerpt, @@ -282,8 +283,8 @@ public function export_post_to_broadcast( $post_id ) { null, '', '', - '', - '', + $thumbnail['alt'], + $thumbnail['url'], '', $this->get_subscriber_filter(), ); @@ -367,6 +368,46 @@ private function get_existing_draft_broadcast_id( $api, $post_id ) { } + /** + * Returns the Post's featured image URL and alt text, to use as the Broadcast's thumbnail + * if the Broadcast is public. + * + * @since 3.4.8 + * + * @param WP_Post $post WordPress Post. + * @return array Thumbnail URL and alt text (blank if no thumbnail) + */ + private function get_thumbnail( $post ) { + + $thumbnail = array( + 'url' => '', + 'alt' => '', + ); + + // Bail if the Broadcast isn't public, as Kit only uses thumbnails for public Broadcasts. + if ( ! $this->broadcasts_settings->export_public() ) { + return $thumbnail; + } + + // Bail if the Post has no featured image. + $image_id = get_post_thumbnail_id( $post ); + if ( ! $image_id ) { + return $thumbnail; + } + + // Bail if the featured image's URL can't be fetched. + $url = wp_get_attachment_image_url( $image_id, 'full' ); + if ( ! $url ) { + return $thumbnail; + } + + return array( + 'url' => $url, + 'alt' => (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true ), + ); + + } + /** * Returns the Post's published date and time, if the Broadcast is public and the Post is published. * diff --git a/tests/Integration/BroadcastsExportTest.php b/tests/Integration/BroadcastsExportTest.php index e87402f3d..e777ad2fd 100644 --- a/tests/Integration/BroadcastsExportTest.php +++ b/tests/Integration/BroadcastsExportTest.php @@ -412,6 +412,71 @@ public function testExportWebLinkNotAddedToDraftPost() $this->assertStringNotContainsString( 'Read this on the web', $this->request['content'] ); } + /** + * Test that the Post's featured image is used as the Broadcast's thumbnail when the Make Public setting is enabled. + * + * @since 3.4.8 + */ + public function testExportFeaturedImageAsThumbnail() + { + // Enable Make Public setting. + $this->saveBroadcastsSettings( [ 'export_public' => 'on' ] ); + + // Create Post with a featured image. + $post_id = $this->createPostWithFeaturedImage( 'Kit: Export: Featured Image' ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm the featured image is the Broadcast's thumbnail. + $this->assertEquals( wp_get_attachment_image_url( get_post_thumbnail_id( $post_id ), 'full' ), $this->request['thumbnail_url'] ); + $this->assertEquals( 'Featured image alt text', $this->request['thumbnail_alt'] ); + } + + /** + * Test that the Post's featured image isn't sent when the Make Public setting is disabled, + * as Kit only uses thumbnails for public Broadcasts. + * + * @since 3.4.8 + */ + public function testExportFeaturedImageNotUsedWhenNotPublic() + { + // Create Post with a featured image. + $post_id = $this->createPostWithFeaturedImage( 'Kit: Export: Featured Image: Not Public' ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm no thumbnail was sent. + $this->assertArrayNotHasKey( 'thumbnail_url', $this->request ); + $this->assertArrayNotHasKey( 'thumbnail_alt', $this->request ); + } + + /** + * Creates a Post with a featured image that has alt text. + * + * @since 3.4.8 + * + * @param string $title Post title. + * @return int + */ + private function createPostWithFeaturedImage( $title ) + { + $post_id = $this->factory->post->create( + [ + 'post_title' => $title, + 'post_content' => '

Post content.

', + ] + ); + $image_id = $this->factory->attachment->create_upload_object( dirname( __DIR__ ) . '/Support/Data/icon.png', $post_id ); + update_post_meta( $image_id, '_wp_attachment_image_alt', 'Featured image alt text' ); + set_post_thumbnail( $post_id, $image_id ); + + return $post_id; + } + /** * Test that exporting a Post creates a Broadcast, storing its ID in the Post's meta. * From b1caebb737f078eea8b5567665d6f3a91eb21035 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 30 Oct 2026 11:34:35 +0800 Subject: [PATCH 5/5] Sanitize Greeting and Sign-off on save only --- .../class-convertkit-settings-broadcasts.php | 11 +++++- tests/Integration/BroadcastsExportTest.php | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/includes/class-convertkit-settings-broadcasts.php b/includes/class-convertkit-settings-broadcasts.php index 657a55518..f89b48a6b 100644 --- a/includes/class-convertkit-settings-broadcasts.php +++ b/includes/class-convertkit-settings-broadcasts.php @@ -212,7 +212,7 @@ public function export_public() { */ public function export_greeting() { - return wp_kses_post( $this->settings['export_greeting'] ); + return (string) $this->settings['export_greeting']; } @@ -225,7 +225,7 @@ public function export_greeting() { */ public function export_sign_off() { - return wp_kses_post( $this->settings['export_sign_off'] ); + return (string) $this->settings['export_sign_off']; } @@ -434,6 +434,13 @@ public function get_defaults() { */ public function save( $settings ) { + // Remove disallowed HTML from the greeting and sign-off. + foreach ( array( 'export_greeting', 'export_sign_off' ) as $key ) { + if ( array_key_exists( $key, $settings ) ) { + $settings[ $key ] = wp_kses_post( $settings[ $key ] ); + } + } + update_option( self::SETTINGS_NAME, array_merge( $this->get(), $settings ) ); // Reload settings in class, to reflect changes. diff --git a/tests/Integration/BroadcastsExportTest.php b/tests/Integration/BroadcastsExportTest.php index 71fcff54e..c36855520 100644 --- a/tests/Integration/BroadcastsExportTest.php +++ b/tests/Integration/BroadcastsExportTest.php @@ -430,6 +430,43 @@ public function testExportGreetingAndSignOff() $this->assertStringEndsWith( '

Thanks

' . "\n", $this->request['content'] ); } + /** + * Test that disallowed HTML is removed from the Greeting and Sign-off settings when saved + * programmatically e.g. using MCP, and isn't included in the Broadcast. + * + * @since 3.4.8 + */ + public function testExportGreetingAndSignOffSanitizedOnSave() + { + // Define Greeting and Sign-off settings, including disallowed HTML. + $this->saveBroadcastsSettings( + [ + 'export_greeting' => 'Hi {{ subscriber.first_name }},', + 'export_sign_off' => 'Thanks', + ] + ); + + // Confirm the settings were stored without disallowed HTML. + $settings = new \ConvertKit_Settings_Broadcasts(); + $this->assertEquals( 'Hi {{ subscriber.first_name }},alert(1)', $settings->export_greeting() ); + $this->assertEquals( 'Thanksalert(2)', $settings->export_sign_off() ); + + // Create Post. + $post_id = $this->factory->post->create( + [ + 'post_title' => 'Kit: Export: Greeting and Sign-off: Sanitized', + 'post_content' => '

Post content.

', + ] + ); + + // Export Post. + $result = $this->exporter->export_post_to_broadcast( $post_id ); + $this->assertNotInstanceOf(\WP_Error::class, $result); + + // Confirm no script tags are in the Broadcast. + $this->assertStringNotContainsString( 'request['content'] ); + } + /** * Test that a link to read the Post on the web is added to the bottom or top of the Broadcast, * with link text that can be changed using a filter.