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

558 lines 17.8 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 if ( $user_id > Actors::BLOG_USER_ID ) {
160 if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) {
161 return;
162 }
163
164 $email = \get_userdata( $user_id )->user_email;
165 $admin_url = '/users.php?page=activitypub-followers-list';
166 } else {
167 if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_follower', '1' ) ) {
168 return;
169 }
170
171 $email = \get_option( 'admin_email' );
172 $admin_url = '/options-general.php?page=activitypub&tab=followers';
173 }
174
175 $actor = get_remote_metadata_by_actor( $activity['actor'] );
176 if ( ! $actor || \is_wp_error( $actor ) ) {
177 return;
178 }
179
180 $actor = self::normalize_actor( $actor );
181
182 // Replace emoji in actor name and summary.
183 if ( ! empty( $actor['name'] ) ) {
184 $actor['name'] = Emoji::replace_for_actor( $actor['name'], $actor['url'] );
185 }
186 if ( ! empty( $actor['summary'] ) ) {
187 $actor['summary'] = Emoji::replace_for_actor( $actor['summary'], $actor['url'] );
188 }
189
190 $template_args = \array_merge(
191 $actor,
192 array(
193 'admin_url' => $admin_url,
194 'user_id' => $user_id,
195 'stats' => array(
196 'outbox' => null,
197 'followers' => null,
198 'following' => null,
199 ),
200 )
201 );
202
203 foreach ( $template_args['stats'] as $field => $value ) {
204 if ( empty( $actor[ $field ] ) ) {
205 continue;
206 }
207
208 $result = Http::get( $actor[ $field ], array(), true );
209 if ( 200 === \wp_remote_retrieve_response_code( $result ) ) {
210 $body = \json_decode( \wp_remote_retrieve_body( $result ), true );
211 if ( isset( $body['totalItems'] ) ) {
212 $template_args['stats'][ $field ] = $body['totalItems'];
213 }
214 }
215 }
216
217 /* translators: 1: Blog name, 2: Follower name */
218 $subject = \sprintf( \__( '[%1$s] New Follower: %2$s', 'activitypub' ), \get_option( 'blogname' ), $actor['name'] );
219
220 \ob_start();
221 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-follower.php', false, $template_args );
222 $html_message = \ob_get_clean();
223
224 $alt_function = static function ( $mailer ) use ( $actor, $admin_url ) {
225 /* translators: 1: Follower name */
226 $message = \sprintf( \__( 'New Follower: %1$s.', 'activitypub' ), $actor['name'] ) . "\r\n\r\n";
227 /* translators: Follower URL */
228 $message .= \sprintf( \__( 'URL: %s', 'activitypub' ), \esc_url( $actor['url'] ) ) . "\r\n\r\n";
229 $message .= \__( 'You can see all followers here:', 'activitypub' ) . "\r\n";
230 $message .= \esc_url( \admin_url( $admin_url ) ) . "\r\n\r\n";
231 $mailer->{'AltBody'} = $message;
232 };
233 \add_action( 'phpmailer_init', $alt_function );
234
235 \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
236
237 \remove_action( 'phpmailer_init', $alt_function );
238 }
239
240 /**
241 * Send a direct message.
242 *
243 * @param array $activity The activity object.
244 * @param int|int[] $user_ids The id(s) of the local blog-user(s).
245 */
246 public static function direct_message( $activity, $user_ids ) {
247 // Early return if activity is public or has no recipients.
248 if ( is_activity_public( $activity ) || empty( $activity['to'] ) ) {
249 return;
250 }
251
252 // Normalize to array.
253 $user_ids = (array) $user_ids;
254
255 // Build a map of user_id => actor_id and filter to only users in the "to" field.
256 $recipients = array();
257 foreach ( $user_ids as $user_id ) {
258 $actor = Actors::get_by_id( $user_id );
259 if ( \is_wp_error( $actor ) ) {
260 continue;
261 }
262
263 $actor_id = $actor->get_id();
264 if ( \in_array( $actor_id, (array) $activity['to'], true ) ) {
265 $recipients[ $user_id ] = $actor_id;
266 }
267 }
268
269 // No matching recipients.
270 if ( empty( $recipients ) ) {
271 return;
272 }
273
274 // Get actor metadata once (shared for all emails).
275 $actor = get_remote_metadata_by_actor( $activity['actor'] );
276 if ( ! $actor || \is_wp_error( $actor ) || empty( $activity['object']['content'] ) ) {
277 return;
278 }
279
280 $actor = self::normalize_actor( $actor );
281
282 // Send email to each recipient.
283 foreach ( $recipients as $user_id => $actor_id ) {
284 // Check user preferences.
285 if ( $user_id > Actors::BLOG_USER_ID ) {
286 if ( ! \get_user_option( 'activitypub_mailer_new_dm', $user_id ) ) {
287 continue;
288 }
289
290 $email = \get_userdata( $user_id )->user_email;
291 } else {
292 if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_dm', '1' ) ) {
293 continue;
294 }
295
296 $email = \get_option( 'admin_email' );
297 }
298
299 $template_args = array(
300 'activity' => $activity,
301 'actor' => $actor,
302 'user_id' => $user_id,
303 );
304
305 /* translators: 1: Blog name, 2 Actor name */
306 $subject = \sprintf( \esc_html__( '[%1$s] Direct Message from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
307
308 \ob_start();
309 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-dm.php', false, $template_args );
310 $html_message = \ob_get_clean();
311
312 $alt_function = static function ( $mailer ) use ( $actor, $activity ) {
313 $content = \html_entity_decode(
314 \wp_strip_all_tags(
315 \str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
316 ),
317 ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
318 );
319
320 /* translators: Actor name */
321 $message = \sprintf( \esc_html__( 'New Direct Message: %s', 'activitypub' ), $content ) . "\r\n\r\n";
322 /* translators: Actor name */
323 $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
324 /* translators: Message URL */
325 $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
326
327 $mailer->{'AltBody'} = $message;
328 };
329 \add_action( 'phpmailer_init', $alt_function );
330
331 \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
332
333 \remove_action( 'phpmailer_init', $alt_function );
334 }
335 }
336
337 /**
338 * Send a mention notification.
339 *
340 * @param array $activity The activity object.
341 * @param int|int[] $user_ids The id(s) of the local blog-user(s).
342 */
343 public static function mention( $activity, $user_ids ) {
344 // Early return if activity has no mentions.
345 if ( empty( $activity['object']['tag'] ) ) {
346 return;
347 }
348
349 // Do not send a mention notification if the activity is a reply to a local post or comment.
350 if ( is_activity_reply( $activity ) && object_id_to_comment( $activity['object']['id'] ) ) {
351 return;
352 }
353
354 $recipients = array();
355 $mentions = \wp_list_filter( (array) $activity['object']['tag'], array( 'type' => 'Mention' ) );
356 $mentions = \array_map( '\Activitypub\object_to_uri', $mentions );
357 foreach ( (array) $user_ids as $user_id ) {
358 $actor = Actors::get_by_id( $user_id );
359 if ( \is_wp_error( $actor ) ) {
360 continue;
361 }
362
363 $actor_id = $actor->get_id();
364 if ( \in_array( $actor_id, $mentions, true ) ) {
365 $recipients[ $user_id ] = $actor_id;
366 }
367 }
368
369 // No matching recipients.
370 if ( empty( $recipients ) ) {
371 return;
372 }
373
374 // Get actor metadata once (shared for all emails).
375 $actor = get_remote_metadata_by_actor( $activity['actor'] );
376 if ( \is_wp_error( $actor ) ) {
377 return;
378 }
379
380 $actor = self::normalize_actor( $actor );
381
382 // Send email to each recipient.
383 foreach ( $recipients as $user_id => $actor_id ) {
384 // Check user preferences.
385 if ( $user_id > Actors::BLOG_USER_ID ) {
386 if ( ! \get_user_option( 'activitypub_mailer_new_mention', $user_id ) ) {
387 continue;
388 }
389
390 $email = \get_userdata( $user_id )->user_email;
391 } else {
392 if ( '1' !== \get_option( 'activitypub_blog_user_mailer_new_mention', '1' ) ) {
393 continue;
394 }
395
396 $email = \get_option( 'admin_email' );
397 }
398
399 $template_args = array(
400 'activity' => $activity,
401 'actor' => $actor,
402 'user_id' => $user_id,
403 );
404
405 /* translators: 1: Blog name, 2 Actor name */
406 $subject = \sprintf( \esc_html__( '[%1$s] Mention from: %2$s', 'activitypub' ), \esc_html( \get_option( 'blogname' ) ), \esc_html( $actor['name'] ) );
407
408 \ob_start();
409 \load_template( ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/new-mention.php', false, $template_args );
410 $html_message = \ob_get_clean();
411
412 $alt_function = static function ( $mailer ) use ( $actor, $activity ) {
413 $content = \html_entity_decode(
414 \wp_strip_all_tags(
415 \str_replace( '</p>', PHP_EOL . PHP_EOL, $activity['object']['content'] )
416 ),
417 ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401
418 );
419
420 /* translators: Message content */
421 $message = \sprintf( \esc_html__( 'New Mention: %s', 'activitypub' ), $content ) . "\r\n\r\n";
422 /* translators: Actor name */
423 $message .= \sprintf( \esc_html__( 'From: %s', 'activitypub' ), \esc_html( $actor['name'] ) ) . "\r\n";
424 /* translators: Message URL */
425 $message .= \sprintf( \esc_html__( 'URL: %s', 'activitypub' ), \esc_url( $activity['object']['id'] ) ) . "\r\n\r\n";
426
427 $mailer->{'AltBody'} = $message;
428 };
429 \add_action( 'phpmailer_init', $alt_function );
430
431 \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
432
433 \remove_action( 'phpmailer_init', $alt_function );
434 }
435 }
436
437 /**
438 * Send a templated email to a user.
439 *
440 * @param int $user_id The user ID (or BLOG_USER_ID for blog actor).
441 * @param string $subject The email subject.
442 * @param string $template The template name (without path/extension).
443 * @param array $args Template arguments.
444 * @param string $alt_body Optional plain text alternative. Auto-generated from HTML if empty.
445 *
446 * @return bool True if email was sent, false otherwise.
447 */
448 public static function send( $user_id, $subject, $template, $args = array(), $alt_body = '' ) {
449 // Get the recipient email address.
450 if ( $user_id > Actors::BLOG_USER_ID ) {
451 $user = \get_userdata( $user_id );
452 if ( ! $user || empty( $user->user_email ) ) {
453 return false;
454 }
455 $email = $user->user_email;
456 } else {
457 $email = \get_option( 'admin_email' );
458 }
459
460 // Load the HTML template.
461 $template_file = ACTIVITYPUB_PLUGIN_DIR . 'templates/emails/' . \sanitize_file_name( $template ) . '.php';
462
463 /**
464 * Filter the email template file path.
465 *
466 * @param string $template_file The template file path.
467 * @param string $template The template name.
468 * @param int $user_id The user ID.
469 * @param array $args Template arguments.
470 */
471 $template_file = \apply_filters( 'activitypub_email_template', $template_file, $template, $user_id, $args );
472
473 if ( ! \file_exists( $template_file ) ) {
474 return false;
475 }
476
477 \ob_start();
478 \load_template( $template_file, false, $args );
479 $html_message = \ob_get_clean();
480
481 // Build plain text alternative from HTML if not provided.
482 if ( empty( $alt_body ) ) {
483 $alt_body = \wp_strip_all_tags( $html_message );
484 }
485 $alt_function = static function ( $mailer ) use ( $alt_body ) {
486 $mailer->{'AltBody'} = $alt_body;
487 };
488 \add_action( 'phpmailer_init', $alt_function );
489
490 $result = \wp_mail( $email, $subject, $html_message, array( 'Content-type: text/html' ) );
491
492 \remove_action( 'phpmailer_init', $alt_function );
493
494 return $result;
495 }
496
497 /**
498 * Apply defaults to the actor object.
499 *
500 * Ensure that the actor object has a name, url, and webfinger.
501 *
502 * @param array $actor The actor object.
503 *
504 * @return array The inflated actor object.
505 */
506 private static function normalize_actor( $actor ) {
507 if ( empty( $actor['name'] ) ) {
508 $actor['name'] = $actor['preferredUsername'];
509 }
510
511 if ( empty( $actor['url'] ) ) {
512 $actor['url'] = $actor['id'];
513 }
514 $actor['url'] = object_to_uri( $actor['url'] );
515
516 if ( empty( $actor['webfinger'] ) ) {
517 $actor['webfinger'] = '@' . ( $actor['preferredUsername'] ?? $actor['name'] ) . '@' . \wp_parse_url( $actor['url'], PHP_URL_HOST );
518 }
519
520 return $actor;
521 }
522
523 /**
524 * Maybe prevent email notifications for comments.
525 *
526 * This filter can prevent both post author and moderator notifications
527 * for comments on specific post types, such as ActivityPub custom post types.
528 *
529 * @param bool $maybe_notify Whether to send the notification.
530 * @param int $comment_id The comment ID.
531 *
532 * @return bool False to prevent notification, original value otherwise.
533 */
534 public static function maybe_prevent_comment_notification( $maybe_notify, $comment_id ) {
535 // If already disabled, respect that.
536 if ( ! $maybe_notify ) {
537 return $maybe_notify;
538 }
539
540 $comment = \get_comment( $comment_id );
541 if ( ! $comment ) {
542 return $maybe_notify;
543 }
544
545 $post = \get_post( $comment->comment_post_ID );
546 if ( ! $post ) {
547 return $maybe_notify;
548 }
549
550 // Prevent notifications for comments on ap_post.
551 if ( is_ap_post( $post ) ) {
552 return false;
553 }
554
555 return $maybe_notify;
556 }
557 }
558