From e751ccb45e9b7a620ed61562162ff799c179e74f Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 17:00:39 -0700 Subject: [PATCH 1/2] Comments: Subscribe note thread participants as followers and notify them of replies. Notes reach only the post author, who is emailed about every note, and users named in one specific note. Reply to a thread and the people already in it hear nothing unless the reply mentions them by name. Subscribes the users who start, reply to, or are mentioned in a thread as followers of its top-level note, stored one meta row per user so concurrent replies cannot lose an update, and emails them about later replies. Every email carries a tokenized unfollow link that works logged out. Bookkeeping is not gated on the notification option, so enabling notifications later works for threads that already exist. --- src/wp-includes/comment.php | 652 ++++++++++++++++- src/wp-includes/default-filters.php | 7 + .../tests/comment/wpNotifyNoteFollowers.php | 680 ++++++++++++++++++ .../tests/comment/wpNotifyNoteMentions.php | 11 + 4 files changed, 1347 insertions(+), 3 deletions(-) create mode 100644 tests/phpunit/tests/comment/wpNotifyNoteFollowers.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 7557e9258c87f..14d732167f18a 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2722,9 +2722,11 @@ function wp_notify_note_mentions( ?WP_Comment $comment, $request = null, bool $c * @param WP_User $user The recipient. * @param WP_Comment $comment The note that triggered the notification. * @param WP_Post|null $post The post the note belongs to. + * @param string $context Optional. Why this user is being notified, reported to + * {@see 'wp_note_notification_sent'} listeners. Default 'mention'. * @return bool Whether the email was accepted for delivery by {@see wp_mail()}. */ -function wp_send_note_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post ): bool { +function wp_send_note_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post, string $context = 'mention' ): bool { $switched_locale = switch_to_user_locale( $user->ID ); /* @@ -2765,15 +2767,630 @@ function wp_send_note_notification( WP_User $user, WP_Comment $comment, ?WP_Post $lines[] = __( 'Edit This' ) . ': ' . $edit_link; } + $body = implode( "\n", $lines ); + + /** + * Filters the note mention notification email body. + * + * Lets features layered on top of mentions extend the message, such as the + * per-thread follower subscriptions appending an unfollow link. + * + * @since 7.2.0 + * + * @param string $body Email body. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The note that triggered the notification. + */ + $body = apply_filters( 'wp_note_notification_text', $body, $user, $comment ); + // Declared explicitly so a filtered default cannot turn the message into HTML. $headers = 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"'; - $sent = wp_mail( $user->user_email, $subject, implode( "\n", $lines ), $headers ); + $sent = wp_mail( $user->user_email, $subject, $body, $headers ); if ( $switched_locale ) { restore_previous_locale(); } + /** + * Fires once for each user a note notification was addressed to. + * + * Email is the only channel notes ship with. This action is the seam for + * the others: a plugin routing notes to another service can hook it to + * learn who was told what. + * + * The notification has already been handed to {@see wp_mail()} when this + * fires; `$sent` reports whether wp_mail() accepted it, which is not a + * delivery receipt. + * + * @since 7.2.0 + * + * @param int $user_id Recipient user ID. + * @param WP_Comment $comment The note that triggered the notification. + * @param string $context Why the user was notified: 'mention', + * 'post_author_mention', 'follower', 'resolved', + * or 'reopen'. + * @param bool $sent Whether wp_mail() accepted the message. + */ + do_action( 'wp_note_notification_sent', (int) $user->ID, $comment, $context, $sent ); + + return $sent; +} + +/** + * Returns the top-level note ID for a thread. + * + * Notes are a single level deep: a top-level note (`comment_parent` of 0) with + * replies hanging directly off it. Follower records are keyed by that top-level + * note so a subscription covers the whole thread. + * + * @since 7.2.0 + * + * @param WP_Comment $comment A note comment. + * @return int The top-level note ID for the thread. + */ +function wp_get_note_thread_root_id( WP_Comment $comment ): int { + $parent = (int) $comment->comment_parent; + return $parent > 0 ? $parent : (int) $comment->comment_ID; +} + +/** + * Returns the thread event a note records, if it records one. + * + * Resolving or reopening a thread posts a child note carrying a + * `_wp_note_status` meta value of `resolved` or `reopen`. Those notes are + * bookkeeping rather than conversation - the resolve one has no content at all + * - so notification handlers treat them as thread events instead of replies. + * + * The {@see 'rest_insert_comment'} action fires before + * WP_REST_Comments_Controller saves the meta, so the request is consulted first + * and the stored meta only as a fallback. That way the answer is the same + * whichever of the two REST comment actions the caller runs on. + * + * @since 7.2.0 + * + * @param WP_Comment $comment The note. + * @param mixed $request The REST request, when the caller has one. + * @return string|null 'resolved' or 'reopen', or null for a regular note. + */ +function wp_get_note_status_event( WP_Comment $comment, $request = null ): ?string { + $event = null; + + if ( $request instanceof WP_REST_Request ) { + $meta = $request['meta']; + if ( is_array( $meta ) && isset( $meta['_wp_note_status'] ) ) { + $event = $meta['_wp_note_status']; + } + } + + if ( null === $event ) { + $event = get_comment_meta( $comment->comment_ID, '_wp_note_status', true ); + } + + return in_array( $event, array( 'resolved', 'reopen' ), true ) ? (string) $event : null; +} + +/** + * Returns the user IDs following a note thread. + * + * Followers are stored as one meta row per user so that concurrent replies can + * subscribe users independently, without the lost updates a read-modify-write + * of a single array value would allow. + * + * @since 7.2.0 + * + * @param int $root_id Top-level note ID. + * @return int[] Follower user IDs. + * @phpstan-return list + */ +function wp_get_note_followers( int $root_id ): array { + $followers = get_comment_meta( $root_id, '_wp_note_followers' ); + if ( ! is_array( $followers ) ) { + return array(); + } + + $followers = array_filter( + array_map( 'intval', $followers ), + static function ( $user_id ) { + return $user_id > 0; + } + ); + + return array_values( array_unique( $followers, SORT_NUMERIC ) ); +} + +/** + * Adds user IDs to a note thread's follower list. + * + * @since 7.2.0 + * + * @param int $root_id Top-level note ID. + * @param int[] $user_ids User IDs to subscribe to the thread. + * @return int[] The updated follower list. + * @phpstan-return list + */ +function wp_add_note_followers( int $root_id, array $user_ids ): array { + $followers = wp_get_note_followers( $root_id ); + + foreach ( $user_ids as $user_id ) { + $user_id = (int) $user_id; + if ( $user_id > 0 && ! in_array( $user_id, $followers, true ) ) { + add_comment_meta( $root_id, '_wp_note_followers', $user_id ); + $followers[] = $user_id; + } + } + + return $followers; +} + +/** + * Removes user IDs from a note thread's follower list. + * + * The follower meta is removed with the rest of the thread's comment meta when + * the thread is permanently deleted, so this only needs to handle explicit + * unsubscribes. + * + * @since 7.2.0 + * + * @param int $root_id Top-level note ID. + * @param int[] $user_ids User IDs to unsubscribe from the thread. + * @return int[] The updated follower list. + * @phpstan-return list + */ +function wp_remove_note_followers( int $root_id, array $user_ids ): array { + foreach ( $user_ids as $user_id ) { + $user_id = (int) $user_id; + if ( $user_id > 0 ) { + delete_comment_meta( $root_id, '_wp_note_followers', $user_id ); + } + } + + return wp_get_note_followers( $root_id ); +} + +/** + * Builds the token authorizing an email unfollow link. + * + * The token is an HMAC of the thread and user, keyed with the site's auth salts + * via {@see wp_hash()}, so it cannot be guessed but does not expire the way a + * nonce would: unsubscribe links in old emails keep working. + * + * @since 7.2.0 + * + * @param int $root_id Top-level note ID. + * @param int $user_id Follower user ID. + * @return string The unfollow token. + */ +function wp_get_note_unfollow_token( int $root_id, int $user_id ): string { + return wp_hash( "wp_note_unfollow|{$root_id}|{$user_id}", 'auth' ); +} + +/** + * Builds the unfollow URL included in note notification emails. + * + * @since 7.2.0 + * + * @param int $root_id Top-level note ID. + * @param int $user_id Follower user ID. + * @return string The unfollow URL. + */ +function wp_get_note_unfollow_url( int $root_id, int $user_id ): string { + return add_query_arg( + array( + 'action' => 'wp_note_unfollow', + 'comment' => $root_id, + 'uid' => $user_id, + 'token' => wp_get_note_unfollow_token( $root_id, $user_id ), + ), + admin_url( 'admin-post.php' ) + ); +} + +/** + * Handles the tokenized unfollow link from note notification emails. + * + * Registered for both `admin_post_wp_note_unfollow` and its `nopriv` variant: + * like a standard email unsubscribe, following the link works without logging + * in, because the token itself proves the request came from the notification + * email. + * + * @since 7.2.0 + */ +function wp_handle_note_unfollow(): void { + $root_id = isset( $_GET['comment'] ) ? (int) $_GET['comment'] : 0; + $user_id = isset( $_GET['uid'] ) ? (int) $_GET['uid'] : 0; + $token = isset( $_GET['token'] ) ? (string) wp_unslash( $_GET['token'] ) : ''; + + if ( + $root_id <= 0 || + $user_id <= 0 || + '' === $token || + ! hash_equals( wp_get_note_unfollow_token( $root_id, $user_id ), $token ) + ) { + wp_die( + __( 'This unfollow link is not valid.' ), + __( 'Unfollow note thread' ), + array( 'response' => 403 ) + ); + } + + wp_remove_note_followers( $root_id, array( $user_id ) ); + + wp_die( + __( 'You will no longer be notified about new activity on this note thread.' ), + __( 'Unfollow note thread' ), + array( 'response' => 200 ) + ); +} + +/** + * Appends the unfollow link to note mention notification emails. + * + * Mentioned users are subscribed to the thread by + * {@see wp_maintain_note_followers()}, so their mention email carries the + * opt-out for the subscription it implies. + * + * @since 7.2.0 + * + * @param string $body Email body. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The note. + * @return string Email body with the unfollow footer. + */ +function wp_add_note_unfollow_link_to_email( string $body, WP_User $user, WP_Comment $comment ): string { + $root_id = wp_get_note_thread_root_id( $comment ); + + $body .= "\n\n"; + $body .= __( 'You are subscribed to this note thread. To stop receiving notifications about it, follow this link:' ); + $body .= "\n" . wp_get_note_unfollow_url( $root_id, (int) $user->ID ); + + return $body; +} + +/** + * Sends the post author a mention email instead of the generic new note one. + * + * The post author is notified about every new note with a generic email by + * {@see wp_new_comment_via_rest_notify_postauthor()}, which is why the mention + * path skips them. But when the post author is the one being mentioned, the + * generic email would swallow the higher-signal mention: this handler, running + * before the generic one, sends them the mention email and suppresses the + * generic one for this comment through the {@see 'notify_post_author'} filter. + * + * @since 7.2.0 + * + * @param WP_Comment|null $comment The note that was just inserted. + * @param mixed $request The REST request. Unused. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_route_post_author_mention_notification( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( ! $creating || ! $comment || 'note' !== $comment->comment_type ) { + return; + } + + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + $comment_post_id = (int) $comment->comment_post_ID; + $post = $comment_post_id ? get_post( $comment_post_id ) : null; + if ( ! $post ) { + return; + } + + $post_author_id = (int) $post->post_author; + if ( + $post_author_id <= 0 || + $post_author_id === (int) $comment->user_id || + ! in_array( $post_author_id, wp_get_note_mentioned_user_ids( $comment->comment_content ), true ) + ) { + return; + } + + $user = get_userdata( $post_author_id ); + if ( ! $user || empty( $user->user_email ) ) { + return; + } + + if ( ! user_can( $post_author_id, 'edit_comment', $comment->comment_ID ) ) { + return; + } + + wp_send_note_notification( $user, $comment, $post, 'post_author_mention' ); + + /* + * Suppress the generic post-author email for this note only; the filter + * stays installed but is scoped to this comment ID. + */ + $target_id = (int) $comment->comment_ID; + add_filter( + 'notify_post_author', + static function ( $maybe_notify, $comment_id ) use ( $target_id ) { + return (int) $comment_id === $target_id ? false : $maybe_notify; + }, + 10, + 2 + ); +} + +/** + * Notifies a thread's existing followers about a new note in it. + * + * Runs after the mention notifications: followers minus the users this note + * mentions (they just received the mention email), minus the note's own author, + * minus the post author when they are already being emailed about this note. + * + * @since 7.2.0 + * + * @param WP_Comment|null $comment The note that was just inserted. + * @param mixed $request The REST request, used to recognize system notes. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_notify_note_followers( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( ! $creating || ! $comment || 'note' !== $comment->comment_type ) { + return; + } + + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + /* + * Resolving or reopening a thread posts a system note. That is a thread + * event rather than a reply, and announcing it as "added a note" would mail + * followers a body with nothing in it, since resolve notes have no content. + * wp_notify_note_event() announces those. + */ + if ( null !== wp_get_note_status_event( $comment, $request ) ) { + return; + } + + $root_id = wp_get_note_thread_root_id( $comment ); + $followers = wp_get_note_followers( $root_id ); + $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content ); + + $author_id = (int) $comment->user_id; + $comment_post_id = (int) $comment->comment_post_ID; + $post = $comment_post_id ? get_post( $comment_post_id ) : null; + $post_author_id = $post ? (int) $post->post_author : 0; + + /* + * Whether the post author is being sent their own email about this note; + * mirrors the option and filter checks in wp_new_comment_notify_postauthor() + * so suppressing one path cannot silently strand the other. + */ + /** This filter is documented in wp-includes/comment.php */ + $post_author_notified = (bool) apply_filters( + 'notify_post_author', + (bool) get_option( 'wp_notes_notify', 1 ), + $comment->comment_ID + ); + + /** + * Filters the user IDs notified about a new note in a thread they follow. + * + * @since 7.2.0 + * + * @param int[] $follower_ids Candidate follower user IDs. + * @param WP_Comment $comment The note that was inserted. + * @param int $root_id The thread's top-level note ID. + */ + $follower_ids = apply_filters( 'wp_note_follower_notification_recipients', $followers, $comment, $root_id ); + + foreach ( $follower_ids as $user_id ) { + $user_id = (int) $user_id; + + if ( $user_id === $author_id ) { + continue; + } + + // The mention email already covered them. + if ( in_array( $user_id, $mentioned, true ) ) { + continue; + } + + if ( $user_id === $post_author_id && $post_author_notified ) { + continue; + } + + $user = get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + continue; + } + + // Same visibility bar as the mention path: never email note content to + // a user who cannot read the note. + if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) { + continue; + } + + wp_send_note_follower_notification( $user, $comment, $post, $root_id ); + } +} + +/** + * Notifies users newly mentioned by an edit to an existing note. + * + * The create path deliberately never re-notifies on edits, but a mention + * *added* by an edit would otherwise be silently swallowed: the mentioner + * reasonably believes they pinged someone. Follower state makes "new" cheap to + * detect: anyone mentioned in the edited content who is not yet a follower has + * never been notified about this thread. + * + * @since 7.2.0 + * + * @param WP_Comment|null $comment The note that was just updated. + * @param mixed $request The REST request. Unused. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_notify_new_mentions_on_note_update( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( $creating || ! $comment || 'note' !== $comment->comment_type ) { + return; + } + + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + $root_id = wp_get_note_thread_root_id( $comment ); + $followers = wp_get_note_followers( $root_id ); + $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content ); + $new_mentioned = array_values( array_diff( $mentioned, $followers ) ); + + if ( array() === $new_mentioned ) { + return; + } + + $author_id = (int) $comment->user_id; + $comment_post_id = (int) $comment->comment_post_ID; + $post = $comment_post_id ? get_post( $comment_post_id ) : null; + + foreach ( $new_mentioned as $user_id ) { + if ( $user_id === $author_id ) { + continue; + } + + $user = get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + continue; + } + + if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) { + continue; + } + + wp_send_note_notification( $user, $comment, $post ); + } +} + +/** + * Keeps a thread's follower list in sync as notes are created and edited. + * + * The note author and everyone mentioned are subscribed to the thread; an edit + * that adds a mention subscribes the newly mentioned user. Removing a mention + * on a later edit intentionally does not unfollow: once pulled into a thread a + * user stays subscribed until they explicitly opt out, through the emailed + * unfollow link, {@see wp_remove_note_followers()}, or the registered meta. + * + * Bookkeeping is intentionally not gated on the `wp_notes_notify` option: that + * option controls whether emails are sent, not who participates in a thread, + * and follower lists must stay correct so enabling notifications later works + * for existing threads. + * + * Runs after the notification handlers, so "existing followers" still means + * "before this note" while they run. + * + * @since 7.2.0 + * + * @param WP_Comment|null $comment The note that was just inserted or updated. + * @param mixed $request The REST request. Unused. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_maintain_note_followers( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( ! $comment || 'note' !== $comment->comment_type ) { + return; + } + + $root_id = wp_get_note_thread_root_id( $comment ); + $new_followers = wp_get_note_mentioned_user_ids( $comment->comment_content ); + + if ( $creating ) { + $author_id = (int) $comment->user_id; + if ( $author_id > 0 ) { + $new_followers[] = $author_id; + } + } + + if ( array() !== $new_followers ) { + wp_add_note_followers( $root_id, $new_followers ); + } +} + +/** + * Sends a single note follower notification email. + * + * @since 7.2.0 + * + * @param WP_User $user The recipient. + * @param WP_Comment $comment The note that triggered the notification. + * @param WP_Post|null $post The post the note belongs to. + * @param int $root_id The thread's top-level note ID. + * @return bool Whether the email was accepted for delivery by {@see wp_mail()}. + */ +function wp_send_note_follower_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post, int $root_id ): bool { + $switched_locale = switch_to_user_locale( $user->ID ); + + $blogname = wp_specialchars_decode( get_bloginfo( 'name', 'display' ), ENT_QUOTES ); + $post_title = $post ? wp_specialchars_decode( get_the_title( $post ), ENT_QUOTES ) : ''; + $author_name = $comment->comment_author ? $comment->comment_author : __( 'Someone' ); + $content = wp_specialchars_decode( wp_strip_all_tags( $comment->comment_content ) ); + + /* + * Composed for the recipient, like the rest of the message: + * get_edit_post_link() answers for whoever is current, which here is the + * replying user over REST and nobody at all under WP-Cron. + */ + $edit_link = ''; + if ( $post ) { + $previous_user_id = get_current_user_id(); + wp_set_current_user( $user->ID ); + $edit_link = (string) get_edit_post_link( $post->ID, 'url' ); + wp_set_current_user( $previous_user_id ); + } + + /* translators: 1: Note author's name, 2: Post title. */ + $message = sprintf( __( '%1$s added a note to a thread you follow on "%2$s".' ), $author_name, $post_title ); + /* translators: Note follower notification email subject. 1: Site title, 2: Post title. */ + $subject = sprintf( __( '[%1$s] New activity on a note you follow on "%2$s"' ), $blogname, $post_title ); + + $lines = array( $message, '' ); + if ( '' !== $content ) { + $lines[] = $content; + $lines[] = ''; + } + if ( $edit_link ) { + $lines[] = __( 'Edit This' ) . ': ' . $edit_link; + } + + $body = implode( "\n", $lines ); + $body .= "\n\n"; + $body .= __( 'You are subscribed to this note thread. To stop receiving notifications about it, follow this link:' ); + $body .= "\n" . wp_get_note_unfollow_url( $root_id, (int) $user->ID ); + + /** + * Filters the note follower notification email subject. + * + * @since 7.2.0 + * + * @param string $subject Email subject. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The note. + */ + $subject = apply_filters( 'wp_note_follower_notification_subject', $subject, $user, $comment ); + + /** + * Filters the note follower notification email body. + * + * @since 7.2.0 + * + * @param string $body Email body. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The note. + */ + $body = apply_filters( 'wp_note_follower_notification_text', $body, $user, $comment ); + + // Declared explicitly so a filtered default cannot turn the message into HTML. + $headers = 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"'; + + $sent = wp_mail( $user->user_email, $subject, $body, $headers ); + + if ( $switched_locale ) { + restore_previous_locale(); + } + + /** This action is documented in wp-includes/comment.php */ + do_action( 'wp_note_notification_sent', (int) $user->ID, $comment, 'follower', $sent ); + return $sent; } @@ -4506,9 +5123,10 @@ function _wp_check_for_scheduled_update_comment_type() { } /** - * Register initial note status meta. + * Register initial note meta. * * @since 6.9.0 + * @since 7.2.0 Registers the note followers meta. */ function wp_create_initial_comment_meta() { register_meta( @@ -4529,6 +5147,34 @@ function wp_create_initial_comment_meta() { }, ) ); + + /* + * Followers are stored as one row per user so concurrent replies can + * subscribe users independently. Writing the registered meta through the + * REST API replaces the whole list, which reintroduces the + * read-modify-write race the per-row storage exists to avoid: interfaces + * managing a single user's subscription should use + * wp_add_note_followers() / wp_remove_note_followers() rather than writing + * the full list. + */ + register_meta( + 'comment', + '_wp_note_followers', + array( + 'type' => 'integer', + 'description' => __( 'User IDs following the note thread' ), + 'single' => false, + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { + return current_user_can( 'edit_comment', $object_id ); + }, + ) + ); } /** diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..36f07a38ad29f 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -536,7 +536,14 @@ add_action( 'comment_post', 'wp_new_comment_notify_moderator' ); add_action( 'comment_post', 'wp_new_comment_notify_postauthor' ); add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' ); +add_action( 'rest_insert_comment', 'wp_route_post_author_mention_notification', 9, 3 ); add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 ); +add_action( 'rest_insert_comment', 'wp_notify_note_followers', 11, 3 ); +add_action( 'rest_insert_comment', 'wp_notify_new_mentions_on_note_update', 11, 3 ); +add_action( 'rest_insert_comment', 'wp_maintain_note_followers', 12, 3 ); +add_filter( 'wp_note_notification_text', 'wp_add_note_unfollow_link_to_email', 10, 3 ); +add_action( 'admin_post_wp_note_unfollow', 'wp_handle_note_unfollow' ); +add_action( 'admin_post_nopriv_wp_note_unfollow', 'wp_handle_note_unfollow' ); add_action( 'after_password_reset', 'wp_password_change_notification' ); add_action( 'register_new_user', 'wp_send_new_user_notifications' ); add_action( 'edit_user_created_user', 'wp_send_new_user_notifications', 10, 2 ); diff --git a/tests/phpunit/tests/comment/wpNotifyNoteFollowers.php b/tests/phpunit/tests/comment/wpNotifyNoteFollowers.php new file mode 100644 index 0000000000000..8ed858598205e --- /dev/null +++ b/tests/phpunit/tests/comment/wpNotifyNoteFollowers.php @@ -0,0 +1,680 @@ +, + * subject: string, + * message: string, + * }> + */ + private array $sent = array(); + + /** + * Captured wp_mail() recipients for the current test. + * + * @var list + */ + private array $sent_to = array(); + + /** + * Sets up shared fixtures. + * + * @param WP_UnitTest_Factory $factory Factory. + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$post_author = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$commenter = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$mentioned = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + + self::$post = $factory->post->create_and_get( array( 'post_author' => self::$post_author->ID ) ); + } + + public function set_up() { + parent::set_up(); + $this->sent = array(); + $this->sent_to = array(); + // Short-circuit wp_mail() and record what would have been sent. + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + /** + * Records wp_mail() calls and short-circuits delivery. + * + * @param null $short_circuit Short-circuit value. + * @param array $atts wp_mail() arguments. + * @return bool Always true to indicate a "sent" message. + * + * @phpstan-param array{ + * to: non-falsy-string|list, + * subject: string, + * message: string, + * ... + * } $atts + * @phpstan-return true + */ + public function capture_mail( $short_circuit, array $atts ): bool { + $to = (array) $atts['to']; + + $this->sent[] = array( + 'to' => $to, + 'subject' => $atts['subject'], + 'message' => $atts['message'], + ); + + foreach ( $to as $recipient ) { + $this->sent_to[] = $recipient; + } + + return true; + } + + /** + * Returns the captured emails sent to the given address. + * + * @param string $email Recipient address. + * @return array The captured emails. + */ + private function emails_to( string $email ): array { + return array_values( + array_filter( + $this->sent, + static function ( $mail ) use ( $email ) { + return in_array( $email, $mail['to'], true ); + } + ) + ); + } + + /** + * Builds a note comment for the shared post. + * + * @param string $content Note content. + * @param int $user_id Author user ID. + * @param int $parent_id Parent note ID (0 for a top-level note). + * @return WP_Comment The inserted note. + */ + private function insert_note( string $content, int $user_id, int $parent_id = 0 ): WP_Comment { + $comment = self::factory()->comment->create_and_get( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_type' => 'note', + 'comment_content' => $content, + 'comment_parent' => $parent_id, + 'user_id' => $user_id, + ) + ); + assert( $comment instanceof WP_Comment ); + return $comment; + } + + /** + * Updates a note's content as its author, preserving mention markup. + * + * @param WP_Comment $note The note to update. + * @param string $content New note content. + * @return WP_Comment The updated note. + */ + private function update_note_content( WP_Comment $note, string $content ): WP_Comment { + $editor_id = (int) $note->user_id; + if ( is_multisite() ) { + grant_super_admin( $editor_id ); + } + $previous_user_id = get_current_user_id(); + wp_set_current_user( $editor_id ); + + wp_update_comment( + array( + 'comment_ID' => $note->comment_ID, + 'comment_content' => $content, + ) + ); + + wp_set_current_user( $previous_user_id ); + if ( is_multisite() ) { + revoke_super_admin( $editor_id ); + } + + $updated = get_comment( $note->comment_ID ); + assert( $updated instanceof WP_Comment ); + return $updated; + } + + /** + * Fires the real `rest_insert_comment` action for a note, exercising every + * registered handler in its true priority order. + * + * @param WP_Comment $comment The note. + * @param bool $creating Whether to simulate a create or an update. + */ + private function fire_rest_insert( WP_Comment $comment, bool $creating = true ): void { + do_action( 'rest_insert_comment', $comment, null, $creating ); + } + + /** + * Builds the stored markup for a mention of the given user. + * + * @param int $user_id User ID to mention. + * @param string $label Optional. The mention's visible text. + * @return string The mention chip markup. + */ + private function get_mention_markup( int $user_id, string $label = '@Mentioned' ): string { + return sprintf( '%s', $user_id, $label ); + } + + /** + * @covers ::wp_add_note_followers + * @covers ::wp_get_note_followers + * @covers ::wp_remove_note_followers + */ + public function test_followers_can_be_added_and_removed() { + $note = $this->insert_note( 'Top level', self::$commenter->ID ); + + wp_add_note_followers( + (int) $note->comment_ID, + array( self::$commenter->ID, self::$mentioned->ID ) + ); + + $remaining = wp_remove_note_followers( + (int) $note->comment_ID, + array( self::$mentioned->ID ) + ); + + $this->assertSame( array( self::$commenter->ID ), $remaining ); + $this->assertSame( + array( self::$commenter->ID ), + wp_get_note_followers( (int) $note->comment_ID ) + ); + + // Removing the last follower clears the meta entirely. + wp_remove_note_followers( (int) $note->comment_ID, array( self::$commenter->ID ) ); + $this->assertSame( '', get_comment_meta( $note->comment_ID, '_wp_note_followers', true ) ); + } + + /** + * @covers ::wp_create_initial_comment_meta + */ + public function test_followers_meta_is_registered_for_rest() { + // The test case unregisters every meta key, so register them again here. + wp_create_initial_comment_meta(); + + $registered = get_registered_meta_keys( 'comment' ); + + $this->assertArrayHasKey( '_wp_note_followers', $registered ); + $this->assertNotFalse( $registered['_wp_note_followers']['show_in_rest'] ); + } + + /** + * @covers ::wp_maintain_note_followers + */ + public function test_author_and_mentioned_users_follow_a_new_thread() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + $this->fire_rest_insert( $note ); + + $followers = wp_get_note_followers( (int) $note->comment_ID ); + $this->assertContains( self::$commenter->ID, $followers ); + $this->assertContains( self::$mentioned->ID, $followers ); + } + + /** + * @covers ::wp_maintain_note_followers + */ + public function test_replying_subscribes_the_replier_to_the_thread_root() { + $root = $this->insert_note( 'Top level', self::$commenter->ID ); + $this->fire_rest_insert( $root ); + + $replier = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $reply = $this->insert_note( 'A reply', $replier->ID, (int) $root->comment_ID ); + $this->fire_rest_insert( $reply ); + + $this->assertContains( + $replier->ID, + wp_get_note_followers( (int) $root->comment_ID ) + ); + // The reply itself carries no follower list; the root anchors it. + $this->assertSame( array(), wp_get_note_followers( (int) $reply->comment_ID ) ); + } + + /** + * Subscription bookkeeping must run even while notifications are off, so + * enabling notifications later works for existing threads. + * + * @covers ::wp_maintain_note_followers + */ + public function test_subscriptions_are_maintained_while_notifications_are_disabled() { + update_option( 'wp_notes_notify', 0 ); + + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $note ); + + update_option( 'wp_notes_notify', 1 ); + + $this->assertEmpty( $this->sent_to ); + $followers = wp_get_note_followers( (int) $note->comment_ID ); + $this->assertContains( self::$commenter->ID, $followers ); + $this->assertContains( self::$mentioned->ID, $followers ); + } + + /** + * @covers ::wp_notify_note_followers + */ + public function test_followers_are_notified_of_replies() { + $root = $this->insert_note( + 'Start ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $root ); + + /* + * A different user replies; the mentioned user follows the thread and + * is notified even though the reply does not mention them. + */ + $this->sent = array(); + $this->sent_to = array(); + $replier = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $reply = $this->insert_note( 'Following up', $replier->ID, (int) $root->comment_ID ); + $this->fire_rest_insert( $reply ); + + $emails = $this->emails_to( self::$mentioned->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'a note you follow', $emails[0]['subject'] ); + // The follower email carries the unfollow link for the thread. + $this->assertStringContainsString( + wp_get_note_unfollow_url( (int) $root->comment_ID, self::$mentioned->ID ), + $emails[0]['message'] + ); + } + + /** + * A follower who is also mentioned in the reply gets the mention email + * only, never two emails about the same note. + * + * @covers ::wp_notify_note_followers + */ + public function test_mentioned_followers_are_not_double_notified() { + $root = $this->insert_note( + 'Start ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $root ); + + $this->sent = array(); + $this->sent_to = array(); + $replier = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $reply = $this->insert_note( + 'Again ' . $this->get_mention_markup( self::$mentioned->ID ), + $replier->ID, + (int) $root->comment_ID + ); + $this->fire_rest_insert( $reply ); + + $emails = $this->emails_to( self::$mentioned->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'You were mentioned', $emails[0]['subject'] ); + } + + /** + * @covers ::wp_notify_new_mentions_on_note_update + * @covers ::wp_maintain_note_followers + */ + public function test_edit_that_adds_a_mention_notifies_and_subscribes_the_new_user() { + $note = $this->insert_note( 'No mentions yet', self::$commenter->ID ); + $this->fire_rest_insert( $note ); + $this->assertEmpty( $this->emails_to( self::$mentioned->user_email ) ); + + $updated = $this->update_note_content( + $note, + 'Now ping ' . $this->get_mention_markup( self::$mentioned->ID ) + ); + $this->fire_rest_insert( $updated, false ); + + $emails = $this->emails_to( self::$mentioned->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'You were mentioned', $emails[0]['subject'] ); + $this->assertContains( + self::$mentioned->ID, + wp_get_note_followers( (int) $note->comment_ID ) + ); + } + + /** + * Users already following the thread are not re-notified when an edit + * repeats their mention. + * + * @covers ::wp_notify_new_mentions_on_note_update + */ + public function test_edit_does_not_renotify_existing_followers() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $note ); + + $this->sent = array(); + $this->sent_to = array(); + $updated = $this->update_note_content( + $note, + 'Edited, still ' . $this->get_mention_markup( self::$mentioned->ID ) + ); + $this->fire_rest_insert( $updated, false ); + + $this->assertEmpty( $this->emails_to( self::$mentioned->user_email ) ); + $this->assertEmpty( $this->emails_to( self::$commenter->user_email ) ); + } + + /** + * A mentioned post author receives the mention email, and the generic + * post-author notification is suppressed for that note. + * + * @covers ::wp_route_post_author_mention_notification + */ + public function test_mentioned_post_author_gets_the_mention_email_not_the_generic_one() { + $note = $this->insert_note( + 'Hey ' . $this->get_mention_markup( self::$post_author->ID, '@Author' ), + self::$commenter->ID + ); + + $this->fire_rest_insert( $note ); + + $emails = $this->emails_to( self::$post_author->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'You were mentioned', $emails[0]['subject'] ); + } + + /** + * Without a mention, the generic post-author email is untouched. + * + * @covers ::wp_route_post_author_mention_notification + */ + public function test_unmentioned_post_author_still_gets_the_generic_email() { + $note = $this->insert_note( 'Just a note', self::$commenter->ID ); + + $this->fire_rest_insert( $note ); + + $emails = $this->emails_to( self::$post_author->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringNotContainsString( 'You were mentioned', $emails[0]['subject'] ); + } + + /** + * @covers ::wp_add_note_unfollow_link_to_email + */ + public function test_mention_email_carries_the_unfollow_link() { + $note = $this->insert_note( + 'Ping ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + + $this->fire_rest_insert( $note ); + + $emails = $this->emails_to( self::$mentioned->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( + wp_get_note_unfollow_url( (int) $note->comment_ID, self::$mentioned->ID ), + $emails[0]['message'] + ); + } + + /** + * @covers ::wp_handle_note_unfollow + */ + public function test_unfollow_link_removes_the_follower() { + $note = $this->insert_note( 'Top level', self::$commenter->ID ); + wp_add_note_followers( (int) $note->comment_ID, array( self::$mentioned->ID ) ); + + $_GET['comment'] = (string) $note->comment_ID; + $_GET['uid'] = (string) self::$mentioned->ID; + $_GET['token'] = wp_get_note_unfollow_token( (int) $note->comment_ID, self::$mentioned->ID ); + + try { + wp_handle_note_unfollow(); + $this->fail( 'Expected wp_die() confirmation.' ); + } catch ( WPDieException $e ) { + $this->assertStringContainsString( 'no longer be notified', $e->getMessage() ); + } finally { + unset( $_GET['comment'], $_GET['uid'], $_GET['token'] ); + } + + $this->assertNotContains( + self::$mentioned->ID, + wp_get_note_followers( (int) $note->comment_ID ) + ); + } + + /** + * @covers ::wp_handle_note_unfollow + */ + public function test_unfollow_link_rejects_a_bad_token() { + $note = $this->insert_note( 'Top level', self::$commenter->ID ); + wp_add_note_followers( (int) $note->comment_ID, array( self::$mentioned->ID ) ); + + $_GET['comment'] = (string) $note->comment_ID; + $_GET['uid'] = (string) self::$mentioned->ID; + $_GET['token'] = 'forged-token'; + + try { + wp_handle_note_unfollow(); + $this->fail( 'Expected wp_die() rejection.' ); + } catch ( WPDieException $e ) { + $this->assertStringContainsString( 'not valid', $e->getMessage() ); + } finally { + unset( $_GET['comment'], $_GET['uid'], $_GET['token'] ); + } + + // The follower list is untouched. + $this->assertContains( + self::$mentioned->ID, + wp_get_note_followers( (int) $note->comment_ID ) + ); + } + + /** + * A follower who cannot read the note is never emailed its content. + * + * @covers ::wp_notify_note_followers + */ + public function test_followers_without_note_access_are_not_emailed() { + $subscriber = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + + $root = $this->insert_note( 'Top level', self::$commenter->ID ); + $this->fire_rest_insert( $root ); + wp_add_note_followers( (int) $root->comment_ID, array( $subscriber->ID ) ); + + $this->sent = array(); + $this->sent_to = array(); + $replier = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $reply = $this->insert_note( 'Following up', $replier->ID, (int) $root->comment_ID ); + $this->fire_rest_insert( $reply ); + + $this->assertNotContains( $subscriber->user_email, $this->sent_to ); + } + + /** + * Resolving or reopening a thread posts a system note. Announcing it as a + * reply would mail followers a body with nothing in it, since resolve + * notes carry no content. + * + * @covers ::wp_notify_note_followers + * @covers ::wp_get_note_status_event + */ + public function test_system_notes_do_not_send_the_follower_email() { + $root = $this->insert_note( + 'Start ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $root ); + + $this->sent = array(); + $this->sent_to = array(); + + $resolver = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $system = $this->insert_note( '', $resolver->ID, (int) $root->comment_ID ); + update_comment_meta( $system->comment_ID, '_wp_note_status', 'resolved' ); + $this->fire_rest_insert( $system ); + + $this->assertEmpty( $this->emails_to( self::$mentioned->user_email ) ); + } + + /** + * The controller saves comment meta after `rest_insert_comment` fires, so + * a system note has to be recognized from the request that created it. + * + * @covers ::wp_get_note_status_event + */ + public function test_system_notes_are_recognized_before_their_meta_is_saved() { + $root = $this->insert_note( + 'Start ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $root ); + + $this->sent = array(); + $this->sent_to = array(); + + $resolver = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $system = $this->insert_note( 'Reopening this', $resolver->ID, (int) $root->comment_ID ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'meta', array( '_wp_note_status' => 'reopen' ) ); + + // No meta row exists yet, exactly as during a real create. + $this->assertSame( '', get_comment_meta( $system->comment_ID, '_wp_note_status', true ) ); + $this->assertSame( 'reopen', wp_get_note_status_event( $system, $request ) ); + + do_action( 'rest_insert_comment', $system, $request, true ); + + $this->assertEmpty( $this->emails_to( self::$mentioned->user_email ) ); + } + + /** + * A regular reply is not mistaken for a thread event. + * + * @covers ::wp_get_note_status_event + */ + public function test_regular_notes_record_no_thread_event() { + $note = $this->insert_note( 'Just a note', self::$commenter->ID ); + + $this->assertNull( wp_get_note_status_event( $note ) ); + $this->assertNull( wp_get_note_status_event( $note, new WP_REST_Request( 'POST', '/wp/v2/comments' ) ) ); + } + + /** + * The sent action is what channels other than email hook, so it has to + * report every recipient and say why they were notified. + * + * @covers ::wp_send_note_notification + * @covers ::wp_send_note_follower_notification + */ + public function test_notification_sent_action_reports_every_recipient() { + $fired = array(); + add_action( + 'wp_note_notification_sent', + static function ( $user_id, $comment, $context, $sent ) use ( &$fired ) { + $fired[] = array( + 'user_id' => $user_id, + 'context' => $context, + 'sent' => $sent, + ); + }, + 10, + 4 + ); + + $root = $this->insert_note( + 'Start ' . $this->get_mention_markup( self::$mentioned->ID ), + self::$commenter->ID + ); + $this->fire_rest_insert( $root ); + + $this->assertSame( + array( + array( + 'user_id' => self::$mentioned->ID, + 'context' => 'mention', + 'sent' => true, + ), + ), + $fired + ); + + // The same user, now reached as a follower rather than a mention. + $fired = array(); + $replier = self::factory()->user->create_and_get( array( 'role' => 'editor' ) ); + $reply = $this->insert_note( 'Following up', $replier->ID, (int) $root->comment_ID ); + $this->fire_rest_insert( $reply ); + + $this->assertContains( + array( + 'user_id' => self::$mentioned->ID, + 'context' => 'follower', + 'sent' => true, + ), + $fired + ); + } + + /** + * A mentioned post author is reported under their own context, so an + * integration can tell the routed mention from a plain one. + * + * @covers ::wp_route_post_author_mention_notification + */ + public function test_notification_sent_action_reports_the_routed_post_author_mention() { + $contexts = array(); + add_action( + 'wp_note_notification_sent', + static function ( $user_id, $comment, $context ) use ( &$contexts ) { + $contexts[ $user_id ] = $context; + }, + 10, + 3 + ); + + $note = $this->insert_note( + 'Hey ' . $this->get_mention_markup( self::$post_author->ID, '@Author' ), + self::$commenter->ID + ); + $this->fire_rest_insert( $note ); + + $this->assertArrayHasKey( self::$post_author->ID, $contexts ); + $this->assertSame( 'post_author_mention', $contexts[ self::$post_author->ID ] ); + } +} diff --git a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php index f8e1eddc75293..97fd075de0584 100644 --- a/tests/phpunit/tests/comment/wpNotifyNoteMentions.php +++ b/tests/phpunit/tests/comment/wpNotifyNoteMentions.php @@ -442,6 +442,17 @@ public function test_rest_note_update_does_not_renotify() { self::$commenter->ID ); + /* + * Run the create path the fixture skipped, so the mentioned user is + * subscribed to the thread as a real create would leave them. An edit + * only notifies users the thread has never told about it, which is how + * wp_notify_new_mentions_on_note_update() tells a mention added by an + * edit from one that was already delivered. + */ + do_action( 'rest_insert_comment', $note, null, true ); + $this->sent = array(); + $this->sent_to = array(); + wp_set_current_user( self::$commenter->ID ); $request = new WP_REST_Request( 'PUT', '/wp/v2/comments/' . $note->comment_ID ); From 32aca75813ebd5c279164f1ad79752f7336d6935 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Wed, 12 Aug 2026 17:01:16 -0700 Subject: [PATCH 2/2] Comments: Notify note thread followers when a thread is resolved or reopened. Resolving or reopening a thread posts a child note carrying the new status as comment meta. That note is bookkeeping rather than conversation, and the resolve one has no content at all, so the post author was emailed a generic new-note message with an empty body and followers heard nothing about the outcome. Announces the event to the thread's followers and the post author, and leaves the generic email alone for that note. The controller saves comment meta between rest_insert_comment and rest_after_insert_comment, so the notifier runs on the later action where the status is stored, while the earlier handlers recognize the note from the request. --- src/wp-includes/comment.php | 234 ++++++++- src/wp-includes/default-filters.php | 3 +- .../tests/comment/wpNotifyNoteEvent.php | 479 ++++++++++++++++++ 3 files changed, 711 insertions(+), 5 deletions(-) create mode 100644 tests/phpunit/tests/comment/wpNotifyNoteEvent.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 14d732167f18a..9ecdf3f8432db 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2581,13 +2581,28 @@ function wp_new_comment_notify_postauthor( $comment_id ) { * Send a notification to the post author when a new note is added via the REST API. * * @since 6.9.0 + * @since 7.2.0 Added the `$request` and `$creating` parameters, and thread + * event notes are left to {@see wp_notify_note_event()}. * - * @param WP_Comment $comment The comment object. + * @param WP_Comment $comment The comment object. + * @param mixed $request The REST request, used to recognize system notes. + * @param bool $creating Whether this is a create (true) or update (false). */ -function wp_new_comment_via_rest_notify_postauthor( $comment ) { - if ( $comment instanceof WP_Comment && 'note' === $comment->comment_type ) { - wp_new_comment_notify_postauthor( (int) $comment->comment_ID ); +function wp_new_comment_via_rest_notify_postauthor( $comment, $request = null, $creating = true ) { + if ( ! $comment instanceof WP_Comment || 'note' !== $comment->comment_type ) { + return; } + + /* + * Resolving or reopening a thread posts a system note. The generic email + * would announce it as a new note and, for a resolve, carry an empty body; + * wp_notify_note_event() tells the post author what actually happened. + */ + if ( null !== wp_get_note_status_event( $comment, $request ) ) { + return; + } + + wp_new_comment_notify_postauthor( (int) $comment->comment_ID ); } /** @@ -3394,6 +3409,217 @@ function wp_send_note_follower_notification( WP_User $user, WP_Comment $comment, return $sent; } +/** + * Notifies a thread's audience that it was resolved or reopened. + * + * The audience is the thread's followers plus the post author, minus whoever + * performed the action and minus anyone the note mentions, who was just sent + * the higher-signal mention email about the same insert. + * + * Runs on {@see 'rest_after_insert_comment'} rather than + * {@see 'rest_insert_comment'} because WP_REST_Comments_Controller saves + * comment meta between the two: by the later action `_wp_note_status` is + * stored, so the event is recognized no matter which client wrote the note. + * + * @since 7.2.0 + * + * @param WP_Comment|null $comment The note that was just inserted. + * @param mixed $request The REST request that created it. + * @param bool $creating Whether this is a create (true) or update (false). + */ +function wp_notify_note_event( ?WP_Comment $comment, $request = null, bool $creating = true ): void { + if ( ! $creating || ! $comment || 'note' !== $comment->comment_type ) { + return; + } + + // Share the single user-facing notes notification preference. + if ( ! get_option( 'wp_notes_notify', 1 ) ) { + return; + } + + $event = wp_get_note_status_event( $comment, $request ); + if ( null === $event ) { + return; + } + + $root_id = wp_get_note_thread_root_id( $comment ); + $followers = wp_get_note_followers( $root_id ); + + $comment_post_id = (int) $comment->comment_post_ID; + $post = $comment_post_id ? get_post( $comment_post_id ) : null; + $post_author_id = $post ? (int) $post->post_author : 0; + + $recipients = $followers; + if ( $post_author_id > 0 ) { + $recipients[] = $post_author_id; + } + $recipients = array_values( array_unique( $recipients, SORT_NUMERIC ) ); + + /** + * Filters the user IDs notified that a note thread was resolved or reopened. + * + * @since 7.2.0 + * + * @param int[] $recipients Candidate user IDs: the thread's followers plus the post author. + * @param WP_Comment $comment The system note recording the event. + * @param string $event The event: 'resolved' or 'reopen'. + * @param int $root_id The thread's top-level note ID. + */ + $recipients = apply_filters( 'wp_note_event_notification_recipients', $recipients, $comment, $event, $root_id ); + + $actor_id = (int) $comment->user_id; + + /* + * A reopen message can mention people, and the mention path has already + * emailed them about this same note. One insert, one email. + */ + $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content ); + $excluded = array_merge( array( $actor_id ), $mentioned ); + + $candidates = array(); + foreach ( $recipients as $user_id ) { + $user_id = (int) $user_id; + if ( $user_id > 0 && ! in_array( $user_id, $excluded, true ) ) { + $candidates[] = $user_id; + } + } + + if ( array() === $candidates ) { + return; + } + + // One user query for the whole audience rather than one per recipient. + cache_users( $candidates ); + + foreach ( $candidates as $user_id ) { + $user = get_userdata( $user_id ); + if ( ! $user || empty( $user->user_email ) ) { + continue; + } + + /* + * Same visibility bar as the mention and follower paths: notes are + * internal, so only users who can read the note are told about it. + */ + if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) { + continue; + } + + wp_send_note_event_notification( + $user, + $comment, + $post, + $root_id, + $event, + in_array( $user_id, $followers, true ) + ); + } +} + +/** + * Sends a single note thread event notification email. + * + * @since 7.2.0 + * + * @param WP_User $user The recipient. + * @param WP_Comment $comment The system note recording the event. + * @param WP_Post|null $post The post the thread belongs to. + * @param int $root_id The thread's top-level note ID. + * @param string $event The event: 'resolved' or 'reopen'. + * @param bool $is_follower Whether the recipient follows the thread. + * @return bool Whether the email was accepted for delivery by {@see wp_mail()}. + */ +function wp_send_note_event_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post, int $root_id, string $event, bool $is_follower = true ): bool { + $switched_locale = switch_to_user_locale( $user->ID ); + + $blogname = wp_specialchars_decode( get_bloginfo( 'name', 'display' ), ENT_QUOTES ); + $post_title = $post ? wp_specialchars_decode( get_the_title( $post ), ENT_QUOTES ) : ''; + $actor_name = $comment->comment_author ? $comment->comment_author : __( 'Someone' ); + + // Resolving carries no message; reopening usually does. + $content = wp_specialchars_decode( wp_strip_all_tags( $comment->comment_content ) ); + + $edit_link = ''; + if ( $post ) { + $previous_user_id = get_current_user_id(); + wp_set_current_user( $user->ID ); + $edit_link = (string) get_edit_post_link( $post->ID, 'url' ); + wp_set_current_user( $previous_user_id ); + } + + if ( 'resolved' === $event ) { + /* translators: 1: Name of the user who resolved the thread, 2: Post title. */ + $message = sprintf( __( '%1$s resolved a note thread on "%2$s".' ), $actor_name, $post_title ); + /* translators: Note thread resolved notification email subject. 1: Site title, 2: Post title. */ + $subject = sprintf( __( '[%1$s] A note thread on "%2$s" was resolved' ), $blogname, $post_title ); + } else { + /* translators: 1: Name of the user who reopened the thread, 2: Post title. */ + $message = sprintf( __( '%1$s reopened a note thread on "%2$s".' ), $actor_name, $post_title ); + /* translators: Note thread reopened notification email subject. 1: Site title, 2: Post title. */ + $subject = sprintf( __( '[%1$s] A note thread on "%2$s" was reopened' ), $blogname, $post_title ); + } + + $lines = array( $message, '' ); + if ( '' !== $content ) { + $lines[] = $content; + $lines[] = ''; + } + if ( $edit_link ) { + $lines[] = __( 'Edit This' ) . ': ' . $edit_link; + } + + $body = implode( "\n", $lines ); + + /* + * Only followers are offered the unfollow link. A post author who never + * joined the thread has no subscription to end, and the generic note emails + * to them answer to the site-wide setting instead. + */ + if ( $is_follower ) { + $body .= "\n\n"; + $body .= __( 'You are subscribed to this note thread. To stop receiving notifications about it, follow this link:' ); + $body .= "\n" . wp_get_note_unfollow_url( $root_id, (int) $user->ID ); + } + + /** + * Filters the note thread event notification email subject. + * + * @since 7.2.0 + * + * @param string $subject Email subject. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The system note recording the event. + * @param string $event The event: 'resolved' or 'reopen'. + */ + $subject = apply_filters( 'wp_note_event_notification_subject', $subject, $user, $comment, $event ); + + /** + * Filters the note thread event notification email body. + * + * @since 7.2.0 + * + * @param string $body Email body. + * @param WP_User $user Recipient. + * @param WP_Comment $comment The system note recording the event. + * @param string $event The event: 'resolved' or 'reopen'. + */ + $body = apply_filters( 'wp_note_event_notification_text', $body, $user, $comment, $event ); + + // Declared explicitly so a filtered default cannot turn the message into HTML. + $headers = 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"'; + + $sent = wp_mail( $user->user_email, $subject, $body, $headers ); + + if ( $switched_locale ) { + restore_previous_locale(); + } + + /** This action is documented in wp-includes/comment.php */ + do_action( 'wp_note_notification_sent', (int) $user->ID, $comment, $event, $sent ); + + return $sent; +} + /** * Sets the status of a comment. * diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 36f07a38ad29f..8fdf950721212 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -535,12 +535,13 @@ // Email notifications. add_action( 'comment_post', 'wp_new_comment_notify_moderator' ); add_action( 'comment_post', 'wp_new_comment_notify_postauthor' ); -add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' ); +add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor', 10, 3 ); add_action( 'rest_insert_comment', 'wp_route_post_author_mention_notification', 9, 3 ); add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 ); add_action( 'rest_insert_comment', 'wp_notify_note_followers', 11, 3 ); add_action( 'rest_insert_comment', 'wp_notify_new_mentions_on_note_update', 11, 3 ); add_action( 'rest_insert_comment', 'wp_maintain_note_followers', 12, 3 ); +add_action( 'rest_after_insert_comment', 'wp_notify_note_event', 10, 3 ); add_filter( 'wp_note_notification_text', 'wp_add_note_unfollow_link_to_email', 10, 3 ); add_action( 'admin_post_wp_note_unfollow', 'wp_handle_note_unfollow' ); add_action( 'admin_post_nopriv_wp_note_unfollow', 'wp_handle_note_unfollow' ); diff --git a/tests/phpunit/tests/comment/wpNotifyNoteEvent.php b/tests/phpunit/tests/comment/wpNotifyNoteEvent.php new file mode 100644 index 0000000000000..a8a55cdeaac12 --- /dev/null +++ b/tests/phpunit/tests/comment/wpNotifyNoteEvent.php @@ -0,0 +1,479 @@ + + */ + private array $sent = array(); + + /** + * Sets up shared fixtures. + * + * @param WP_UnitTest_Factory $factory Factory. + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$post_author = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$commenter = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$follower = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + self::$resolver = $factory->user->create_and_get( array( 'role' => 'editor' ) ); + + self::$post = $factory->post->create_and_get( array( 'post_author' => self::$post_author->ID ) ); + } + + /** + * Creates a user with the given role. + * + * @param string $role Role to assign. + * @return WP_User The created user. + */ + private static function create_user( string $role ): WP_User { + $user = self::factory()->user->create_and_get( array( 'role' => $role ) ); + assert( $user instanceof WP_User ); + return $user; + } + + public function set_up(): void { + parent::set_up(); + $this->sent = array(); + // Short-circuit wp_mail() and record what would have been sent. + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + public function tear_down(): void { + wp_set_current_user( 0 ); + parent::tear_down(); + } + + /** + * Records wp_mail() calls and short-circuits delivery. + * + * @param null $short_circuit Short-circuit value. + * @param array{ to: string|string[], subject: string, message: string } $atts wp_mail() arguments. + * @return bool Always true to indicate a "sent" message. + */ + public function capture_mail( $short_circuit, $atts ): bool { + $this->sent[] = array( + 'to' => (array) $atts['to'], + 'subject' => (string) $atts['subject'], + 'message' => (string) $atts['message'], + ); + return true; + } + + /** + * Returns the captured emails sent to the given address. + * + * @param string $email Recipient address. + * @return array + */ + private function emails_to( string $email ): array { + return array_values( + array_filter( + $this->sent, + fn ( $mail ) => in_array( $email, $mail['to'], true ) + ) + ); + } + + /** + * Builds mention markup for a user. + * + * @param int $user_id User to mention. + * @param string $label Visible mention label. + * @return string Mention chip markup. + */ + private static function mention( int $user_id, string $label = '@User' ): string { + return sprintf( '%s', $user_id, $label ); + } + + /** + * Starts a note thread through the REST API and returns its root note. + * + * Dispatching real requests keeps core's own handlers in the picture, so + * the tests prove the suppression and dedupe interplay rather than the + * plugin's handlers in isolation. + * + * @param string $content Note content. + * @param int $user_id Author user ID. + * @return WP_Comment The thread's top-level note. + */ + private function start_thread( string $content, int $user_id ): WP_Comment { + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'content', $content ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status(), 'Failed to create the thread root.' ); + + $data = $response->get_data(); + $comment = get_comment( $data['id'] ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + + $this->sent = array(); + + return $comment; + } + + /** + * Resolves or reopens a thread the way the editor does: a child note + * carrying the resolution status as comment meta. + * + * @param WP_Comment $root The thread's top-level note. + * @param int $user_id The user performing the action. + * @param string $event 'resolved' or 'reopen'. + * @param string $content Message to include, if any. + * @return WP_Comment The system note recording the event. + */ + private function post_event( WP_Comment $root, int $user_id, string $event, string $content = '' ): WP_Comment { + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'parent', $root->comment_ID ); + $request->set_param( 'content', $content ); + $request->set_param( 'status', 'resolved' === $event ? 'approved' : 'hold' ); + $request->set_param( 'meta', array( '_wp_note_status' => $event ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status(), 'Failed to create the system note.' ); + + $data = $response->get_data(); + $comment = get_comment( $data['id'] ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + + return $comment; + } + + /** + * A resolve reaches the thread's followers with copy about the event, not + * the generic "added a note" email whose body would be empty. + * + * @covers ::wp_notify_note_event + */ + public function test_resolving_notifies_followers_about_the_event(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $emails = $this->emails_to( self::$follower->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'was resolved', $emails[0]['subject'] ); + $this->assertStringContainsString( 'resolved a note thread', $emails[0]['message'] ); + $this->assertStringNotContainsString( 'added a note', $emails[0]['message'] ); + } + + /** + * @covers ::wp_notify_note_event + */ + public function test_reopening_notifies_followers_and_carries_the_message(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'reopen', 'Still broken on mobile' ); + + $emails = $this->emails_to( self::$follower->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'was reopened', $emails[0]['subject'] ); + $this->assertStringContainsString( 'Still broken on mobile', $emails[0]['message'] ); + } + + /** + * The post author hears about the event instead of core's generic "a new + * note was added" email, which for a resolve would have an empty body. + * + * @covers ::wp_new_comment_via_rest_notify_postauthor + */ + public function test_post_author_gets_the_event_email_instead_of_the_generic_one(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $emails = $this->emails_to( self::$post_author->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'was resolved', $emails[0]['subject'] ); + } + + /** + * The suppression is scoped to the note carrying the event: an ordinary + * reply in the same request cycle still reaches the post author. + * + * @covers ::wp_new_comment_via_rest_notify_postauthor + */ + public function test_plain_replies_still_notify_the_post_author_and_followers(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + $this->sent = array(); + + // A regular reply, with no resolution metadata. + wp_set_current_user( self::$resolver->ID ); + $request = new WP_REST_Request( 'POST', '/wp/v2/comments' ); + $request->set_param( 'post', self::$post->ID ); + $request->set_param( 'type', 'note' ); + $request->set_param( 'parent', $root->comment_ID ); + $request->set_param( 'content', 'One more thing' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + + $follower_emails = $this->emails_to( self::$follower->user_email ); + $this->assertCount( 1, $follower_emails ); + $this->assertStringContainsString( 'a note you follow', $follower_emails[0]['subject'] ); + $this->assertCount( 1, $this->emails_to( self::$post_author->user_email ) ); + } + + /** + * @covers ::wp_notify_note_event + */ + public function test_the_actor_is_not_emailed_about_their_own_action(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$resolver->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $this->assertEmpty( $this->emails_to( self::$resolver->user_email ) ); + } + + /** + * The post author following their own post is one recipient, not two. + * + * @covers ::wp_notify_note_event + */ + public function test_a_following_post_author_receives_exactly_one_email(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$post_author->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $this->assertCount( 1, $this->emails_to( self::$post_author->user_email ) ); + } + + /** + * A reopen message can mention someone. They get the mention email, which + * says more than the event email, and only that one. + * + * @covers ::wp_notify_note_event + */ + public function test_mentioned_users_get_the_mention_email_only(): void { + $mentioned = self::create_user( 'editor' ); + + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( $mentioned->ID ) ); + + $this->post_event( + $root, + self::$resolver->ID, + 'reopen', + 'Back to you ' . self::mention( $mentioned->ID ) + ); + + $emails = $this->emails_to( $mentioned->user_email ); + $this->assertCount( 1, $emails ); + $this->assertStringContainsString( 'You were mentioned', $emails[0]['subject'] ); + } + + /** + * Notes are internal, so the event email is held to the same visibility + * bar as the note itself. + * + * @covers ::wp_notify_note_event + */ + public function test_followers_without_note_access_are_not_emailed(): void { + $subscriber = self::create_user( 'subscriber' ); + + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( $subscriber->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $this->assertEmpty( $this->emails_to( $subscriber->user_email ) ); + } + + /** + * The site-wide setting silences event emails along with the rest. + * + * @covers ::wp_notify_note_event + */ + public function test_no_event_emails_while_notes_notifications_are_off(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + update_option( 'wp_notes_notify', 0 ); + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + update_option( 'wp_notes_notify', 1 ); + + $this->assertEmpty( $this->sent ); + } + + /** + * @covers ::wp_notify_note_event + */ + public function test_recipients_filter_can_add_and_remove_users(): void { + $outsider = self::create_user( 'editor' ); + + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + $follower_id = self::$follower->ID; + add_filter( + 'wp_note_event_notification_recipients', + static function ( $recipients ) use ( $outsider, $follower_id ) { + $recipients = array_values( array_diff( $recipients, array( $follower_id ) ) ); + $recipients[] = $outsider->ID; + return $recipients; + } + ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $this->assertEmpty( $this->emails_to( self::$follower->user_email ) ); + $this->assertCount( 1, $this->emails_to( $outsider->user_email ) ); + } + + /** + * @covers ::wp_send_note_event_notification + */ + public function test_event_emails_can_be_rewritten_by_filters(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + add_filter( + 'wp_note_event_notification_subject', + static fn ( $subject, $user, $comment, $event ) => "[{$event}] rewritten", + 10, + 4 + ); + add_filter( + 'wp_note_event_notification_text', + static fn ( $body, $user, $comment, $event ) => "body for {$event}", + 10, + 4 + ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $emails = $this->emails_to( self::$follower->user_email ); + $this->assertCount( 1, $emails ); + $this->assertSame( '[resolved] rewritten', $emails[0]['subject'] ); + $this->assertSame( 'body for resolved', $emails[0]['message'] ); + } + + /** + * Followers can leave the thread from the email; a post author who never + * joined it has no subscription to offer them out of. + * + * @covers ::wp_send_note_event_notification + */ + public function test_only_followers_are_offered_the_unfollow_link(): void { + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'resolved' ); + + $follower_email = $this->emails_to( self::$follower->user_email )[0]; + $this->assertStringContainsString( + wp_get_note_unfollow_url( (int) $root->comment_ID, self::$follower->ID ), + $follower_email['message'] + ); + + $author_email = $this->emails_to( self::$post_author->user_email )[0]; + $this->assertStringNotContainsString( 'wp_note_unfollow', $author_email['message'] ); + } + + /** + * @covers ::wp_notify_note_event + */ + public function test_notification_sent_action_reports_the_event_context(): void { + $fired = array(); + add_action( + 'wp_note_notification_sent', + static function ( $user_id, $comment, $context, $sent ) use ( &$fired ): void { + $fired[] = array( + 'user_id' => $user_id, + 'context' => $context, + 'sent' => $sent, + ); + }, + 10, + 4 + ); + + $root = $this->start_thread( 'Please look at this', self::$commenter->ID ); + wp_add_note_followers( $root->comment_ID, array( self::$follower->ID ) ); + + $this->post_event( $root, self::$resolver->ID, 'reopen', 'Once more' ); + + $this->assertContains( + array( + 'user_id' => self::$follower->ID, + 'context' => 'reopen', + 'sent' => true, + ), + $fired + ); + } + + /** + * Nothing here may touch comment types other than notes. + * + * @covers ::wp_notify_note_event + * @covers ::wp_new_comment_via_rest_notify_postauthor + */ + public function test_regular_comments_are_left_alone(): void { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => self::$post->ID, + 'comment_content' => 'A regular comment', + 'user_id' => self::$commenter->ID, + ) + ); + $comment = get_comment( $comment_id ); + $this->assertInstanceOf( WP_Comment::class, $comment ); + update_comment_meta( $comment_id, '_wp_note_status', 'resolved' ); + + do_action( 'rest_after_insert_comment', $comment, null, true ); + + $this->assertEmpty( $this->sent ); + } +}