PluginProbe
ActivityPub / 9.0.1
ActivityPub v9.0.1
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 9.0.1, at includes/class-mailer.php

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