From 292ceb513185533ec32ef4d5291218646e6c8191 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 11:10:05 +0200 Subject: [PATCH 01/16] Add email notifications for new quote events Implements email notifications when a user's post is quoted, including user and blog-level settings, email template, and tests. Updates admin and settings UI to allow enabling/disabling quote notifications. Adds comprehensive PHPUnit tests for the new notification logic. --- includes/class-activitypub.php | 12 + includes/class-mailer.php | 111 ++++++++- includes/wp-admin/class-admin.php | 1 + .../wp-admin/class-blog-settings-fields.php | 6 + includes/wp-admin/class-settings.php | 10 + .../wp-admin/class-user-settings-fields.php | 6 + templates/emails/new-quote.php | 73 ++++++ .../tests/includes/class-test-mailer.php | 235 ++++++++++++++++++ .../handler/class-test-quote-request.php | 9 +- 9 files changed, 458 insertions(+), 5 deletions(-) create mode 100644 templates/emails/new-quote.php diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index b95b69ec0..00131c299 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -340,6 +340,18 @@ public static function register_user_meta() { ); \add_filter( 'get_user_option_activitypub_mailer_new_mention', array( self::class, 'user_options_default' ) ); + \register_meta( + 'user', + $blog_prefix . 'activitypub_mailer_new_quote', + array( + 'type' => 'integer', + 'description' => 'Send a notification when someone quotes this user.', + 'single' => true, + 'sanitize_callback' => 'absint', + ) + ); + \add_filter( 'get_user_option_activitypub_mailer_new_quote', array( self::class, 'user_options_default' ) ); + \register_meta( 'user', 'activitypub_show_welcome_tab', diff --git a/includes/class-mailer.php b/includes/class-mailer.php index f78ae8b3b..71bc31b49 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -22,7 +22,10 @@ public static function init() { \add_action( 'activitypub_inbox_follow', array( self::class, 'new_follower' ), 10, 2 ); \add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 ); - \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); /** After @see \Activitypub\Handler\Create::handle_create() */ + /** After @see \Activitypub\Handler\Create::handle_create() */ + \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); + + \add_action( 'activitypub_handled_quote_request', array( self::class, 'quote' ), 10, 3 ); } /** @@ -360,6 +363,112 @@ public static function mention( $activity, $user_id ) { \remove_action( 'phpmailer_init', $alt_function ); } + /** + * Send a quoted notification. + * + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $state True on success, false otherwise. + */ + public static function quote( $activity, $user_id, $state ) { + if ( ! $state ) { + return; + } + + // Do not send notifications to the Application user. + if ( Actors::APPLICATION_USER_ID === $user_id ) { + return; + } + + if ( $user_id > Actors::BLOG_USER_ID ) { + if ( ! \get_user_option( 'activitypub_mailer_new_quote', $user_id ) ) { + return; + } + + $email = \get_userdata( $user_id )->user_email; + } else { + if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_quote', '1' ) ) { + return; + } + + $email = \get_option( 'admin_email' ); + } + + $actor = get_remote_metadata_by_actor( $activity['actor'] ); + if ( ! $actor || \is_wp_error( $actor ) ) { + return; + } + + $actor = self::normalize_actor( $actor ); + + // Get the quoted post/object. + // For QuoteRequest activities, object is the quoted URL string. + // For regular quote posts, object.quoteUrl contains the quoted URL. + $quoted_url = null; + if ( is_string( $activity['object'] ) ) { + // QuoteRequest format. + $quoted_url = $activity['object']; + } elseif ( ! empty( $activity['object']['quoteUrl'] ) ) { + // Regular quote post format. + $quoted_url = $activity['object']['quoteUrl']; + } + + $template_args = array( + 'activity' => $activity, + 'actor' => $actor, + 'user_id' => $user_id, + 'quoted_url' => $quoted_url, + ); + + /* translators: 1: Blog name, 2: Actor name */ + $subject = \sprintf( \esc_html__( '[%1$s] Quote from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) ); + + \ob_start(); + \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-quote.php', false, $template_args ); + $html_message = \ob_get_clean(); + + $alt_function = function ( $mailer ) use ( $actor, $activity, $quoted_url ) { + $content = ''; + // For QuoteRequest, instrument contains the quote post URL. + // For regular quotes, object contains the quote post. + $quote_object = $activity['instrument'] ?? $activity['object']; + if ( is_array( $quote_object ) && ! empty( $quote_object['content'] ) ) { + $content = \html_entity_decode( + \wp_strip_all_tags( + str_replace( '

', PHP_EOL . PHP_EOL, $quote_object['content'] ) + ), + ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 + ); + } + + /* translators: Actor name */ + $message = \sprintf( \esc_html__( '%1$s quoted your post:', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n\r\n"; + + if ( $content ) { + $message .= $content . "\r\n\r\n"; + } + + if ( $quoted_url ) { + /* translators: Quoted post URL */ + $message .= \sprintf( \esc_html__( 'Your post: %s', 'activitypub' ), \esc_url( $quoted_url ) ) . "\r\n"; + } + + // Get the quote post URL. + $quote_url = is_array( $quote_object ) && ! empty( $quote_object['id'] ) ? $quote_object['id'] : ( $activity['instrument'] ?? '' ); + if ( $quote_url ) { + /* translators: Quote post URL */ + $message .= \sprintf( \esc_html__( 'Quote URL: %s', 'activitypub' ), \esc_url( $quote_url ) ) . "\r\n\r\n"; + } + + $mailer->{'AltBody'} = $message; + }; + \add_action( 'phpmailer_init', $alt_function ); + + \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) ); + + \remove_action( 'phpmailer_init', $alt_function ); + } + /** * Apply defaults to the actor object. * diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index a51382290..af579922e 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -249,6 +249,7 @@ public static function save_user_settings( $user_id ) { 'activitypub_mailer_new_dm', 'activitypub_mailer_new_follower', 'activitypub_mailer_new_mention', + 'activitypub_mailer_new_quote', ); foreach ( $required_user_options as $option ) { diff --git a/includes/wp-admin/class-blog-settings-fields.php b/includes/wp-admin/class-blog-settings-fields.php index ee96d2841..2dcb244aa 100644 --- a/includes/wp-admin/class-blog-settings-fields.php +++ b/includes/wp-admin/class-blog-settings-fields.php @@ -236,6 +236,12 @@ public static function notifications_callback() {

+

+ +

'integer', + 'description' => 'Send a notification when someone quotes a user of the blog.', + 'default' => 1, + ) + ); + \register_setting( 'activitypub_blog', 'activitypub_blog_user_also_known_as', diff --git a/includes/wp-admin/class-user-settings-fields.php b/includes/wp-admin/class-user-settings-fields.php index a011651e8..d7267185f 100644 --- a/includes/wp-admin/class-user-settings-fields.php +++ b/includes/wp-admin/class-user-settings-fields.php @@ -250,6 +250,12 @@ public static function notifications_callback() {

+

+ +

+ +

+ +

+ ' . esc_html( $args['actor']['webfinger'] ) . '' ); + ?> +

+ + +
+ +
+ + +

+ + + +

+ + +

+ + +

+ + + 1, $blog_prefix . 'activitypub_mailer_new_follower' => 1, $blog_prefix . 'activitypub_mailer_new_mention' => 1, + $blog_prefix . 'activitypub_mailer_new_quote' => 1, ), ) ); @@ -743,4 +744,238 @@ public function test_blog_mention_with_disabled_option() { delete_option( 'activitypub_blog_user_mailer_new_mention' ); delete_option( 'activitypub_actor_mode' ); } + + /** + * Test quote notification. + * + * @covers ::quoted + */ + public function test_quoted() { + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/author', + 'object' => array( + 'id' => 'https://example.com/post/1', + 'type' => 'Note', + 'content' => '

Great article! I have some thoughts on this.

', + 'quoteUrl' => get_permalink( self::$post_id ), + ), + ); + + // Mock remote metadata. + add_filter( + 'pre_get_remote_metadata_by_actor', + function () { + return array( + 'name' => 'Test Quoter', + 'url' => 'https://example.com/author', + 'preferredUsername' => 'quoter', + ); + } + ); + + // Capture email. + add_filter( + 'wp_mail', + function ( $args ) { + $this->assertStringContainsString( 'Test Quoter', $args['subject'] ); + $this->assertStringContainsString( 'Quote from', $args['subject'] ); + $this->assertStringContainsString( 'https://example.com/post/1', $args['message'] ); + $this->assertStringContainsString( 'Great article', $args['message'] ); + $this->assertEquals( get_user_by( 'id', self::$user_id )->user_email, $args['to'] ); + return $args; + } + ); + + Mailer::quote( $activity, self::$user_id, true ); + + // Clean up. + remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + remove_all_filters( 'wp_mail' ); + } + + /** + * Test quote notification when state is false. + * + * @covers ::quoted + */ + public function test_quoted_with_false_state() { + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/author', + 'object' => array( + 'id' => 'https://example.com/post/1', + 'type' => 'Note', + 'content' => '

Great article!

', + 'quoteUrl' => get_permalink( self::$post_id ), + ), + ); + + // Add a filter to fail the test if an email is sent. + $mock = new \MockAction(); + add_action( 'wp_before_load_template', array( $mock, 'action' ) ); + + // Call with false state - should not send email. + Mailer::quote( $activity, self::$user_id, false ); + + // Assert no email was sent. + $this->assertEquals( 0, $mock->get_call_count() ); + + // Clean up. + remove_all_filters( 'wp_before_load_template' ); + } + + /** + * Test quote notification when user option is disabled. + * + * @covers ::quoted + */ + public function test_quoted_with_disabled_option() { + // Disable the user option. + update_user_option( self::$user_id, 'activitypub_mailer_new_quote', false ); + + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/author', + 'object' => array( + 'id' => 'https://example.com/post/1', + 'type' => 'Note', + 'content' => '

Great article!

', + 'quoteUrl' => get_permalink( self::$post_id ), + ), + ); + + // Add a filter to fail the test if an email is sent. + $mock = new \MockAction(); + add_action( 'wp_before_load_template', array( $mock, 'action' ) ); + + Mailer::quote( $activity, self::$user_id, true ); + + // Assert no email was sent. + $this->assertEquals( 0, $mock->get_call_count() ); + + // Clean up. + remove_all_filters( 'wp_before_load_template' ); + delete_user_option( self::$user_id, 'activitypub_mailer_new_quote' ); + } + + /** + * Test quote notification for blog user. + * + * @covers ::quoted + */ + public function test_blog_quoted() { + update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + update_option( 'activitypub_blog_user_mailer_new_quote', '1' ); + + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/author', + 'object' => array( + 'id' => 'https://example.com/post/1', + 'type' => 'Note', + 'content' => '

Great blog post!

', + 'quoteUrl' => get_permalink( self::$post_id ), + ), + ); + + // Mock remote metadata. + add_filter( + 'pre_get_remote_metadata_by_actor', + function () { + return array( + 'name' => 'Test Quoter', + 'url' => 'https://example.com/author', + 'preferredUsername' => 'quoter', + ); + } + ); + + // Capture email. + add_filter( + 'wp_mail', + function ( $args ) { + $this->assertStringContainsString( 'Test Quoter', $args['subject'] ); + $this->assertStringContainsString( 'https://example.com/post/1', $args['message'] ); + $this->assertEquals( get_option( 'admin_email' ), $args['to'] ); + return $args; + } + ); + + Mailer::quote( $activity, Actors::BLOG_USER_ID, true ); + + // Clean up. + remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + remove_all_filters( 'wp_mail' ); + delete_option( 'activitypub_blog_user_mailer_new_quote' ); + delete_option( 'activitypub_actor_mode' ); + } + + /** + * Test quote notification for blog user when option is disabled. + * + * @covers ::quoted + */ + public function test_blog_quoted_with_disabled_option() { + // Set blog option to false (0). + update_option( 'activitypub_blog_user_mailer_new_quote', '0' ); + update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/author', + 'object' => array( + 'id' => 'https://example.com/post/1', + 'type' => 'Note', + 'content' => '

Great blog!

', + 'quoteUrl' => get_permalink( self::$post_id ), + ), + ); + + // Add a filter to fail the test if an email is sent. + $mock = new \MockAction(); + add_action( 'wp_before_load_template', array( $mock, 'action' ) ); + + // Call the method with blog user ID. + Mailer::quote( $activity, Actors::BLOG_USER_ID, true ); + + // Assert no email was sent. + $this->assertEquals( 0, $mock->get_call_count() ); + + // Clean up. + remove_all_filters( 'wp_before_load_template' ); + delete_option( 'activitypub_blog_user_mailer_new_quote' ); + delete_option( 'activitypub_actor_mode' ); + } + + /** + * Test quote notification does not send to Application user. + * + * @covers ::quoted + */ + public function test_quoted_application_user() { + $activity = array( + 'type' => 'Create', + 'actor' => 'https://example.com/author', + 'object' => array( + 'id' => 'https://example.com/post/1', + 'type' => 'Note', + 'content' => '

Great article!

', + 'quoteUrl' => get_permalink( self::$post_id ), + ), + ); + + // Add a filter to fail the test if an email is sent. + $mock = new \MockAction(); + add_action( 'wp_before_load_template', array( $mock, 'action' ) ); + + // Call with Application user - should not send email. + Mailer::quote( $activity, Actors::APPLICATION_USER_ID, true ); + + // Assert no email was sent. + $this->assertEquals( 0, $mock->get_call_count() ); + + // Clean up. + remove_all_filters( 'wp_before_load_template' ); + } } diff --git a/tests/phpunit/tests/includes/handler/class-test-quote-request.php b/tests/phpunit/tests/includes/handler/class-test-quote-request.php index 32e8f53bf..3e7dd3ec3 100644 --- a/tests/phpunit/tests/includes/handler/class-test-quote-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-quote-request.php @@ -151,10 +151,11 @@ public function test_handle_quote_request_policies( $policy, $setup_callback, $e 'pre_get_remote_metadata_by_actor', function () use ( $actor_url ) { return array( - 'id' => $actor_url, - 'actor' => $actor_url, - 'type' => 'Person', - 'inbox' => str_replace( '/users/', '/inbox/', $actor_url ), + 'id' => $actor_url, + 'actor' => $actor_url, + 'type' => 'Person', + 'preferredUsername' => 'remote_user', + 'inbox' => str_replace( '/users/', '/inbox/', $actor_url ), ); } ); From e8703fc6419a2ad803b4873c3f53dbfc217942d5 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 11:13:23 +0200 Subject: [PATCH 02/16] Update includes/class-mailer.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- includes/class-mailer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 71bc31b49..20f405a7b 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -22,8 +22,8 @@ public static function init() { \add_action( 'activitypub_inbox_follow', array( self::class, 'new_follower' ), 10, 2 ); \add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 ); - /** After @see \Activitypub\Handler\Create::handle_create() */ - \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); + + \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); // After @see \Activitypub\Handler\Create::handle_create() \add_action( 'activitypub_handled_quote_request', array( self::class, 'quote' ), 10, 3 ); } From 9434ff8df2af674a43e43ceb51aff5f9458ba851 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 11:13:35 +0200 Subject: [PATCH 03/16] Update templates/emails/new-quote.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- templates/emails/new-quote.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/templates/emails/new-quote.php b/templates/emails/new-quote.php index 9f7b75cdc..ddbe9c6ad 100644 --- a/templates/emails/new-quote.php +++ b/templates/emails/new-quote.php @@ -21,13 +21,8 @@

' . esc_html( $args['actor']['webfinger'] ) . '' ); ?> From 06fd4b0e86a8d00358f325e007dfc28ae2d4839d Mon Sep 17 00:00:00 2001 From: Automattic Bot Date: Thu, 9 Oct 2025 11:13:52 +0200 Subject: [PATCH 04/16] Add changelog --- .github/changelog/2303-from-description | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/changelog/2303-from-description diff --git a/.github/changelog/2303-from-description b/.github/changelog/2303-from-description new file mode 100644 index 000000000..bcd8bfd66 --- /dev/null +++ b/.github/changelog/2303-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added email notifications for quoted posts, with user and blog-level settings and a new customizable email template. From 30ddfbecbe54885ffd4c5af1e98b1d1c460c23c7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 11:31:28 +0200 Subject: [PATCH 05/16] Update comment style for mention action hook Replaces a line comment with a PHPDoc-style comment for the 'mention' action hook to improve code documentation consistency. --- includes/class-mailer.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 20f405a7b..88e019060 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -22,9 +22,7 @@ public static function init() { \add_action( 'activitypub_inbox_follow', array( self::class, 'new_follower' ), 10, 2 ); \add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 ); - - \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); // After @see \Activitypub\Handler\Create::handle_create() - + \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); /** After {@see \Activitypub\Handler\Create::handle_create()}. */ \add_action( 'activitypub_handled_quote_request', array( self::class, 'quote' ), 10, 3 ); } From 1795260e025e3d46bea4ea539604c58cef08dd3a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 11:37:56 +0200 Subject: [PATCH 06/16] Update @covers annotations from quoted to quote Replaces all @covers ::quoted annotations with @covers ::quote in class-test-mailer.php to accurately reflect the method being tested. --- tests/phpunit/tests/includes/class-test-mailer.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/phpunit/tests/includes/class-test-mailer.php b/tests/phpunit/tests/includes/class-test-mailer.php index 7ec9decb6..ecf63d1ee 100644 --- a/tests/phpunit/tests/includes/class-test-mailer.php +++ b/tests/phpunit/tests/includes/class-test-mailer.php @@ -748,7 +748,7 @@ public function test_blog_mention_with_disabled_option() { /** * Test quote notification. * - * @covers ::quoted + * @covers ::quote */ public function test_quoted() { $activity = array( @@ -797,7 +797,7 @@ function ( $args ) { /** * Test quote notification when state is false. * - * @covers ::quoted + * @covers ::quote */ public function test_quoted_with_false_state() { $activity = array( @@ -828,7 +828,7 @@ public function test_quoted_with_false_state() { /** * Test quote notification when user option is disabled. * - * @covers ::quoted + * @covers ::quote */ public function test_quoted_with_disabled_option() { // Disable the user option. @@ -862,7 +862,7 @@ public function test_quoted_with_disabled_option() { /** * Test quote notification for blog user. * - * @covers ::quoted + * @covers ::quote */ public function test_blog_quoted() { update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); @@ -914,7 +914,7 @@ function ( $args ) { /** * Test quote notification for blog user when option is disabled. * - * @covers ::quoted + * @covers ::quote */ public function test_blog_quoted_with_disabled_option() { // Set blog option to false (0). @@ -951,7 +951,7 @@ public function test_blog_quoted_with_disabled_option() { /** * Test quote notification does not send to Application user. * - * @covers ::quoted + * @covers ::quote */ public function test_quoted_application_user() { $activity = array( From b369c9273a275171a64ba4b7a02944ab4ceebb0a Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 15:42:48 +0200 Subject: [PATCH 07/16] Update includes/class-mailer.php Co-authored-by: Konstantin Obenland --- includes/class-mailer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 88e019060..5c02fabd7 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -430,7 +430,7 @@ public static function quote( $activity, $user_id, $state ) { // For QuoteRequest, instrument contains the quote post URL. // For regular quotes, object contains the quote post. $quote_object = $activity['instrument'] ?? $activity['object']; - if ( is_array( $quote_object ) && ! empty( $quote_object['content'] ) ) { + if ( ! empty( $quote_object['content'] ) ) { $content = \html_entity_decode( \wp_strip_all_tags( str_replace( '

', PHP_EOL . PHP_EOL, $quote_object['content'] ) From ee8c1c299d6b8a04c746c7c440b477414c852d4b Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 15:45:37 +0200 Subject: [PATCH 08/16] Refactor comments and quote URL assignment in mailer Updated block comments for clarity and consistency in the mailer class. Simplified the quote URL assignment by using null coalescing, improving code readability. --- includes/class-mailer.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 5c02fabd7..9f9747dac 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -399,9 +399,11 @@ public static function quote( $activity, $user_id, $state ) { $actor = self::normalize_actor( $actor ); - // Get the quoted post/object. - // For QuoteRequest activities, object is the quoted URL string. - // For regular quote posts, object.quoteUrl contains the quoted URL. + /* + * Get the quoted post/object. + * For QuoteRequest activities, object is the quoted URL string. + * For regular quote posts, object.quoteUrl contains the quoted URL. + */ $quoted_url = null; if ( is_string( $activity['object'] ) ) { // QuoteRequest format. @@ -427,8 +429,11 @@ public static function quote( $activity, $user_id, $state ) { $alt_function = function ( $mailer ) use ( $actor, $activity, $quoted_url ) { $content = ''; - // For QuoteRequest, instrument contains the quote post URL. - // For regular quotes, object contains the quote post. + + /* + * For QuoteRequest, instrument contains the quote post URL. + * For regular quotes, object contains the quote post. + */ $quote_object = $activity['instrument'] ?? $activity['object']; if ( ! empty( $quote_object['content'] ) ) { $content = \html_entity_decode( @@ -452,7 +457,7 @@ public static function quote( $activity, $user_id, $state ) { } // Get the quote post URL. - $quote_url = is_array( $quote_object ) && ! empty( $quote_object['id'] ) ? $quote_object['id'] : ( $activity['instrument'] ?? '' ); + $quote_url = $quote_object['id'] ?? $activity['instrument'] ?? ''; if ( $quote_url ) { /* translators: Quote post URL */ $message .= \sprintf( \esc_html__( 'Quote URL: %s', 'activitypub' ), \esc_url( $quote_url ) ) . "\r\n\r\n"; From 7bd5426186cb8fcca482d695c92c1a4e5f7a958e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 15:46:15 +0200 Subject: [PATCH 09/16] Update templates/emails/new-quote.php Co-authored-by: Konstantin Obenland --- templates/emails/new-quote.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/emails/new-quote.php b/templates/emails/new-quote.php index ddbe9c6ad..82324df79 100644 --- a/templates/emails/new-quote.php +++ b/templates/emails/new-quote.php @@ -22,7 +22,7 @@

' . esc_html( $args['actor']['webfinger'] ) . '' ); ?> From ccbed26bc658902b061c1e3536d633406fae684f Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 15:51:58 +0200 Subject: [PATCH 10/16] Update includes/class-mailer.php Co-authored-by: Konstantin Obenland --- includes/class-mailer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 9f9747dac..3d8be0219 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -410,7 +410,7 @@ public static function quote( $activity, $user_id, $state ) { $quoted_url = $activity['object']; } elseif ( ! empty( $activity['object']['quoteUrl'] ) ) { // Regular quote post format. - $quoted_url = $activity['object']['quoteUrl']; + $quoted_url = object_to_uri( $activity['object']['quoteUrl'] ); } $template_args = array( From 70fde3811114664017314d2a86ed4ff93b2a9208 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 9 Oct 2025 17:46:24 +0200 Subject: [PATCH 11/16] Update email message for new quote notification Simplified the wording in the notification email when a post is quoted on the Fediverse. The message is now more direct and concise. --- templates/emails/new-quote.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/emails/new-quote.php b/templates/emails/new-quote.php index 82324df79..d1c6c54c5 100644 --- a/templates/emails/new-quote.php +++ b/templates/emails/new-quote.php @@ -5,7 +5,6 @@ * @package Activitypub */ -use Activitypub\Collection\Actors; use Activitypub\Embed; use function Activitypub\site_supports_blocks; @@ -22,7 +21,7 @@

' . esc_html( $args['actor']['webfinger'] ) . '' ); ?> From 0b8fd5eb792a3bfe12a03cd139a09925c4e120f6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Fri, 10 Oct 2025 17:33:48 +0200 Subject: [PATCH 12/16] Prevent processing when quoted_url is empty Adds a check to return early if $quoted_url is not set, avoiding further processing and potential errors when quoteUrl is missing in the activity object. --- includes/class-mailer.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index f2b8068a7..4c2558eb7 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -420,6 +420,10 @@ public static function quote( $activity, $user_id, $state ) { $quoted_url = object_to_uri( $activity['object']['quoteUrl'] ); } + if ( ! $quoted_url ) { + return; + } + $template_args = array( 'activity' => $activity, 'actor' => $actor, From b78c9a160663a0e25b04775112b5c122b007f90c Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 13 Oct 2025 09:51:58 -0500 Subject: [PATCH 13/16] Improve quote notification email with post references (#2310) --- includes/class-mailer.php | 16 +++++++++++---- templates/emails/new-quote.php | 37 ++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 4c2558eb7..80c456552 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -424,11 +424,19 @@ public static function quote( $activity, $user_id, $state ) { return; } + // Try to get the quoted post title. + $quoted_title = null; + $post_id = \url_to_postid( $quoted_url ); + if ( $post_id ) { + $quoted_title = \get_the_title( $post_id ); + } + $template_args = array( - 'activity' => $activity, - 'actor' => $actor, - 'user_id' => $user_id, - 'quoted_url' => $quoted_url, + 'activity' => $activity, + 'actor' => $actor, + 'user_id' => $user_id, + 'quoted_url' => $quoted_url, + 'quoted_title' => $quoted_title, ); /* translators: 1: Blog name, 2: Actor name */ diff --git a/templates/emails/new-quote.php b/templates/emails/new-quote.php index d1c6c54c5..8b4d2150d 100644 --- a/templates/emails/new-quote.php +++ b/templates/emails/new-quote.php @@ -12,6 +12,28 @@ /* @var array $args Template arguments. */ $args = wp_parse_args( $args ?? array() ); +// For QuoteRequest activities, the instrument contains the quote post URL. +$quote_object = $args['activity']['instrument'] ?? $args['activity']['object']; + +$comment_author = esc_html( $args['actor']['webfinger'] ); +if ( ! empty( $args['actor']['url'] ) ) { + $comment_author = sprintf( '%s', esc_url( $args['actor']['url'] ), esc_html( $args['actor']['webfinger'] ) ); +} + +/* translators: %s: The name of the person who quoted the post. */ +$message = __( 'Looks like one of your posts caught someone’s attention! %s just shared one of your posts.', 'activitypub' ); + +// Determine the message based on available data. +if ( ! empty( $args['quoted_url'] ) ) { + if ( ! empty( $args['quoted_title'] ) ) { + /* translators: 1: actor name/link, 2: quoted post URL, 3: quoted post title */ + $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared %3$s.', 'activitypub' ); + } else { + /* translators: 1: actor name/link, 2: quoted post URL */ + $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared your post.', 'activitypub' ); + } +} + // Load header. require __DIR__ . '/parts/header.php'; ?> @@ -20,20 +42,23 @@

' . esc_html( $args['actor']['webfinger'] ) . '' ); + printf( + wp_kses( $message, array( 'a' => array( 'href' => array() ) ) ), + wp_kses( $comment_author, array( 'a' => array( 'href' => array() ) ) ), + esc_url( $args['quoted_url'] ), + esc_html( $args['quoted_title'] ) + ); ?>

+

+ +

Date: Tue, 14 Oct 2025 09:21:02 -0500 Subject: [PATCH 14/16] Enhance quote notification with full object handling - Fetch remote quote object when only URL is available - Support both 'quote' (FEP-044f) and 'quoteUrl' properties - Simplify email template to use pre-prepared variables - Add direct link to quoted post for better user experience - Improve plaintext alternative message formatting --- includes/class-mailer.php | 33 +++++++++++++---------------- templates/emails/new-quote.php | 38 ++++++++++++++-------------------- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 80c456552..0355a4f84 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -409,26 +409,24 @@ public static function quote( $activity, $user_id, $state ) { /* * Get the quoted post/object. * For QuoteRequest activities, object is the quoted URL string. - * For regular quote posts, object.quoteUrl contains the quoted URL. + * For regular quote posts, check both 'quote' (FEP-044f) and 'quoteUrl' properties. */ - $quoted_url = null; if ( is_string( $activity['object'] ) ) { // QuoteRequest format. - $quoted_url = $activity['object']; - } elseif ( ! empty( $activity['object']['quoteUrl'] ) ) { - // Regular quote post format. - $quoted_url = object_to_uri( $activity['object']['quoteUrl'] ); + $quoted_url = $activity['object']; + $quote_object = $activity['instrument'] ?? null; + } else { + $quoted_url = object_to_uri( $activity['object']['quote'] ?? $activity['object']['quoteUrl'] ?? '' ); + $quote_object = $activity['object']; } if ( ! $quoted_url ) { return; } - // Try to get the quoted post title. - $quoted_title = null; - $post_id = \url_to_postid( $quoted_url ); - if ( $post_id ) { - $quoted_title = \get_the_title( $post_id ); + $fetched = Http::get_remote_object( $quote_object ); + if ( ! \is_wp_error( $fetched ) && is_array( $fetched ) ) { + $quote_object = $fetched; } $template_args = array( @@ -436,7 +434,8 @@ public static function quote( $activity, $user_id, $state ) { 'actor' => $actor, 'user_id' => $user_id, 'quoted_url' => $quoted_url, - 'quoted_title' => $quoted_title, + 'quoted_title' => \get_the_title( \url_to_postid( $quoted_url ) ), + 'quote_object' => $quote_object, ); /* translators: 1: Blog name, 2: Actor name */ @@ -446,15 +445,11 @@ public static function quote( $activity, $user_id, $state ) { \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-quote.php', false, $template_args ); $html_message = \ob_get_clean(); - $alt_function = function ( $mailer ) use ( $actor, $activity, $quoted_url ) { + $alt_function = function ( $mailer ) use ( $actor, $activity, $quoted_url, $quote_object ) { $content = ''; - /* - * For QuoteRequest, instrument contains the quote post URL. - * For regular quotes, object contains the quote post. - */ - $quote_object = $activity['instrument'] ?? $activity['object']; - if ( ! empty( $quote_object['content'] ) ) { + // Extract content from the quote object if available. + if ( is_array( $quote_object ) && ! empty( $quote_object['content'] ) ) { $content = \html_entity_decode( \wp_strip_all_tags( str_replace( '

', PHP_EOL . PHP_EOL, $quote_object['content'] ) diff --git a/templates/emails/new-quote.php b/templates/emails/new-quote.php index 8b4d2150d..88d86308d 100644 --- a/templates/emails/new-quote.php +++ b/templates/emails/new-quote.php @@ -7,31 +7,26 @@ use Activitypub\Embed; +use function Activitypub\object_to_uri; use function Activitypub\site_supports_blocks; /* @var array $args Template arguments. */ $args = wp_parse_args( $args ?? array() ); -// For QuoteRequest activities, the instrument contains the quote post URL. -$quote_object = $args['activity']['instrument'] ?? $args['activity']['object']; +$quote_object = $args['quote_object']; +$quote_url = object_to_uri( $quote_object ); $comment_author = esc_html( $args['actor']['webfinger'] ); if ( ! empty( $args['actor']['url'] ) ) { $comment_author = sprintf( '%s', esc_url( $args['actor']['url'] ), esc_html( $args['actor']['webfinger'] ) ); } -/* translators: %s: The name of the person who quoted the post. */ -$message = __( 'Looks like one of your posts caught someone’s attention! %s just shared one of your posts.', 'activitypub' ); - -// Determine the message based on available data. -if ( ! empty( $args['quoted_url'] ) ) { - if ( ! empty( $args['quoted_title'] ) ) { - /* translators: 1: actor name/link, 2: quoted post URL, 3: quoted post title */ - $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared %3$s.', 'activitypub' ); - } else { - /* translators: 1: actor name/link, 2: quoted post URL */ - $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared your post.', 'activitypub' ); - } +if ( ! empty( $args['quoted_title'] ) ) { + /* translators: 1: actor name/link, 2: quoted post URL, 3: quoted post title */ + $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared %3$s.', 'activitypub' ); +} else { + /* translators: 1: actor name/link, 2: quoted post URL */ + $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared your post.', 'activitypub' ); } // Load header. @@ -65,20 +60,17 @@ echo Embed::get_html_for_object( $quote_object ); ?>
+ +

-

- + -

- -

- - -

- + + +

Date: Tue, 14 Oct 2025 09:36:15 -0500 Subject: [PATCH 15/16] Assume we always have a post to embed --- includes/class-mailer.php | 2 +- templates/emails/new-quote.php | 42 +++++++++++----------------------- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/includes/class-mailer.php b/includes/class-mailer.php index 0355a4f84..b6d57c278 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -410,7 +410,7 @@ public static function quote( $activity, $user_id, $state ) { * Get the quoted post/object. * For QuoteRequest activities, object is the quoted URL string. * For regular quote posts, check both 'quote' (FEP-044f) and 'quoteUrl' properties. - */ + */ if ( is_string( $activity['object'] ) ) { // QuoteRequest format. $quoted_url = $activity['object']; diff --git a/templates/emails/new-quote.php b/templates/emails/new-quote.php index 88d86308d..711a2b4f1 100644 --- a/templates/emails/new-quote.php +++ b/templates/emails/new-quote.php @@ -7,26 +7,21 @@ use Activitypub\Embed; -use function Activitypub\object_to_uri; use function Activitypub\site_supports_blocks; /* @var array $args Template arguments. */ $args = wp_parse_args( $args ?? array() ); -$quote_object = $args['quote_object']; -$quote_url = object_to_uri( $quote_object ); - $comment_author = esc_html( $args['actor']['webfinger'] ); if ( ! empty( $args['actor']['url'] ) ) { $comment_author = sprintf( '%s', esc_url( $args['actor']['url'] ), esc_html( $args['actor']['webfinger'] ) ); } +/* translators: 1: actor name/link, 2: quoted post URL */ +$message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared your post. Here’s what they said:', 'activitypub' ); if ( ! empty( $args['quoted_title'] ) ) { /* translators: 1: actor name/link, 2: quoted post URL, 3: quoted post title */ - $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared %3$s.', 'activitypub' ); -} else { - /* translators: 1: actor name/link, 2: quoted post URL */ - $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared your post.', 'activitypub' ); + $message = __( 'Looks like one of your posts caught someone’s attention! %1$s just shared %3$s. Here’s what they said:', 'activitypub' ); } // Load header. @@ -46,31 +41,20 @@ ?>

- + -

- -

-
- -
- + -

- - + +

+ - - - -

+

+ Date: Tue, 14 Oct 2025 09:51:47 -0500 Subject: [PATCH 16/16] Update quote notification tests to properly mock remote object fetching - Use activitypub_pre_http_get_remote_object filter for mocking - Template expects quote_object to always be an array from mailer - All 1116 tests pass successfully --- .../tests/includes/class-test-mailer.php | 31 +++++++++++++++++-- .../handler/class-test-quote-request.php | 18 +++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/includes/class-test-mailer.php b/tests/phpunit/tests/includes/class-test-mailer.php index 1bdaaf472..3263f5ed0 100644 --- a/tests/phpunit/tests/includes/class-test-mailer.php +++ b/tests/phpunit/tests/includes/class-test-mailer.php @@ -774,13 +774,25 @@ function () { } ); + // Mock HTTP request to fetch quote object. + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url ) use ( $activity ) { + if ( 'https://example.com/post/1' === $url || $url === $activity['object'] ) { + return $activity['object']; + } + return $preempt; + }, + 10, + 2 + ); + // Capture email. add_filter( 'wp_mail', function ( $args ) { $this->assertStringContainsString( 'Test Quoter', $args['subject'] ); $this->assertStringContainsString( 'Quote from', $args['subject'] ); - $this->assertStringContainsString( 'https://example.com/post/1', $args['message'] ); $this->assertStringContainsString( 'Great article', $args['message'] ); $this->assertEquals( get_user_by( 'id', self::$user_id )->user_email, $args['to'] ); return $args; @@ -791,6 +803,7 @@ function ( $args ) { // Clean up. remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); remove_all_filters( 'wp_mail' ); } @@ -891,12 +904,25 @@ function () { } ); + // Mock HTTP request to fetch quote object. + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url ) use ( $activity ) { + if ( 'https://example.com/post/1' === $url || $url === $activity['object'] ) { + return $activity['object']; + } + return $preempt; + }, + 10, + 2 + ); + // Capture email. add_filter( 'wp_mail', function ( $args ) { $this->assertStringContainsString( 'Test Quoter', $args['subject'] ); - $this->assertStringContainsString( 'https://example.com/post/1', $args['message'] ); + $this->assertStringContainsString( 'Great blog post!', $args['message'] ); $this->assertEquals( get_option( 'admin_email' ), $args['to'] ); return $args; } @@ -906,6 +932,7 @@ function ( $args ) { // Clean up. remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); remove_all_filters( 'wp_mail' ); delete_option( 'activitypub_blog_user_mailer_new_quote' ); delete_option( 'activitypub_actor_mode' ); diff --git a/tests/phpunit/tests/includes/handler/class-test-quote-request.php b/tests/phpunit/tests/includes/handler/class-test-quote-request.php index 3e7dd3ec3..c9454f58a 100644 --- a/tests/phpunit/tests/includes/handler/class-test-quote-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-quote-request.php @@ -160,6 +160,23 @@ function () use ( $actor_url ) { } ); + // Mock HTTP request to fetch quote object (instrument). + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url ) use ( $activity ) { + if ( $activity['instrument'] === $url ) { + return array( + 'id' => $activity['instrument'], + 'type' => 'Note', + 'content' => '

Great post! I have some thoughts.

', + ); + } + return $preempt; + }, + 10, + 2 + ); + $remote_actor_id = false; // Run setup callback if provided. @@ -473,6 +490,7 @@ public function test_init_registers_hooks() { public function tear_down() { // Remove all the filters we added during tests. remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); parent::tear_down(); }