diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 7557e9258c87f..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 ); } /** @@ -2722,9 +2737,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 +2782,841 @@ 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, $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; +} + +/** + * 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, implode( "\n", $lines ), $headers ); + $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; } @@ -4506,9 +5349,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 +5373,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..8fdf950721212 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -535,8 +535,16 @@ // 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' ); 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/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 ); + } +} 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 );