PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-mailer.php

class-mailer.php in ActivityPub trunk, at includes/class-mailer.php

594 lines 19.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Mailer Class.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 use Activitypub\Collection\Actors;
11 use Activitypub\Comment;
12
13 /**
14 * Mailer Class.
15 */
16 class Mailer {
17 /**
18 * Initialize the Mailer.
19 */
20 public static function init() {
21 \add_filter( 'comment_notification_subject', array( self::class, 'comment_notification_subject' ), 10, 2 );
22 \add_filter( 'comment_notification_text', array( self::class, 'comment_notification_text' ), 10, 2 );
23
24 \add_action( 'activitypub_handled_follow', array( self::class, 'new_follower' ), 10, 3 );
25
26 \add_action( 'activitypub_inbox_create', array( self::class, 'direct_message' ), 10, 2 );
27 \add_action( 'activitypub_inbox_create', array( self::class, 'mention' ), 20, 2 ); /** After @see \Activitypub\Handler\Create::handle_create() */
28
29 \add_filter( 'notify_post_author', array( self::class, 'maybe_prevent_comment_notification' ), 10, 2 );
30 \add_filter( 'notify_post_author', array( self::class, 'maybe_prevent_reaction_notification' ), 10, 2 );
31 \add_filter( 'notify_moderator', array( self::class, 'maybe_prevent_comment_notification' ), 10, 2 );
32 }
33
34 /**
35 * Filter the subject line for Like and Announce notifications.
36 *
37 * @param string $subject The default subject line.
38 * @param int|string $comment_id The comment ID.
39 *
40 * @return string The filtered subject line.
41 */
42 public static function comment_notification_subject( $subject, $comment_id ) {
43 $comment = \get_comment( $comment_id );
44
45 if ( ! $comment ) {
46 return $subject;
47 }
48
49 $type = \get_comment_meta( $comment->comment_ID, 'protocol', true );
50
51 if ( 'activitypub' !== $type ) {
52 return $subject;
53 }
54
55 $singular = Comment::get_comment_type_attr( $comment->comment_type, 'singular' );
56
57 if ( ! $singular ) {
58 return $subject;
59 }
60
61 $post = \get_post( $comment->comment_post_ID );
62
63 /* translators: 1: Blog name, 2: Like or Repost, 3: Post title */
64 return \sprintf( \esc_html__( '[%1$s] %2$s: %3$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $singular ), \esc_html( $post->post_title ) );
65 }
66
67 /**
68 * Filter the notification text for Like and Announce notifications.
69 *
70 * @param string $message The default notification text.
71 * @param int|string $comment_id The comment ID.
72 *
73 * @return string The filtered notification text.
74 */
75 public static function comment_notification_text( $message, $comment_id ) {
76 $comment = \get_comment( $comment_id );
77
78 if ( ! $comment ) {
79 return $message;
80 }
81
82 $type = \get_comment_meta( $comment->comment_ID, 'protocol', true );
83
84 if ( 'activitypub' !== $type ) {
85 return $message;
86 }
87
88 $comment_type = Comment::get_comment_type( $comment->comment_type );
89
90 if ( ! $comment_type ) {
91 return $message;
92 }
93
94 $post = \get_post( $comment->comment_post_ID );
95 $comment_author_domain = '';
96
97 // Only attempt to resolve hostname if we have a valid IP address.
98 if ( \filter_var( $comment->comment_author_IP, FILTER_VALIDATE_IP ) ) {
99 $comment_author_domain = \gethostbyaddr( $comment->comment_author_IP );
100 }
101
102 // Check if this is a reaction to a post or a comment.
103 if ( 0 === (int) $comment->comment_parent ) {
104 $notify_message = \sprintf(
105 /* translators: 1: Comment type, 2: Post title */
106 \html_entity_decode( \esc_html__( 'New %1$s on your post &#8220;%2$s&#8221;.', 'activitypub' ) ),
107 \esc_html( $comment_type['singular'] ),
108 \esc_html( $post->post_title )
109 ) . PHP_EOL . PHP_EOL;
110
111 } else {
112 $parent_comment = \get_comment( $comment->comment_parent );
113 $notify_message = \sprintf(
114 /* translators: 1: Comment type, 2: Post title, 3: Parent comment author */
115 \html_entity_decode( \esc_html__( 'New %1$s on your post &#8220;%2$s&#8221; in reply to %3$s&#8217;s comment.', 'activitypub' ) ),
116 \esc_html( $comment_type['singular'] ),
117 \esc_html( $post->post_title ),
118 \esc_html( $parent_comment->comment_author )
119 ) . PHP_EOL . PHP_EOL;
120 }
121
122 /* translators: 1: Website name, 2: Website IP address, 3: Website hostname. */
123 $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";
124 /* translators: Reaction author URL. */
125 $notify_message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $comment->comment_author_url ) ) . "\r\n";
126
127 // For quotes, link to the quoting post itself so the author can review and respond.
128 if ( 'quote' === $comment->comment_type ) {
129 $quote_url = Comment::get_source_url( $comment->comment_ID );
130
131 if ( $quote_url ) {
132 /* translators: Quoting post URL. */
133 $notify_message .= \sprintf( \esc_html__( 'Quoting post: %s', 'activitypub' ), \esc_url( $quote_url ) ) . "\r\n";
134 }
135 }
136
137 $notify_message .= "\r\n";
138 /* translators: Comment type label */
139 $notify_message .= \sprintf( \esc_html__( 'You can see all %s on this post here:', 'activitypub' ), \esc_html( $comment_type['label'] ) ) . "\r\n";
140 $notify_message .= \get_permalink( $comment->comment_post_ID ) . '#' . \esc_attr( $comment_type['type'] ) . "\r\n\r\n";
141
142 return $notify_message;
143 }
144
145 /**
146 * Send a notification email for every new follower.
147 *
148 * @param array $activity The activity object.
149 * @param int|int[] $user_ids The id(s) of the local blog-user(s).
150 * @param bool $success True on success, false otherwise.
151 */
152 public static function new_follower( $activity, $user_ids, $success ) {
153 // Only send notification if the follow was successful.
154 if ( ! $success ) {
155 return;
156 }
157
158 // Extract the user ID (follows are always for a single user).
159 $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
160
161 if ( $user_id > Actors::BLOG_USER_ID ) {
162 if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) {
163 return;
164 }
165
166 $email = \get_userdata( $user_id )->user_email;
167 $admin_url = '/users.php?page=activitypub-followers-list';
168 } else {
169 if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_follower', '1' ) ) {
170 return;
171 }
172
173 $email = \get_option( 'admin_email' );
174 $admin_url = '/options-general.php?page=activitypub&tab=followers';
175 }
176
177 $actor = get_remote_metadata_by_actor( $activity['actor'] );
178 if ( ! $actor || \is_wp_error( $actor ) ) {
179 return;
180 }
181
182 $actor = self::normalize_actor( $actor );
183
184 // Replace emoji in actor name and summary.
185 if ( ! empty( $actor['name'] ) ) {
186 $actor['name'] = Emoji::replace_for_actor( $actor['name'], $actor['url'] );
187 }
188 if ( ! empty( $actor['summary'] ) ) {
189 $actor['summary'] = Emoji::replace_for_actor( $actor['summary'], $actor['url'] );
190 }
191
192 $template_args = \array_merge(
193 $actor,
194 array(
195 'admin_url' => $admin_url,
196 'user_id' => $user_id,
197 'stats' => array(
198 'outbox' => null,
199 'followers' => null,
200 'following' => null,
201 ),
202 )
203 );
204
205 foreach ( $template_args['stats'] as $field => $value ) {
206 if ( empty( $actor[ $field ] ) ) {
207 continue;
208 }
209
210 $result = Http::get( $actor[ $field ], array(), true );
211 if ( 200 === \wp_remote_retrieve_response_code( $result ) ) {
212 $body = \json_decode( \wp_remote_retrieve_body( $result ), true );
213 if ( isset( $body['totalItems'] ) ) {
214 $template_args['stats'][ $field ] = $body['totalItems'];
215 }
216 }
217 }
218
219 /* translators: 1: Blog name, 2: Follower name */
220 $subject = \sprintf( \__( '[%1$s] New Follower: %2$s', 'activitypub' ), \get_option( 'blogname' ), $actor['name'] );
221
222 \ob_start();
223 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-follower.php', false, $template_args );
224 $html_message = \ob_get_clean();
225
226 $alt_function = static function ( $mailer ) use ( $actor, $admin_url ) {
227 /* translators: 1: Follower name */
228 $message = \sprintf( \__( 'New Follower: %1$s.', 'activitypub' ), $actor['name'] ) . "\r\n\r\n";
229 /* translators: Follower URL */
230 $message .= \sprintf( \__( 'URL: %s', 'activitypub' ), \esc_url( $actor['url'] ) ) . "\r\n\r\n";
231 $message .= \__( 'You can see all followers here:', 'activitypub' ) . "\r\n";
232 $message .= \esc_url( \admin_url( $admin_url ) ) . "\r\n\r\n";
233 $mailer->{'AltBody'} = $message;
234 };
235 \add_action( 'phpmailer_init', $alt_function );
236
237 \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
238
239 \remove_action( 'phpmailer_init', $alt_function );
240 }
241
242 /**
243 * Send a direct message.
244 *
245 * @param array $activity The activity object.
246 * @param int|int[] $user_ids The id(s) of the local blog-user(s).
247 */
248 public static function direct_message( $activity, $user_ids ) {
249 // Early return if activity is public or has no recipients.
250 if ( is_activity_public( $activity ) || empty( $activity['to'] ) ) {
251 return;
252 }
253
254 // Normalize to array.
255 $user_ids = (array) $user_ids;
256
257 // Build a map of user_id => actor_id and filter to only users in the "to" field.
258 $recipients = array();
259 foreach ( $user_ids as $user_id ) {
260 $actor = Actors::get_by_id( $user_id );
261 if ( \is_wp_error( $actor ) ) {
262 continue;
263 }
264
265 $actor_id = $actor->get_id();
266 if ( \in_array( $actor_id, (array) $activity['to'], true ) ) {
267 $recipients[ $user_id ] = $actor_id;
268 }
269 }
270
271 // No matching recipients.
272 if ( empty( $recipients ) ) {
273 return;
274 }
275
276 // Get actor metadata once (shared for all emails).
277 $actor = get_remote_metadata_by_actor( $activity['actor'] );
278 if ( ! $actor || \is_wp_error( $actor ) || empty( $activity['object']['content'] ) ) {
279 return;
280 }
281
282 $actor = self::normalize_actor( $actor );
283
284 // Send email to each recipient.
285 foreach ( $recipients as $user_id => $actor_id ) {
286 // Check user preferences.
287 if ( $user_id > Actors::BLOG_USER_ID ) {
288 if ( ! \get_user_option( 'activitypub_mailer_new_dm', $user_id ) ) {
289 continue;
290 }
291
292 $email = \get_userdata( $user_id )->user_email;
293 } else {
294 if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_dm', '1' ) ) {
295 continue;
296 }
297
298 $email = \get_option( 'admin_email' );
299 }
300
301 $template_args = array(
302 'activity' => $activity,
303 'actor' => $actor,
304 'user_id' => $user_id,
305 );
306
307 /* translators: 1: Blog name, 2 Actor name */
308 $subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
309
310 \ob_start();
311 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-dm.php', false, $template_args );
312 $html_message = \ob_get_clean();
313
314 $alt_function = static function ( $mailer ) use ( $actor, $activity ) {
315 $content = \html_entity_decode(
316 \wp_strip_all_tags(
317 \str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
318 ),
319 ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
320 );
321
322 /* translators: Actor name */
323 $message = \sprintf( \esc_html__( 'New Direct Message: %s', 'activitypub' ), $content ) . "\r\n\r\n";
324 /* translators: Actor name */
325 $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
326 /* translators: Message URL */
327 $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
328
329 $mailer->{'AltBody'} = $message;
330 };
331 \add_action( 'phpmailer_init', $alt_function );
332
333 \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
334
335 \remove_action( 'phpmailer_init', $alt_function );
336 }
337 }
338
339 /**
340 * Send a mention notification.
341 *
342 * @param array $activity The activity object.
343 * @param int|int[] $user_ids The id(s) of the local blog-user(s).
344 */
345 public static function mention( $activity, $user_ids ) {
346 // Early return if activity has no mentions.
347 if ( empty( $activity['object']['tag'] ) ) {
348 return;
349 }
350
351 // Do not send a mention notification if the activity is a reply to a local post or comment.
352 if ( is_activity_reply( $activity ) && object_id_to_comment( $activity['object']['id'] ) ) {
353 return;
354 }
355
356 $recipients = array();
357 $mentions = \wp_list_filter( (array) $activity['object']['tag'], array( 'type' => 'Mention' ) );
358 $mentions = \array_map( '\Activitypub\object_to_uri', $mentions );
359 foreach ( (array) $user_ids as $user_id ) {
360 $actor = Actors::get_by_id( $user_id );
361 if ( \is_wp_error( $actor ) ) {
362 continue;
363 }
364
365 $actor_id = $actor->get_id();
366 if ( \in_array( $actor_id, $mentions, true ) ) {
367 $recipients[ $user_id ] = $actor_id;
368 }
369 }
370
371 // No matching recipients.
372 if ( empty( $recipients ) ) {
373 return;
374 }
375
376 // Get actor metadata once (shared for all emails).
377 $actor = get_remote_metadata_by_actor( $activity['actor'] );
378 if ( \is_wp_error( $actor ) ) {
379 return;
380 }
381
382 $actor = self::normalize_actor( $actor );
383
384 // Send email to each recipient.
385 foreach ( $recipients as $user_id => $actor_id ) {
386 // Check user preferences.
387 if ( $user_id > Actors::BLOG_USER_ID ) {
388 if ( ! \get_user_option( 'activitypub_mailer_new_mention', $user_id ) ) {
389 continue;
390 }
391
392 $email = \get_userdata( $user_id )->user_email;
393 } else {
394 if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_mention', '1' ) ) {
395 continue;
396 }
397
398 $email = \get_option( 'admin_email' );
399 }
400
401 $template_args = array(
402 'activity' => $activity,
403 'actor' => $actor,
404 'user_id' => $user_id,
405 );
406
407 /* translators: 1: Blog name, 2 Actor name */
408 $subject = \sprintf( \esc_html__( '[%1$s] Mention from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
409
410 \ob_start();
411 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-mention.php', false, $template_args );
412 $html_message = \ob_get_clean();
413
414 $alt_function = static function ( $mailer ) use ( $actor, $activity ) {
415 $content = \html_entity_decode(
416 \wp_strip_all_tags(
417 \str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
418 ),
419 ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
420 );
421
422 /* translators: Message content */
423 $message = \sprintf( \esc_html__( 'New Mention: %s', 'activitypub' ), $content ) . "\r\n\r\n";
424 /* translators: Actor name */
425 $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
426 /* translators: Message URL */
427 $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
428
429 $mailer->{'AltBody'} = $message;
430 };
431 \add_action( 'phpmailer_init', $alt_function );
432
433 \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
434
435 \remove_action( 'phpmailer_init', $alt_function );
436 }
437 }
438
439 /**
440 * Send a templated email to a user.
441 *
442 * @param int $user_id The user ID (or BLOG_USER_ID for blog actor).
443 * @param string $subject The email subject.
444 * @param string $template The template name (without path/extension).
445 * @param array $args Template arguments.
446 * @param string $alt_body Optional plain text alternative. Auto-generated from HTML if empty.
447 *
448 * @return bool True if email was sent, false otherwise.
449 */
450 public static function send( $user_id, $subject, $template, $args = array(), $alt_body = '' ) {
451 // Get the recipient email address.
452 if ( $user_id > Actors::BLOG_USER_ID ) {
453 $user = \get_userdata( $user_id );
454 if ( ! $user || empty( $user->user_email ) ) {
455 return false;
456 }
457 $email = $user->user_email;
458 } else {
459 $email = \get_option( 'admin_email' );
460 }
461
462 // Load the HTML template.
463 $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . \sanitize_file_name( $template ) . '.php';
464
465 /**
466 * Filter the email template file path.
467 *
468 * @param string $template_file The template file path.
469 * @param string $template The template name.
470 * @param int $user_id The user ID.
471 * @param array $args Template arguments.
472 */
473 $template_file = \apply_filters( 'activitypub_email_template', $template_file, $template, $user_id, $args );
474
475 if ( ! \file_exists( $template_file ) ) {
476 return false;
477 }
478
479 \ob_start();
480 \load_template( $template_file, false, $args );
481 $html_message = \ob_get_clean();
482
483 // Build plain text alternative from HTML if not provided.
484 if ( empty( $alt_body ) ) {
485 $alt_body = \wp_strip_all_tags( $html_message );
486 }
487 $alt_function = static function ( $mailer ) use ( $alt_body ) {
488 $mailer->{'AltBody'} = $alt_body;
489 };
490 \add_action( 'phpmailer_init', $alt_function );
491
492 $result = \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
493
494 \remove_action( 'phpmailer_init', $alt_function );
495
496 return $result;
497 }
498
499 /**
500 * Apply defaults to the actor object.
501 *
502 * Ensure that the actor object has a name, url, and webfinger.
503 *
504 * @param array $actor The actor object.
505 *
506 * @return array The inflated actor object.
507 */
508 private static function normalize_actor( $actor ) {
509 if ( empty( $actor['name'] ) ) {
510 $actor['name'] = $actor['preferredUsername'];
511 }
512
513 if ( empty( $actor['url'] ) ) {
514 $actor['url'] = $actor['id'];
515 }
516 $actor['url'] = object_to_uri( $actor['url'] );
517
518 if ( empty( $actor['webfinger'] ) ) {
519 $actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
520 }
521
522 return $actor;
523 }
524
525 /**
526 * Maybe prevent email notifications for comments.
527 *
528 * This filter can prevent both post author and moderator notifications
529 * for comments on specific post types, such as ActivityPub custom post types.
530 *
531 * @param bool $maybe_notify Whether to send the notification.
532 * @param int $comment_id The comment ID.
533 *
534 * @return bool False to prevent notification, original value otherwise.
535 */
536 public static function maybe_prevent_comment_notification( $maybe_notify, $comment_id ) {
537 // If already disabled, respect that.
538 if ( ! $maybe_notify ) {
539 return $maybe_notify;
540 }
541
542 $comment = \get_comment( $comment_id );
543 if ( ! $comment ) {
544 return $maybe_notify;
545 }
546
547 $post = \get_post( $comment->comment_post_ID );
548 if ( ! $post ) {
549 return $maybe_notify;
550 }
551
552 // Prevent notifications for comments on ap_post.
553 if ( is_ap_post( $post ) ) {
554 return false;
555 }
556
557 return $maybe_notify;
558 }
559
560 /**
561 * Let the post author mute email about reactions to their post.
562 *
563 * Likes, reposts, and quotes are stored as comments, so WordPress emails the post author about
564 * them like any other comment. This is hooked on `notify_post_author` only, so it never affects
565 * the moderator notification, and it targets the plugin's own reaction comment types so pingbacks,
566 * trackbacks, and plain replies keep notifying as usual. The preference defaults to on.
567 *
568 * @since 9.3.0
569 *
570 * @param bool $maybe_notify Whether to send the notification.
571 * @param int $comment_id The comment ID.
572 *
573 * @return bool Whether to send the notification.
574 */
575 public static function maybe_prevent_reaction_notification( $maybe_notify, $comment_id ) {
576 // If already disabled, respect that.
577 if ( ! $maybe_notify ) {
578 return $maybe_notify;
579 }
580
581 $comment = \get_comment( $comment_id );
582 if ( ! $comment || ! \in_array( \get_comment_type( $comment ), Comment::get_comment_type_slugs(), true ) ) {
583 return $maybe_notify;
584 }
585
586 $post = \get_post( $comment->comment_post_ID );
587 if ( ! $post ) {
588 return $maybe_notify;
589 }
590
591 return (bool) \get_user_option( 'activitypub_mailer_new_reaction', $post->post_author );
592 }
593 }
594