Skip to content

Comments: Notify users mentioned in a note - #12548

Closed
adamsilverstein wants to merge 21 commits into
WordPress:trunkfrom
adamsilverstein:add/notes-mention-notifications
Closed

adamsilverstein wants to merge 21 commits into
WordPress:trunkfrom
adamsilverstein:add/notes-mention-notifications

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jul 15, 2026 •

Copy link
Copy Markdown
Member

Backports the notification layer from the Gutenberg Notes @mention work (WordPress/gutenberg#79606, still open upstream). Split out of #12503, which covered the kses allowance for the mention markup.

Trac ticket: #65639. The kses half is on #65622.

Upstream status: the source Gutenberg PR (#79606) is approved but has not merged yet, so this backport should not land until it does.

What

A user who is @mentioned in a note gets an email about that note. That is the whole feature.

This is a straight port, deliberately not a superset: the same three functions, the same audience rules, the same twelve tests. The only differences from the plugin copy are core conventions - wp_ prefixes, no scalar type hints or return types, no text domain, @var docblocks on the test properties, and the WP_Comment check moved from the signature into the function body. An earlier revision of this PR also carried a per-thread followers model and a deep link into the specific thread; both were dropped upstream and are not here.

On rest_insert_comment for note comments - alongside the existing post-author notification - wp_notify_note_mentions() parses the mentions out of the saved content and emails each mentioned user.

Mentions are stored as chips carrying the mentioned user's ID in a class token: <span class="wp-note-mention user-N">@Name</span>. Only elements carrying both classes are treated as mentions. The email is composed in the recipient's locale via switch_to_user_locale() and links to the post editor with get_edit_post_link(), the same way wp_notify_postauthor() does for the post author's note email.

Audience rules:

  • The post author is excluded - wp_new_comment_via_rest_notify_postauthor() already notifies them of every note, so including them here would send a duplicate.
  • The note's own author is never notified about their own note.
  • Recipients are limited to users who can edit_comment the note, matching WP_REST_Comments_Controller::check_read_permission() for notes, so emails cannot leak note content to users who cannot see the note in the editor.
  • Notifications fire only when a note is created; editing a note does not re-notify.
  • Everything honors the existing wp_notes_notify option.

New public API

Three functions, no new filters:

  • wp_get_note_mentioned_user_ids( $content )
  • wp_notify_note_mentions( $comment, $request, $creating )
  • wp_send_note_notification( $user, $comment, $post )

Existing hooks cover extension: comment_notification_recipients already runs on every note through wp_notify_postauthor(), so extra recipients can be added there; pre_wp_mail and wp_mail can suppress or reroute delivery to another channel; and wp_send_note_notification() is public, so a plugin can notify anyone it likes from its own rest_insert_comment callback.

Running alongside the Gutenberg plugin

The plugin ships the same feature in its lib/compat/wordpress-7.1/ layer until this lands, so with both active rest_insert_comment would carry two mention callbacks and email every mentioned user twice. The dedupe belongs on the plugin side, since core has no way to know about the plugin, and it is already in place upstream: the compat file removes wp_notify_note_mentions and keeps its own copy, matching how the plugin handles other backported hooks. Core's wp_new_comment_via_rest_notify_postauthor() is deliberately left attached - the mention path never emails the post author, so those two do not overlap. Nothing to do in this PR; noting it so the coexistence story is written down somewhere.

Testing

tests/phpunit/tests/comment/wpNotifyNoteMentions.php covers mention parsing (non-mention spans and plain anchors ignored, IDs deduped), the mentioned-user email, email content and the editor link, author self-exclusion, post-author exclusion, capability gating, nonexistent-user mentions, the wp_notes_notify off switch, and - through real REST dispatches - that creating a note via POST /wp/v2/comments triggers the email while updating one does not, which guards the rest_insert_comment wiring itself.

npm run test:php -- --filter Tests_Comment_WpNotifyNoteMentions

12 tests pass locally.

Manual steps:

  1. Create a second user with an editor role and an email address you can inspect (or install an email logger).
  2. Add a note to a block in the editor and @mention the second user.
  3. The mentioned user receives a "[Site] You were mentioned in a note on ..." email linking to the post editor.
  4. Mention yourself: no email. Mention the post author: they get the regular "new note" email only, no duplicate.

Commit message

Comments: Notify users mentioned in a note.

A note that @mentions a collaborator had no way of reaching them: the mention was stored as a chip carrying the user's ID, `<span class="wp-note-mention user-N">@Name</span>`, but nothing acted on it, so the mentioned user only saw the note if they happened to open the editor. 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.

@github-actions

github-actions Bot commented Jul 15, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein, westonruter, mamaduka.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Port the notification layer from the Gutenberg Notes @mention work. On
rest_insert_comment for note comments, parse the user-N mention classes
out of the saved content, email each mentioned user plus any existing
followers of the thread, and subscribe the note author and everyone they
mention. Followers are stored as one _wp_note_followers meta row per user
on the thread's top-level note so concurrent replies cannot clobber each
other, and the meta is registered for REST so follower management UI can
build on it.

The post author is excluded because wp_new_comment_via_rest_notify_postauthor()
already notifies them of every note, the note's own author is never
notified about their own note, and recipients are limited to users who
can edit_comment the note so note content cannot leak. Everything honors
the wp_notes_notify option.

See related Gutenberg pull request: WordPress/gutenberg#79606.

Props mamaduka.
Fixes #65639.
@adamsilverstein
adamsilverstein force-pushed the add/notes-mention-notifications branch from fc3e164 to 859ba08 Compare July 15, 2026 20:32
Drop the per-thread followers model - the follower getters and setters,
the `_wp_note_followers` meta and its registration, the follower union in
the recipient set, and the `$was_mentioned` argument on the email filters.
Subscription semantics are a separate concern from notifying a mentioned
user, and should not ride along with this feature.

Match the `<span class="wp-note-mention user-N">` chip markup that landed
with the kses allowance; the parser still looked for anchors, so it found
no mentions and sent no email.

Also drop the notification subject and text filters, leaving the
recipients filter as the single extension point.

Follow-up to the Gutenberg source PR WordPress/gutenberg#79606.
@adamsilverstein adamsilverstein changed the title Comments: Notify mentioned users and thread followers for Notes @mentions Comments: Notify users mentioned in a note Aug 3, 2026
Existing hooks already cover the cases it was added for. The long-standing
`comment_notification_recipients` filter runs on every note through
wp_notify_postauthor(), so extra recipients can be added there; `pre_wp_mail`
and `wp_mail` can suppress or reroute delivery to another channel; and
wp_send_note_notification() is public, so a plugin can notify anyone it
likes from its own `rest_insert_comment` callback.

That leaves this feature with no new filters at all.

@Mamaduka Mamaduka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good and I've already reviewed same code in Gutenberg repo. But would appriciate second opinion from @westonruter.

@adamsilverstein adamsilverstein self-assigned this Aug 4, 2026
Comment thread src/wp-includes/comment.php Outdated
Comment thread src/wp-includes/comment.php Outdated
Comment thread src/wp-includes/comment.php Outdated
Comment thread src/wp-includes/comment.php Outdated
Comment thread src/wp-includes/comment.php Outdated
Comment thread src/wp-includes/comment.php Outdated
Comment thread src/wp-includes/comment.php Outdated
adamsilverstein and others added 5 commits August 4, 2026 11:15
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Co-authored-by: Weston Ruter <westonruter@gmail.com>
The post title and the site title were decoded on assignment and again when
the assembled subject reached wp_mail(), resolving entities the author meant
to be read literally. Note content was also decoded before its markup was
stripped, so escaped text such as "&lt;code&gt;" was treated as a tag and
dropped from the message body.

@westonruter westonruter left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note on provenance: @westonruter asked Claude (Anthropic's Claude Opus 5, via Claude Code) to review this PR, and is posting the output here unedited. Findings were checked against a local wordpress-develop checkout at the PR head — the TypeError below was reproduced by running PHPUnit, not inferred. Treat it as a machine-assisted review: useful signal, but please verify before acting.

Overview

Backports the mention-notification layer from Gutenberg #79606. Three new public functions in src/wp-includes/comment.php, one add_action() in default-filters.php, and a 406-line test file. On rest_insert_comment for note comments, mentions (<span class="wp-note-mention user-N">) are parsed out of the saved content and each mentioned user is emailed, minus the note author and the post author.

The design is sound and well-documented. The audience rules are right: the edit_comment gate matches WP_REST_Comments_Controller::check_read_permission() for notes (class-wp-rest-comments-controller.php:1920-1942 — author-or-edit_comment), and map_meta_cap() resolves edit_comment to edit_post of the note's post (capabilities.php:554-585), so no leak to subscribers. Tests pass (14, not the 12 the description claims), PHPCS is clean.

Blocking issue (Resolved by e5dcf46 ✅ )

Fatal TypeError from the WP_Comment type hint on a hook callback

comment.php:2620 — function wp_notify_note_mentions( WP_Comment $comment, ... ).

The controller fires the hook with get_comment( $comment_id ) (class-wp-rest-comments-controller.php:806-819), which returns null if the row is gone by then. Two reachable paths:

  1. Core-only: handle_status_param() runs before the get_comment() at line 806. With status=trash (no enum on the schema — sanitize_key only, line 1612), wp_trash_comment() hard-deletes when EMPTY_TRASH_DAYS is 0 (comment.php:1609-1612). POST /wp/v2/comments {type: note, status: trash} on such a site → null at the hook.
  2. Any plugin on wp_insert_comment that deletes the comment.

Path 2 confirmed end-to-end with a throwaway PHPUnit test against this branch:

TypeError: wp_notify_note_mentions(): Argument #1 ($comment) must be of type WP_Comment, null given,
  called in /var/www/src/wp-includes/class-wp-hook.php on line 353
  /var/www/src/wp-includes/comment.php:2620
  /var/www/src/wp-includes/rest-api/endpoints/class-wp-rest-comments-controller.php:819

The sibling callback on the same hook handles this correctly and no-ops:

function wp_new_comment_via_rest_notify_postauthor( $comment ) {
	if ( $comment instanceof WP_Comment && 'note' === $comment->comment_type ) {

So this is a regression in robustness relative to the code right above it. The PR description says "the WP_Comment check moved from the signature into the function body" — that is what should be there, but the code does the opposite. Suggested fix:

function wp_notify_note_mentions( $comment, $request = null, $creating = true ) {
	if ( ! $creating || ! ( $comment instanceof WP_Comment ) || 'note' !== $comment->comment_type ) {
		return;
	}

This generalizes: hook callbacks should not carry hard class type hints, because do_action() has no contract enforcement and a TypeError in WP_Hook::apply_filters() is unrecoverable. Same reasoning applies to bool $creating (a caller passing null for a non-nullable scalar throws in coercive mode).

Should fix before landing

Missing filters

wp_send_note_notification() offers no filters at all, while every comparable core function does — wp_notify_postauthor() has comment_notification_recipients, comment_notification_subject, comment_notification_text, and comment_notification_headers (pluggable.php:1779, 1867, 1972, 1982). The PR argues pre_wp_mail/wp_mail cover it, but those are blunt: you cannot adjust this email's subject without string-matching, and comment_notification_recipients runs on the post-author path, not this one. At minimum a note_mention_notification_text/_subject pair would match the established shape and cost nothing. This is the point most likely to draw committer pushback.

Editor link is built with the sender's capabilities

comment.php — $edit_link = $post ? get_edit_post_link( $post->ID, 'url' ) : '';

Everything else in this function is composed in the recipient's context (switch_to_user_locale( $user->ID )), but get_edit_post_link() reads current_user_can(). The test file has to work around this explicitly:

/*
 * The editor link comes from get_edit_post_link(), which is scoped to
 * the current user; in the REST flow that is the note's author.
 */
wp_set_current_user( self::$commenter->ID );

In the REST path this is benign — note creation already requires current_user_can( 'edit_post' ) (controller line 563) — but the function is public API, so any non-REST caller silently produces link-less emails. test_mentioned_user_is_emailed() in fact exercises that case without noticing. Gate on the recipient instead:

$edit_link = ( $post && user_can( $user->ID, 'edit_post', $post->ID ) )
	? admin_url( 'post.php?post=' . $post->ID . '&action=edit' )
	: '';

Minor

  • No email headers. wp_notify_postauthor() sends Content-Type: text/plain; charset=<blog_charset> (pluggable.php:1852-1853). This sends none, so wp_mail()'s defaults apply. Functionally fine, but inconsistent with the sibling notification.
  • @param mixed $request — inconsistent with the strictly-typed $comment, and the hook documents it as WP_REST_Request. Should be WP_REST_Request $request in the docblock (untyped in the signature).
  • Silent gap for a mentioned post author. The post author is skipped on the assumption wp_new_comment_via_rest_notify_postauthor() covers them — but that path is gated by the notify_post_author filter (comment.php:2533). If a plugin returns false there, a mentioned post author gets nothing at all. Worth a sentence in the inline comment at minimum.
  • class_list() is case-sensitive in no-quirks mode, so USER-5 wouldn't match /^user-(\d+)$/. The markup is core-generated, so this is theoretical — preg_match( '/^user-(\d+)$/i', ... ) would close it for free.
  • No length cap on $content — a long note is emailed verbatim.

Process

  • Stale PR description. It claims "no scalar type hints or return types" and "the WP_Comment check moved from the signature into the function body" — the code has string $content, : array, : void, : bool, ?WP_Post, and the check in the signature. It also says 12 tests; there are 14. Worth refreshing before a committer reads it.
  • Version timing. @since 7.1.0 against a trunk at 7.1-beta4-src. New public API and new translatable strings after Beta 1 need an explicit exception from the release squad, or a bump to 7.2.

Verdict

Good, careful work — the capability gating in particular is correct and well-justified, and the test coverage (including two real REST dispatches guarding the hook wiring) is above average for a change this size. The type hint on the hook callback is a genuine fatal and should be removed before this lands. The missing filters are the main thing a core committer is likely to hold it on.

@adamsilverstein

adamsilverstein commented Aug 4, 2026 •

Copy link
Copy Markdown
Member Author

I'm fine adding the suggested filters, the argument for not adding them was to reduce new public APIs when we know a new full blown notification system is needed. I went back and forth on it when working on this. What do you think @westonruter?

@westonruter

Copy link
Copy Markdown
Member

IMO, the filters can be added later as need arises.

@westonruter

Copy link
Copy Markdown
Member
  • No email headers. wp_notify_postauthor() sends Content-Type: text/plain; charset=<blog_charset> (pluggable.php:1852-1853). This sends none, so wp_mail()'s defaults apply. Functionally fine, but inconsistent with the sibling notification.

This makes sense to me to add. We wouldn't want HTML email to be sent accidentally, right?

@westonruter

Copy link
Copy Markdown
Member

Editor link is built with the sender's capabilities

comment.php — $edit_link = $post ? get_edit_post_link( $post->ID, 'url' ) : '';

Everything else in this function is composed in the recipient's context (switch_to_user_locale( $user->ID )), but get_edit_post_link() reads current_user_can().

This review also makes sense to me. Unless the functions get marked as private, it should probably make sure that the edit post link is constructed appropriately for the recipient. Also consider the case where the functions could be called during WP Cron when no user is logged-in, in which case no edit link would be included.

@adamsilverstein

Copy link
Copy Markdown
Member Author
  • No email headers. wp_notify_postauthor() sends Content-Type: text/plain; charset=<blog_charset> (pluggable.php:1852-1853). This sends none, so wp_mail()'s defaults apply. Functionally fine, but inconsistent with the sibling notification.

This makes sense to me to add. We wouldn't want HTML email to be sent accidentally, right?

right

This review also makes sense to me. Unless the functions get marked as private, it should probably make sure that the edit post link is constructed appropriately for the recipient. Also consider the case where the functions could be called during WP Cron when no user is logged-in, in which case no edit link would be included.

Good point, will adjust.

The message is composed for the recipient, but the editor link was not:
get_edit_post_link() answers for whoever is current, which is the note's
author over REST and nobody at all under WP-Cron, so the link was either
built against the wrong capabilities or dropped entirely. Switch to the
recipient for the length of that call.

Also declare the plain text content type explicitly, matching
wp_notify_postauthor(), so a filtered wp_mail() default cannot turn the
message into HTML.
@adamsilverstein

Copy link
Copy Markdown
Member Author

Both addressed in 6b687d1.

Content type. Now sends Content-Type: text/plain; charset=<blog_charset> as a header, same as wp_notify_postauthor() does at pluggable.php:1852-1853. Right, the point is that a filtered wp_mail() default should not be able to turn this into HTML.

Editor link. Switches to the recipient for the length of the get_edit_post_link() call and restores the previous user right after, so it brackets the same way switch_to_user_locale() / restore_previous_locale() already does in that function:

$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 );
}

Good call on WP-Cron - I wrote a test that sets the current user to 0 before notifying, and against the old code it failed with no link in the message at all. Two new tests cover both changes (test_editor_link_is_built_for_the_recipient, test_email_is_sent_as_plain_text), verified red first. 16/16 green, phpcs and phpstan clean.

Worth noting wp_notify_postauthor() has the same problem on its note branch - get_edit_post_link( $comment->comment_post_ID, 'url' ) at pluggable.php:1945 is evaluated against whoever is current, not the post author it is mailing. Same class of thing as the double wp_specialchars_decode() on $blogname I mentioned above. Both look worth a separate ticket rather than widening this one.

On the filters - agreed, leaving them out and adding later as the need arises.

@adamsilverstein

Copy link
Copy Markdown
Member Author

Fixed in https://core.trac.wordpress.org/changeset/63012.

cc: @t-hamano

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants