Make WordPress Core

Changeset 63012


Ignore:
Timestamp:
08/04/2026 09:30:53 PM (3 weeks ago)
Author:
adamsilverstein
Message:

Comments: Notify users mentioned in a note.

Introduce wp_notify_note_mentions() on rest_insert_comment, alongside the existing post author notification, which parses those IDs out of the saved note and emails each mentioned user in their own locale with a link back to the post editor.

Recipients are limited to users who can edit_comment the note, matching WP_REST_Comments_Controller::check_read_permission(), so an email cannot carry note content to someone who cannot see the note in the editor. The note's own author is skipped, as is the post author, who wp_new_comment_via_rest_notify_postauthor() already notifies about every note. Only note creation notifies, and the existing wp_notes_notify option turns the whole path off.

See related Gutenberg pull request: https://github.com/WordPress/gutenberg/pull/79606.

Follow-up to [62832].

Props westonruter, mamaduka.
Fixes #65639.

Location:
trunk
Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/comment.php

    r62928 r63012  
    25582558
    25592559/**
     2560 * Extracts the mentioned user IDs from note content.
     2561 *
     2562 * Mentions are stored as chips carrying the `wp-note-mention` class plus a
     2563 * `user-N` class token holding the mentioned user's ID:
     2564 * `<span class="wp-note-mention user-N">@Name</span>`. Only elements that
     2565 * carry both classes are treated as mentions.
     2566 *
     2567 * @since 7.1.0
     2568 *
     2569 * @param string $content Note (comment) content, as stored.
     2570 * @return int[] Unique, positive mentioned user IDs.
     2571 * @phpstan-return list<positive-int>
     2572 */
     2573function wp_get_note_mentioned_user_ids( string $content ): array {
     2574        if ( ! str_contains( $content, 'wp-note-mention' ) ) {
     2575                return array();
     2576        }
     2577
     2578        $user_ids  = array();
     2579        $processor = new WP_HTML_Tag_Processor( $content );
     2580        while (
     2581                $processor->next_tag(
     2582                        array(
     2583                                'tag_name'   => 'SPAN',
     2584                                'class_name' => 'wp-note-mention',
     2585                        )
     2586                )
     2587        ) {
     2588                foreach ( $processor->class_list() as $class_name ) {
     2589                        if ( 1 === preg_match( '/^user-(\d+)$/', $class_name, $matches ) ) {
     2590                                $user_id = (int) $matches[1];
     2591                                if ( $user_id > 0 ) {
     2592                                        $user_ids[] = $user_id;
     2593                                }
     2594                                break;
     2595                        }
     2596                }
     2597        }
     2598
     2599        return array_values( array_unique( $user_ids, SORT_NUMERIC ) );
     2600}
     2601
     2602/**
     2603 * Notifies mentioned users about a new note.
     2604 *
     2605 * Runs on {@see 'rest_insert_comment'} alongside the post author notification.
     2606 * The recipient set is the users mentioned in this note, minus the note's own
     2607 * author (a user is not notified about their own note) and the post author,
     2608 * who is already notified about every note by
     2609 * {@see wp_new_comment_via_rest_notify_postauthor()}.
     2610 *
     2611 * Only fires when a note is created, not when an existing one is edited, so
     2612 * correcting a note does not re-notify everyone who already received it.
     2613 *
     2614 * @since 7.1.0
     2615 *
     2616 * @param WP_Comment|null $comment  The note that was just inserted. (May only be null as an edge case.)
     2617 * @param mixed           $request  The REST request. Unused.
     2618 * @param bool            $creating Whether this is a create (true) or update (false).
     2619 */
     2620function wp_notify_note_mentions( ?WP_Comment $comment, $request = null, bool $creating = true ): void {
     2621        if ( ! $creating || ! $comment ) {
     2622                return;
     2623        }
     2624
     2625        if ( 'note' !== $comment->comment_type ) {
     2626                return;
     2627        }
     2628
     2629        // Share the single user-facing notes notification preference.
     2630        if ( ! get_option( 'wp_notes_notify', 1 ) ) {
     2631                return;
     2632        }
     2633
     2634        $mentioned = wp_get_note_mentioned_user_ids( $comment->comment_content );
     2635
     2636        $author_id       = (int) $comment->user_id;
     2637        $comment_post_id = (int) $comment->comment_post_ID;
     2638        $post            = $comment_post_id ? get_post( $comment_post_id ) : null;
     2639        $post_author_id  = $post ? (int) $post->post_author : 0;
     2640
     2641        /*
     2642         * The recipient set is bounded and small (one note's mentions), so emails
     2643         * are sent synchronously here. If notification volume ever warrants it,
     2644         * the right fix is to offload delivery to a background queue rather than
     2645         * throttle within the request.
     2646         */
     2647        foreach ( $mentioned as $user_id ) {
     2648                // Never notify the author about their own note.
     2649                if ( $user_id === $author_id ) {
     2650                        continue;
     2651                }
     2652
     2653                // The post author is already notified of every note.
     2654                if ( $user_id === $post_author_id ) {
     2655                        continue;
     2656                }
     2657
     2658                $user = get_userdata( $user_id );
     2659                if ( ! $user || empty( $user->user_email ) ) {
     2660                        continue;
     2661                }
     2662
     2663                /*
     2664                 * Only notify users who can actually read the note. Notes are
     2665                 * internal: WP_REST_Comments_Controller::check_read_permission()
     2666                 * only exposes a note to its author or to users who can edit it, so
     2667                 * the email audience is held to the same bar. A plain read_post
     2668                 * check would leak note content to, for example, subscribers on a
     2669                 * public post, who cannot see the note in the editor.
     2670                 */
     2671                if ( ! user_can( $user_id, 'edit_comment', $comment->comment_ID ) ) {
     2672                        continue;
     2673                }
     2674
     2675                wp_send_note_notification( $user, $comment, $post );
     2676        }
     2677}
     2678
     2679/**
     2680 * Sends a single note mention notification email.
     2681 *
     2682 * The email is composed in the recipient's locale, matching how other
     2683 * user-directed notifications are composed, and links to the post editor the
     2684 * same way the post author's note notification does.
     2685 *
     2686 * @since 7.1.0
     2687 *
     2688 * @param WP_User      $user    The recipient.
     2689 * @param WP_Comment   $comment The note that triggered the notification.
     2690 * @param WP_Post|null $post    The post the note belongs to.
     2691 * @return bool Whether the email was accepted for delivery by {@see wp_mail()}.
     2692 */
     2693function wp_send_note_notification( WP_User $user, WP_Comment $comment, ?WP_Post $post ): bool {
     2694        $switched_locale = switch_to_user_locale( $user->ID );
     2695
     2696        /*
     2697         * The site title and the post title are escaped on the way into the database,
     2698         * and note content is stored as HTML. Both are reversed once here for the
     2699         * plain text arena of emails. Decoding a second time would go too far and
     2700         * resolve entities the author meant to be read literally.
     2701         */
     2702        $blogname    = wp_specialchars_decode( get_bloginfo( 'name', 'display' ), ENT_QUOTES );
     2703        $post_title  = $post ? wp_specialchars_decode( get_the_title( $post ), ENT_QUOTES ) : '';
     2704        $author_name = $comment->comment_author ? $comment->comment_author : __( 'Someone' );
     2705        $content     = wp_specialchars_decode( wp_strip_all_tags( $comment->comment_content ) );
     2706
     2707        /*
     2708         * The rest of the message is composed for the recipient, and so is the editor
     2709         * link: get_edit_post_link() answers for whoever is current, which here is the
     2710         * note's author over REST and nobody at all under WP-Cron.
     2711         */
     2712        $edit_link = '';
     2713        if ( $post ) {
     2714                $previous_user_id = get_current_user_id();
     2715                wp_set_current_user( $user->ID );
     2716                $edit_link = (string) get_edit_post_link( $post->ID, 'url' );
     2717                wp_set_current_user( $previous_user_id );
     2718        }
     2719
     2720        /* translators: 1: Note author's name, 2: Post title. */
     2721        $message = sprintf( __( '%1$s mentioned you in a note on "%2$s".' ), $author_name, $post_title );
     2722        /* translators: Note mention notification email subject. 1: Site title, 2: Post title. */
     2723        $subject = sprintf( __( '[%1$s] You were mentioned in a note on "%2$s"' ), $blogname, $post_title );
     2724
     2725        $lines = array( $message, '' );
     2726        if ( '' !== $content ) {
     2727                $lines[] = $content;
     2728        }
     2729        if ( $edit_link ) {
     2730                $lines[] = '';
     2731                $lines[] = __( 'Edit This' ) . ': ' . $edit_link;
     2732        }
     2733
     2734        // Declared explicitly so a filtered default cannot turn the message into HTML.
     2735        $headers = 'Content-Type: text/plain; charset="' . get_option( 'blog_charset' ) . '"';
     2736
     2737        $sent = wp_mail( $user->user_email, $subject, implode( "\n", $lines ), $headers );
     2738
     2739        if ( $switched_locale ) {
     2740                restore_previous_locale();
     2741        }
     2742
     2743        return $sent;
     2744}
     2745
     2746/**
    25602747 * Sets the status of a comment.
    25612748 *
  • trunk/src/wp-includes/default-filters.php

    r62970 r63012  
    537537add_action( 'comment_post', 'wp_new_comment_notify_postauthor' );
    538538add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' );
     539add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 );
    539540add_action( 'after_password_reset', 'wp_password_change_notification' );
    540541add_action( 'register_new_user', 'wp_send_new_user_notifications' );
Note: See TracChangeset for help on using the changeset viewer.