diff --git a/CHANGELOG.md b/CHANGELOG.md index ed099414d..fab2e82b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `icon` support for `Audio` and `Video` attachments * Send "new follower" emails +* Send "direct message" emails ### Improved diff --git a/activitypub.php b/activitypub.php index 9ce69d314..6948febeb 100644 --- a/activitypub.php +++ b/activitypub.php @@ -72,6 +72,7 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Link', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Mailer', 'init' ) ); if ( site_supports_blocks() ) { \add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) ); diff --git a/includes/class-mailer.php b/includes/class-mailer.php index ab551d22d..8c32cd16e 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -8,37 +8,41 @@ namespace Activitypub; use Activitypub\Collection\Actors; + /** - * Mailer Class + * Mailer Class. */ class Mailer { /** * Initialize the Mailer. */ public static function init() { - add_filter( 'comment_notification_subject', array( self::class, 'comment_notification_subject' ), 10, 2 ); - add_filter( 'comment_notification_text', array( self::class, 'comment_notification_text' ), 10, 2 ); + \add_filter( 'comment_notification_subject', array( self::class, 'comment_notification_subject' ), 10, 2 ); + \add_filter( 'comment_notification_text', array( self::class, 'comment_notification_text' ), 10, 2 ); // New follower notification. - add_action( 'activitypub_notification_follow', array( self::class, 'new_follower' ) ); + \add_action( 'activitypub_notification_follow', array( self::class, 'new_follower' ) ); + + // Direct message notification. + \add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 ); } /** - * Filter the mail-subject for Like and Announce notifications. + * Filter the subject line for Like and Announce notifications. * - * @param string $subject The default mail-subject. + * @param string $subject The default subject line. * @param int|string $comment_id The comment ID. * - * @return string The filtered mail-subject + * @return string The filtered subject line. */ public static function comment_notification_subject( $subject, $comment_id ) { - $comment = get_comment( $comment_id ); + $comment = \get_comment( $comment_id ); if ( ! $comment ) { return $subject; } - $type = get_comment_meta( $comment->comment_ID, 'protocol', true ); + $type = \get_comment_meta( $comment->comment_ID, 'protocol', true ); if ( 'activitypub' !== $type ) { return $subject; @@ -50,28 +54,28 @@ public static function comment_notification_subject( $subject, $comment_id ) { return $subject; } - $post = get_post( $comment->comment_post_ID ); + $post = \get_post( $comment->comment_post_ID ); - /* translators: %1$s: Blog name, %2$s: Post title */ - return sprintf( __( '[%1$s] %2$s: %3$s', 'activitypub' ), get_option( 'blogname' ), $singular, $post->post_title ); + /* translators: 1: Blog name, 2: Like or Repost, 3: Post title */ + return \sprintf( \esc_html__( '[%1$s] %2$s: %3$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $singular ), \esc_html( $post->post_title ) ); } /** - * Filter the mail-content for Like and Announce notifications. + * Filter the notification text for Like and Announce notifications. * - * @param string $message The default mail-content. + * @param string $message The default notification text. * @param int|string $comment_id The comment ID. * - * @return string The filtered mail-content + * @return string The filtered notification text. */ public static function comment_notification_text( $message, $comment_id ) { - $comment = get_comment( $comment_id ); + $comment = \get_comment( $comment_id ); if ( ! $comment ) { return $message; } - $type = get_comment_meta( $comment->comment_ID, 'protocol', true ); + $type = \get_comment_meta( $comment->comment_ID, 'protocol', true ); if ( 'activitypub' !== $type ) { return $message; @@ -83,24 +87,24 @@ public static function comment_notification_text( $message, $comment_id ) { return $message; } - $post = get_post( $comment->comment_post_ID ); - $comment_author_domain = gethostbyaddr( $comment->comment_author_IP ); + $post = \get_post( $comment->comment_post_ID ); + $comment_author_domain = \gethostbyaddr( $comment->comment_author_IP ); - /* translators: %1$s: Comment type, %2$s: Post title */ - $notify_message = \sprintf( __( 'New %1$s on your post "%2$s"', 'activitypub' ), $comment_type['singular'], $post->post_title ) . "\r\n"; - /* translators: 1: Trackback/pingback website name, 2: Website IP address, 3: Website hostname. */ - $notify_message .= \sprintf( __( 'Website: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), $comment->comment_author, $comment->comment_author_IP, $comment_author_domain ) . "\r\n"; - /* translators: %s: Trackback/pingback/comment author URL. */ - $notify_message .= \sprintf( __( 'URL: %s', 'activitypub' ), $comment->comment_author_url ) . "\r\n\r\n"; - /* translators: %s: Comment type label */ - $notify_message .= \sprintf( __( 'You can see all %s on this post here:', 'activitypub' ), $comment_type['label'] ) . "\r\n"; - $notify_message .= \get_permalink( $comment->comment_post_ID ) . '#' . $comment_type['singular'] . "\r\n\r\n"; + /* translators: 1: Comment type, 2: Post title */ + $notify_message = \sprintf( html_entity_decode( esc_html__( 'New %1$s on your post “%2$s”.', 'activitypub' ) ), \esc_html( $comment_type['singular'] ), \esc_html( $post->post_title ) ) . "\r\n\r\n"; + /* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */ + $notify_message .= \sprintf( \esc_html__( 'From: %1$s (IP address: %2$s, %3$s)', 'activitypub' ), \esc_html( $comment->comment_author ), \esc_html( $comment->comment_author_IP ), \esc_html( $comment_author_domain ) ) . "\r\n"; + /* translators: Reaction author URL. */ + $notify_message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $comment->comment_author_url ) ) . "\r\n\r\n"; + /* translators: Comment type label */ + $notify_message .= \sprintf( \esc_html__( 'You can see all %s on this post here:', 'activitypub' ), \esc_html( $comment_type['label'] ) ) . "\r\n"; + $notify_message .= \get_permalink( $comment->comment_post_ID ) . '#' . \esc_attr( $comment_type['type'] ) . "\r\n\r\n"; return $notify_message; } /** - * Send a Mail for every new follower. + * Send a notification email for every new follower. * * @param Notification $notification The notification object. */ @@ -111,26 +115,73 @@ public static function new_follower( $notification ) { return; } - $email = \get_option( 'admin_email' ); + $email = \get_option( 'admin_email' ); + $admin_url = '/options-general.php?page=activitypub&tab=followers'; - if ( (int) $notification->target > Actors::BLOG_USER_ID ) { + if ( $notification->target > Actors::BLOG_USER_ID ) { $user = \get_user_by( 'id', $notification->target ); if ( ! $user ) { return; } + $email = $user->user_email; + $admin_url = '/users.php?page=activitypub-followers-list'; + } + + /* translators: 1: Blog name, 2: Follower name */ + $subject = \sprintf( \esc_html__( '[%1$s] Follower: %2$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $actor['name'] ) ); + /* translators: 1: Blog name, 2: Follower name */ + $message = \sprintf( \esc_html__( 'New Follower: %2$s.', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $actor['name'] ) ) . "\r\n\r\n"; + /* translators: Follower URL */ + $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $actor['url'] ) ) . "\r\n\r\n"; + $message .= \esc_html__( 'You can see all followers here:', 'activitypub' ) . "\r\n"; + $message .= \esc_url( \admin_url( $admin_url ) ) . "\r\n\r\n"; + + \wp_mail( $email, $subject, $message ); + } + + /** + * Send a direct message. + * + * @param array $activity The activity object. + * @param int $user_id The id of the local blog-user. + */ + public static function direct_message( $activity, $user_id ) { + // Check if Activity is public or not. + if ( + is_activity_public( $activity ) && + is_activity_reply( $activity ) + ) { + return; + } + + $actor = get_remote_metadata_by_actor( $activity['actor'] ); + + if ( ! $actor || \is_wp_error( $actor ) || empty( $activity['object']['content'] ) ) { + return; + } + + $email = \get_option( 'admin_email' ); + + if ( (int) $user_id > Actors::BLOG_USER_ID ) { + $user = \get_user_by( 'id', $user_id ); + + if ( ! $user ) { + return; + } + $email = $user->user_email; } - /* translators: %1$s: Blog name, %2$s: Follower name */ - $subject = \sprintf( \__( '[%1$s] Follower: %2$s', 'activitypub' ), get_option( 'blogname' ), $actor['name'] ); - /* translators: %1$s: Blog name, %2$s: Follower name */ - $message = \sprintf( \__( 'New follower: %2$s', 'activitypub' ), get_option( 'blogname' ), $actor['name'] ) . "\r\n"; - /* translators: %s: Follower URL */ - $message .= \sprintf( \__( 'URL: %s', 'activitypub' ), $actor['url'] ) . "\r\n\r\n"; - $message .= \sprintf( \__( 'You can see all followers here:', 'activitypub' ) ) . "\r\n"; - $message .= \esc_url( \admin_url( '/users.php?page=activitypub-followers-list' ) ) . "\r\n\r\n"; + /* translators: 1: Blog name, 2 Actor name */ + $subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \esc_html( $actor['name'] ) ); + /* translators: 1: Blog name, 2: Actor name */ + $message = \sprintf( \esc_html__( 'New Direct Message: %2$s', 'activitypub' ), \esc_html( get_option( 'blogname' ) ), \wp_strip_all_tags( $activity['object']['content'] ) ) . "\r\n\r\n"; + /* translators: Actor name */ + $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n"; + /* translators: Actor URL */ + $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $actor['url'] ) ) . "\r\n\r\n"; \wp_mail( $email, $subject, $message ); } diff --git a/includes/class-notification.php b/includes/class-notification.php index 1dd657325..05d32e358 100644 --- a/includes/class-notification.php +++ b/includes/class-notification.php @@ -60,7 +60,18 @@ public function __construct( $type, $actor, $activity, $target ) { public function send() { $type = \strtolower( $this->type ); + /** + * Action to send ActivityPub notifications. + * + * @param Notification $this The notification object. + */ do_action( 'activitypub_notification', $this ); + + /** + * Type-specific action to send ActivityPub notifications. + * + * @param Notification $this The notification object. + */ do_action( "activitypub_notification_{$type}", $this ); } } diff --git a/includes/functions.php b/includes/functions.php index d16a30bf7..b6cc9aa83 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -689,6 +689,17 @@ function is_activity_public( $data ) { return in_array( 'https://www.w3.org/ns/activitystreams#Public', $recipients, true ); } +/** + * Check if passed Activity is a reply. + * + * @param array $data The Activity object as array. + * + * @return boolean True if a reply, false if not. + */ +function is_activity_reply( $data ) { + return ! empty( $data['object']['inReplyTo'] ); +} + /** * Get active users based on a given duration. * diff --git a/readme.txt b/readme.txt index ea0b95adb..b91787322 100644 --- a/readme.txt +++ b/readme.txt @@ -136,6 +136,7 @@ For reasons of data protection, it is not possible to see the followers of other * Added: `icon` support for `Audio` and `Video` attachments * Added: Send "new follower" emails +* Added: Send "direct message" emails * Improved: Email templates for Likes and Reposts * Improved: Interactions moderation * Improved: Compatibility with Akismet diff --git a/tests/includes/class-test-mailer.php b/tests/includes/class-test-mailer.php index afd98561f..37548000e 100644 --- a/tests/includes/class-test-mailer.php +++ b/tests/includes/class-test-mailer.php @@ -209,4 +209,73 @@ public function test_init() { $this->assertEquals( 10, has_filter( 'comment_notification_text', array( Mailer::class, 'comment_notification_text' ) ) ); $this->assertEquals( 10, has_action( 'activitypub_notification_follow', array( Mailer::class, 'new_follower' ) ) ); } + + /** + * Test direct message notification. + * + * @covers ::direct_message + */ + public function test_direct_message() { + $user_id = self::$user_id; + + $activity = array( + 'actor' => 'https://example.com/author', + 'object' => array( + 'content' => 'Test direct message', + ), + ); + + // Mock remote metadata. + add_filter( + 'pre_get_remote_metadata_by_actor', + function () { + return array( + 'name' => 'Test Sender', + 'url' => 'https://example.com/author', + ); + } + ); + + // Capture email. + add_filter( + 'wp_mail', + function ( $args ) use ( $user_id ) { + $this->assertStringContainsString( 'Direct Message', $args['subject'] ); + $this->assertStringContainsString( 'Test Sender', $args['subject'] ); + $this->assertStringContainsString( 'Test direct message', $args['message'] ); + $this->assertStringContainsString( 'https://example.com/author', $args['message'] ); + $this->assertEquals( get_user_by( 'id', $user_id )->user_email, $args['to'] ); + return $args; + } + ); + + Mailer::direct_message( $activity, $user_id ); + + // Test public activity (should not send email). + $public_activity = array( + 'actor' => 'https://example.com/author', + 'object' => array( + 'content' => 'Test public message', + 'inReplyTo' => 'https://example.com/post/1', + ), + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + ); + + // Reset email capture. + remove_all_filters( 'wp_mail' ); + add_filter( + 'wp_mail', + function ( $args ) { + $this->fail( 'Email should not be sent for public activity' ); + return $args; + } + ); + + Mailer::direct_message( $public_activity, $user_id ); + + // Clean up. + remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + remove_all_filters( 'wp_mail' ); + wp_delete_user( $user_id ); + } }