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. 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 16f079537..b6d57c278 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -23,7 +23,8 @@ public static function init() { \add_action( 'activitypub_handled_follow', array( self::class, 'new_follower' ), 10, 3 ); \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 ); } /** @@ -367,6 +368,124 @@ 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, check both 'quote' (FEP-044f) and 'quoteUrl' properties. + */ + if ( is_string( $activity['object'] ) ) { + // QuoteRequest format. + $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; + } + + $fetched = Http::get_remote_object( $quote_object ); + if ( ! \is_wp_error( $fetched ) && is_array( $fetched ) ) { + $quote_object = $fetched; + } + + $template_args = array( + 'activity' => $activity, + 'actor' => $actor, + 'user_id' => $user_id, + 'quoted_url' => $quoted_url, + 'quoted_title' => \get_the_title( \url_to_postid( $quoted_url ) ), + 'quote_object' => $quote_object, + ); + + /* 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, $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'] ) + ), + 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 = $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() { ++ +
%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. Here’s what they said:', 'activitypub' ); +} + +// Load header. +require __DIR__ . '/parts/header.php'; +?> + + + ++ array( 'href' => array() ) ) ), + wp_kses( $comment_author, array( 'a' => array( 'href' => array() ) ) ), + esc_url( $args['quoted_url'] ), + esc_html( $args['quoted_title'] ) + ); + ?> +
+ + + + ++ + + +
+ + + 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,265 @@ 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 ::quote + */ + 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', + ); + } + ); + + // 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( '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( 'activitypub_pre_http_get_remote_object' ); + remove_all_filters( 'wp_mail' ); + } + + /** + * Test quote notification when state is false. + * + * @covers ::quote + */ + 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 ::quote + */ + 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 ::quote + */ + 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', + ); + } + ); + + // 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( 'Great blog post!', $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( 'activitypub_pre_http_get_remote_object' ); + 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 ::quote + */ + 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 ::quote + */ + 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..c9454f58a 100644 --- a/tests/phpunit/tests/includes/handler/class-test-quote-request.php +++ b/tests/phpunit/tests/includes/handler/class-test-quote-request.php @@ -151,14 +151,32 @@ 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 ), ); } ); + // 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. @@ -472,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(); }