PluginProbe
ActivityPub / 8.2.0
ActivityPub v8.2.0
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 8.2.0, at includes/class-mailer.php

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