From 0bad6d5563091974398d4553c300c1db71f2c17c Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 15:16:35 -0500 Subject: [PATCH 1/8] FreeScout: Add WPOrgApi, for WordPress.org to read saved replies WordPress.org's scripts write their emails from the Plugins mailbox's saved replies, which they read from HelpScout's API. FreeScout's API has no endpoint for them, so WPOrgApi adds one: a mailbox's saved reply by name, from the Saved Replies module, for requests WordPress.org signs with the secret the modules already share with it. Co-Authored-By: Claude Opus 5.5 --- freescout.wordpress.net/AGENTS.md | 1 + .../Modules/WPOrgApi/Config/config.php | 18 ++ .../Http/Controllers/SavedReplyController.php | 76 +++++++ .../Modules/WPOrgApi/Http/routes.php | 20 ++ .../Providers/WPOrgApiServiceProvider.php | 63 ++++++ .../Modules/WPOrgApi/Public/img/icon.svg | 8 + .../Modules/WPOrgApi/README.md | 13 ++ .../WPOrgApi/Services/SignedRequest.php | 68 +++++++ .../Modules/WPOrgApi/module.json | 21 ++ .../Modules/WPOrgApi/tests/SavedReplyTest.php | 185 ++++++++++++++++++ freescout.wordpress.net/README.md | 5 +- 11 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/Config/config.php create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/README.md create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/module.json create mode 100644 freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 24eebc6b38..f764728593 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -10,6 +10,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `Modules//` — one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`). - `WPOrgAkismet` — checks new conversations from senders with Akismet (`rest.akismet.com`, its own key), and reports agents' corrections. + - `WPOrgApi` — endpoints WordPress.org calls, signed with the same secret the other way around (`X-WPOrg-Signature`, see `Services/SignedRequest.php`), outside the `web` middleware. So far, a mailbox's saved reply by name, for the plugin directory's scripts. - `WPOrgHelpScoutImport` — imports HelpScout mailboxes' conversations, with their tags and custom fields, and their saved replies, from HelpScout's API, page by page on the queue, without the events live email fires, and points WordPress.org's copy of conversations at the imported ones as each mailbox switches. Administrators run it under Manage » HelpScout Import. Remove it once every mailbox has moved. - `WPOrgSearch` — gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB. - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`. diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php b/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php new file mode 100644 index 0000000000..9eb44c5660 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php @@ -0,0 +1,18 @@ + 'WPOrgApi', + + // Shared with WPOrgSidebar and WPOrgWebhooks: must match FREESCOUT_SECRET on WordPress.org. + 'secret' => env( 'WPORG_API_SECRET', '' ), + + // How long a signed request is good for, in seconds. + 'max_age' => 300, +); diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php b/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php new file mode 100644 index 0000000000..5dea0bfd7e --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php @@ -0,0 +1,76 @@ +json( array( 'error' => 'invalid_signature' ), 403 ); + } + + try { + if ( ! \App\Module::isActive( self::MODULE ) || ! Schema::hasTable( self::TABLE ) ) { + return response()->json( array( 'error' => 'saved_replies_unavailable' ), 503 ); + } + + $reply = \DB::table( self::TABLE ) + ->where( 'mailbox_id', (int) ( $payload['mailbox_id'] ?? 0 ) ) + ->where( 'name', (string) ( $payload['name'] ?? '' ) ) + ->first( array( 'name', 'text' ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgApi] Could not read a saved reply: ' . $e->getMessage() ); + + return response()->json( array( 'error' => 'saved_replies_unavailable' ), 503 ); + } + + if ( ! $reply ) { + return response()->json( array( 'error' => 'not_found' ), 404 ); + } + + return response()->json( + array( + 'name' => (string) $reply->name, + 'text' => (string) $reply->text, + ) + ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php new file mode 100644 index 0000000000..8b1f037470 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Http/routes.php @@ -0,0 +1,20 @@ + array( 'throttle:60,1' ), + 'prefix' => \Helper::getSubdirectory(), + 'namespace' => 'Modules\WPOrgApi\Http\Controllers', + ), + static function (): void { + Route::post( '/wporgapi/saved-reply', 'SavedReplyController@show' )->name( 'wporgapi.saved_reply' ); + } +); diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php new file mode 100644 index 0000000000..677c7cb4f5 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php @@ -0,0 +1,63 @@ +hide_secret_from_debug_pages(); + } + + /** + * Boots the module. + * + * @return void + */ + public function boot(): void { + $this->mergeConfigFrom( __DIR__ . '/../Config/config.php', self::ALIAS ); + $this->loadRoutesFrom( __DIR__ . '/../Http/routes.php' ); + } + + /** + * Keeps the signing secret off the Whoops error pages that APP_DEBUG shows, which list the environment. + * + * @return void + */ + private function hide_secret_from_debug_pages(): void { + $blacklist = (array) config( 'app.debug_blacklist', array() ); + + foreach ( array( '_ENV', '_SERVER' ) as $key ) { + $blacklist[ $key ] = (array) ( $blacklist[ $key ] ?? array() ); + + if ( ! in_array( 'WPORG_API_SECRET', $blacklist[ $key ], true ) ) { + $blacklist[ $key ][] = 'WPORG_API_SECRET'; + } + } + + config( array( 'app.debug_blacklist' => $blacklist ) ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg b/freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg new file mode 100644 index 0000000000..d5b413d551 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Public/img/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/freescout.wordpress.net/Modules/WPOrgApi/README.md b/freescout.wordpress.net/Modules/WPOrgApi/README.md new file mode 100644 index 0000000000..2ea56c4e40 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/README.md @@ -0,0 +1,13 @@ +# WP.org API + +Answers requests from WordPress.org, which reads from FreeScout what only FreeScout has. The other modules talk to WordPress.org; this one is talked to. + +## Endpoints + +- **`POST /wporgapi/saved-reply`:** a mailbox's saved reply, by name: `{"mailbox_id": 3, "name": "Closed: Security Exploit"}` gets `{"name": …, "text": …}`, the reply's HTML, from the Saved Replies module. The plugin directory's scripts write their emails from them, so the plugins team keeps the texts up to date in FreeScout. It answers 404 when the mailbox has no such reply, and 503 while the Saved Replies module is off. + +Requests are JSON, signed with an HMAC-SHA256 of the body in `X-WPOrg-Signature`, with the secret the modules share with WordPress.org. They name the endpoint, as `endpoint`, and when they were sent, as `sent_at`, and are refused after 5 minutes. They aren't in FreeScout's `web` middleware, so they need no session or CSRF token, and they're throttled to 60 a minute. + +## Setup + +Needs `WPORG_API_SECRET`, which must match `FREESCOUT_SECRET` on WordPress.org; see [configuration](../../README.md#deployment). Without it, every request is refused. diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php new file mode 100644 index 0000000000..00147566b2 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php @@ -0,0 +1,68 @@ +getContent(); + + if ( '' === $secret || ! hash_equals( self::sign( $body, $secret ), (string) $request->header( self::SIGNATURE_HEADER ) ) ) { + return null; + } + + $payload = json_decode( $body, true ); + if ( ! is_array( $payload ) || ( $payload['endpoint'] ?? '' ) !== $endpoint ) { + return null; + } + + $age = time() - (int) ( $payload['sent_at'] ?? 0 ); + if ( $age < -10 || $age > (int) config( 'wporgapi.max_age' ) ) { + return null; + } + + return $payload; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/module.json b/freescout.wordpress.net/Modules/WPOrgApi/module.json new file mode 100644 index 0000000000..0c7df94d46 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/module.json @@ -0,0 +1,21 @@ +{ + "name": "WP.org API", + "alias": "wporgapi", + "img": "/modules/wporgapi/img/icon.svg", + "description": "Answers WordPress.org's signed requests, like for a mailbox's saved replies.", + "version": "1.0.0", + "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgApi", + "author": "WordPress.org", + "authorUrl": "https://wordpress.org/", + "requiredAppVersion": "1.8.243", + "license": "GPL-2.0-or-later", + "keywords": [], + "active": 0, + "order": 0, + "providers": [ + "Modules\\WPOrgApi\\Providers\\WPOrgApiServiceProvider" + ], + "aliases": {}, + "files": [], + "requires": [] +} diff --git a/freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php b/freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php new file mode 100644 index 0000000000..e6ec991125 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgApi/tests/SavedReplyTest.php @@ -0,0 +1,185 @@ +increments( 'id' ); + $table->integer( 'mailbox_id' ); + $table->string( 'name', 75 ); + $table->longText( 'text' )->nullable(); + $table->integer( 'user_id' ); + $table->timestamps(); + } + ); + } + + /** + * Registers the module, and switches the Saved Replies module on. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->app->register( WPOrgApiServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + \App\Module::clearModulesCache(); + \App\Module::setActive( 'savedreplies', true ); + \App\Module::clearModulesCache(); + + // After DatabaseTransactions' rollback, which was registered first. + $this->beforeApplicationDestroyed( + static function (): void { + Schema::dropIfExists( 'saved_replies' ); + \App\Module::clearModulesCache(); + } + ); + } + + /** + * The reply is the named one of the mailbox asked about, not another mailbox's of the same name. + * + * @return void + */ + public function test_hands_over_the_mailboxs_saved_reply(): void { + $plugins = $this->create_mailbox( 'Plugins' ); + $themes = $this->create_mailbox( 'Themes' ); + $this->create_reply( (int) $themes->id, 'Closed: Security Exploit', '

Themes’ text.

' ); + $this->create_reply( (int) $plugins->id, 'Closed: Security Exploit', '

Your plugin PLUGIN_LINK was closed.

' ); + + $response = $this->request( + array( + 'mailbox_id' => $plugins->id, + 'name' => 'Closed: Security Exploit', + ) + ); + + $response->assertStatus( 200 ); + $this->assertSame( + array( + 'name' => 'Closed: Security Exploit', + 'text' => '

Your plugin PLUGIN_LINK was closed.

', + ), + $response->json() + ); + } + + /** + * Requests WordPress.org didn't sign, for this endpoint, just now, are refused. + * + * @dataProvider data_unsigned_requests + * + * @param string $secret Secret they're signed with. + * @param string $endpoint Endpoint they name. + * @param int $age How long ago they were sent, in seconds. + * @return void + */ + public function test_refuses_requests_wordpress_org_did_not_sign( string $secret, string $endpoint, int $age ): void { + $mailbox = $this->create_mailbox(); + $this->create_reply( (int) $mailbox->id, 'Welcome', '

Hi

' ); + + $response = $this->request( + array( + 'mailbox_id' => $mailbox->id, + 'name' => 'Welcome', + ), + $secret, + $endpoint, + $age + ); + + $response->assertStatus( 403 ); + } + + /** + * Requests to refuse. + * + * @return array[] + */ + public static function data_unsigned_requests(): array { + return array( + 'another secret' => array( 'not-the-secret', 'saved-reply', 0 ), + 'another endpoint' => array( 'test-secret', 'something-else', 0 ), + 'too old' => array( 'test-secret', 'saved-reply', 301 ), + ); + } + + /** + * Adds a saved reply. + * + * @param int $mailbox_id Mailbox ID. + * @param string $name Name. + * @param string $text HTML. + * @return void + */ + private function create_reply( int $mailbox_id, string $name, string $text ): void { + \DB::table( 'saved_replies' )->insert( + array( + 'mailbox_id' => $mailbox_id, + 'name' => $name, + 'text' => $text, + 'user_id' => 1, + ) + ); + } + + /** + * Sends a request the way WordPress.org does. + * + * @param array $payload Payload. + * @param string $secret Secret to sign it with; the configured one by default. + * @param string $endpoint Endpoint it names. + * @param int $age How long ago it was sent, in seconds. + * @return TestResponse + */ + private function request( array $payload, string $secret = 'test-secret', string $endpoint = 'saved-reply', int $age = 0 ): TestResponse { + $payload['endpoint'] = $endpoint; + $payload['sent_at'] = time() - $age; + $body = (string) json_encode( $payload ); + + return $this->call( + 'POST', + route( 'wporgapi.saved_reply' ), + array(), + array(), + array(), + array( + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WPORG_SIGNATURE' => SignedRequest::sign( $body, $secret ), + ), + $body + ); + } +} diff --git a/freescout.wordpress.net/README.md b/freescout.wordpress.net/README.md index 55968c9457..77c5f50225 100644 --- a/freescout.wordpress.net/README.md +++ b/freescout.wordpress.net/README.md @@ -5,6 +5,7 @@ | Module | What it does | |---|---| | [WPOrgAkismet](Modules/WPOrgAkismet) | Checks new conversations from senders with Akismet, and teaches it from what agents mark as spam. | +| [WPOrgApi](Modules/WPOrgApi) | Answers WordPress.org's signed requests for what only FreeScout has, like a mailbox's saved replies, which the plugin directory's scripts write their emails from. | | [WPOrgHelpScoutImport](Modules/WPOrgHelpScoutImport) | Imports HelpScout mailboxes' conversations, so their history moves to FreeScout. Administrators run it under Manage » HelpScout Import. | | [WPOrgSearch](Modules/WPOrgSearch) | Speeds up conversation search: an After date for searches without other filters, and one query instead of two. | | [WPOrgSidebar](Modules/WPOrgSidebar) | Shows the sender's WordPress.org profile, forum notes, plugins and themes, and privacy requests next to each conversation. | @@ -12,7 +13,7 @@ | [WPOrgSSO](Modules/WPOrgSSO) | Agents log in with their WordPress.org account, through login.wordpress.org; there's no other way in. Every user is connected to a WordPress.org account, and new users are created from one. Name, email, and avatar are updated from it at every login. | | [WPOrgWebhooks](Modules/WPOrgWebhooks) | Sends conversation events to WordPress.org, which records them as contributor stats. | -WPOrgSidebar, WPOrgWebhooks, and WPOrgSSO talk to [`api.wordpress.org/dotorg/freescout/`](../api.wordpress.org/public_html/dotorg/freescout), which does the WordPress.org lookups. +WPOrgSidebar, WPOrgWebhooks, and WPOrgSSO talk to [`api.wordpress.org/dotorg/freescout/`](../api.wordpress.org/public_html/dotorg/freescout), which does the WordPress.org lookups. WPOrgApi is the other way around: WordPress.org talks to it. ## Development @@ -41,7 +42,7 @@ Configuration, in FreeScout's `.env`. Required: | Variable | Value | |---|---| -| `WPORG_API_SECRET` | Shared secret; must match `FREESCOUT_SECRET` on api.wordpress.org. | +| `WPORG_API_SECRET` | Shared secret; must match `FREESCOUT_SECRET` on api.wordpress.org, and on WordPress.org, which signs its requests to `WPOrgApi` with it. | | `WPORG_SSO_IDP_CERT` | The identity provider's signing certificate, on one line, with or without the BEGIN/END lines; line breaks written as `\n` work too. Until it and `WPORG_API_SECRET` are set, logins stay as they are, so users can be connected first. Once they are, new logins go through WordPress.org, and sessions from before go on for a day; Manage » System » Tools » Logout Users ends them sooner. After that day, switching enforcement off and on again ends every session from while it was off, including a password login of whoever switches it back on: have break-glass or a connected account ready. | | `WPORG_AKISMET_KEY` | FreeScout's own Akismet API key. Without it, `WPOrgAkismet` checks nothing, and spam comes in like any other email. | | `APP_CUSTOM_NUMBER` | `true`. FreeScout's own setting (Manage » Settings » General » Conversation Number » Custom), which saves it here. Conversations imported from HelpScout keep HelpScout's numbers, which people quote; without it, FreeScout shows and searches its internal IDs instead. Before the first live email, also set the **Next Conversation #** there well above HelpScout's numbers, like 2,000,000. | From ad7b37cdf2b33346ffe5058b507c925451ebdfda Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 15:16:43 -0500 Subject: [PATCH 2/8] FreeScout: Run the plugin directory's email scripts against FreeScout bin/bounced-emails.php and bin/email-bulk-security-vulnerabilities.php were the last code needing dotorg/helpscout/ and wporg-mu-plugins' HelpScout class. - bounced-emails.php lists the Plugins mailbox's pending auto-bounce conversations through FreeScout's API, finds whom each bounced for with dotorg/freescout/'s bounce helpers, downloading the attachments that may hold the bounced email, and closes them as the agent in FREESCOUT_USER_ID. Audit logs link to FreeScout. - email-bulk-security-vulnerabilities.php writes its emails from the Plugins mailbox's saved replies, through WPOrgApi, and creates them as conversations FreeScout sends, so replies thread there, instead of sending with wp_mail() and logging a copy in HelpScout. --from and --assign take FreeScout's IDs. It no longer closes a plugin whose author couldn't be emailed. Co-Authored-By: Claude Opus 5.5 --- .../plugin-directory/bin/bounced-emails.php | 231 ++++++++++++------ .../email-bulk-security-vulnerabilities.php | 118 ++++----- .../clients/class-freescout.php | 51 ++++ 3 files changed, 265 insertions(+), 135 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php index 32ff58e38b..7109dec541 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php @@ -5,14 +5,15 @@ use WordPressdotorg\Plugin_Directory\Admin\Metabox\Author_Notice; use WordPressdotorg\Plugin_Directory\Admin\Status_Transitions; -use WordPressdotorg\MU_Plugins\Utilities\HelpScout; // NOTE: NOT the same as the HelpScout class in this plugin. +use WordPressdotorg\Plugin_Directory\Clients\FreeScout; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; -use function WordPressdotorg\API\HelpScout\{ get_user_email_for_email }; +use function WordPressdotorg\API\FreeScout\{ get_user_email_for_email }; // phpcs:disable WordPress.Security.EscapeOutput /** - * This script will process all the bounced emails in HelpScout and take the appropriate action. + * This script will process all the bounced emails in FreeScout's Plugins mailbox and take the appropriate action. * * It will: * - Fetch plugins tagged 'auto-bounce' @@ -23,7 +24,7 @@ * - In both cases, an Audit log entry is added referencing this script. * - In the case of revoke, the audit log entry contains the bounce message. * - In the case of a plugin closure, the Author notice is set to the next steps for the user AND a copy of the bounce is included. - * - If an action is taken, the HS ticket is closed. (This action will show as 'Systems'). + * - If an action is taken, the conversation is closed, by the agent in FREESCOUT_USER_ID. */ // This script should only be called in a CLI environment. @@ -69,27 +70,119 @@ } -// Load the HelpScout API helper methods. -require_once API_WPORGPATH . '/dotorg/helpscout/common.php'; +if ( ! Helpdesk::is_freescout() || ! FreeScout::api_key() || ! FreeScout::user_id() ) { + fwrite( STDERR, "Error! Define FREESCOUT_PLUGINS_MAILBOX_ID, FREESCOUT_API_KEY, and FREESCOUT_USER_ID first.\n" ); + die(); +} + +// Load the helpdesk's bounce helpers. +require_once API_WPORGPATH . '/dotorg/freescout/common.php'; // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); /** - * Fetch the bounce email threads. + * Fetch the bounce conversations, with their threads, from every page. + * + * @return array[] */ function get_bounces() { - $api = HelpScout::instance(); + $bounces = []; + $page = 1; + + do { + $response = FreeScout::api( + 'conversations', + [ + 'mailboxId' => Helpdesk::freescout_mailbox_id(), + 'embed' => 'threads', + 'status' => 'pending', + 'tag' => 'auto-bounce', + 'page' => $page, + ], + 'GET', + $response_code + ); + + if ( 200 !== $response_code ) { + fwrite( STDERR, "Error! Couldn't list the bounces: $response_code\n" ); + die(); + } + + $bounces = array_merge( $bounces, $response['_embedded']['conversations'] ?? [] ); + } while ( $page++ < (int) ( $response['page']['totalPages'] ?? 0 ) ); + + return $bounces; +} + +/** + * Describe a bounce the way FreeScout's sidebar sends a conversation, which the bounce helpers read. + * + * Attachments that may hold the bounced email are downloaded, as the API only links them. + * + * @param array $bounce A conversation, with its threads. + * @return object + */ +function get_bounce_request( $bounce ) { + $threads = []; + foreach ( $bounce['_embedded']['threads'] ?? [] as $thread ) { + $attachments = []; + foreach ( $thread['_embedded']['attachments'] ?? [] as $attachment ) { + $mime_type = (string) ( $attachment['mimeType'] ?? '' ); + $size = (int) ( $attachment['size'] ?? 0 ); + $content = null; + + if ( $size <= 100 * KB_IN_BYTES && preg_match( '/message|text|rfc/', $mime_type ) ) { + $response = wp_remote_get( (string) ( $attachment['fileUrl'] ?? '' ), [ 'timeout' => FreeScout::TIMEOUT ] ); + $content = 200 === wp_remote_retrieve_response_code( $response ) ? wp_remote_retrieve_body( $response ) : null; + } + + $attachments[] = (object) [ + 'mime_type' => $mime_type, + 'size' => $size, + 'content' => $content, + ]; + } + + $threads[] = (object) [ + 'type' => (string) ( $thread['type'] ?? '' ), + 'body' => (string) ( $thread['body'] ?? '' ), + 'attachments' => $attachments, + ]; + } - return $api->get_paged( - '/v2/conversations', + return (object) [ + 'conversation' => (object) [ 'subject' => (string) ( $bounce['subject'] ?? '' ) ], + 'sender' => (object) [ + 'email' => (string) ( $bounce['customer']['email'] ?? '' ), + 'first_name' => (string) ( $bounce['customer']['firstName'] ?? '' ), + 'last_name' => (string) ( $bounce['customer']['lastName'] ?? '' ), + ], + 'threads' => $threads, + // A bounce is about whoever the email bounced for, not its sender. + 'related' => true, + ]; +} + +/** + * Close a bounce's conversation. + * + * @param int $conversation_id Conversation ID. + */ +function close_conversation( $conversation_id ) { + FreeScout::api( + 'conversations/' . $conversation_id, [ - 'mailbox' => $api->get_mailbox_id( 'plugins' ), - 'embed' => 'threads', - 'status' => 'pending', - 'tag' => 'auto-bounce', - ] + 'status' => 'closed', + 'byUser' => FreeScout::user_id(), + ], + 'PUT', + $response_code ); + + if ( $response_code < 200 || $response_code >= 300 ) { + echo "\tCouldn't close " . Helpdesk::conversation_url( $conversation_id, 0, true ) . ": $response_code\n"; + } } /** @@ -102,12 +195,12 @@ function extract_bounce_message( $email ) { $body = ''; $message = ''; - foreach ( $email->_embedded->threads as $reply ) { - if ( 'customer' != $reply->type ) { + foreach ( $email['_embedded']['threads'] ?? [] as $reply ) { + if ( 'customer' != $reply['type'] ) { continue; } - $body = $reply->body; + $body = $reply['body']; break; } @@ -160,11 +253,11 @@ function extract_bounce_message( $email ) { } $stats = [ - 'processed' => 0, - 'error' => 0, - 'closed' => 0, - 'revoked-commit' => 0, - 'hs-closed' => 0, + 'processed' => 0, + 'error' => 0, + 'closed' => 0, + 'revoked-commit' => 0, + 'conversations-closed' => 0, ]; $actions_to_take = [ @@ -178,16 +271,16 @@ function extract_bounce_message( $email ) { ], */ ]; -$just_close_it = []; // The HS tickets to just close. +$just_close_it = []; // The conversations to just close. -foreach ( get_bounces()->_embedded->conversations as $bounce ) { - $helpscout_url = "https://secure.helpscout.net/conversation/{$bounce->id}/{$bounce->number}"; +foreach ( get_bounces() as $bounce ) { + $conversation_url = Helpdesk::conversation_url( (int) $bounce['id'], (int) $bounce['number'], true ); $stats['processed']++; - echo "Processing {$helpscout_url}\n"; + echo "Processing {$conversation_url}\n"; - $email = get_user_email_for_email( $bounce ); + $email = get_user_email_for_email( get_bounce_request( $bounce ) ); $user = get_user_by( 'email', $email ); $slugs = Tools::get_users_write_access_plugins( $user ) ?: []; @@ -250,12 +343,12 @@ function( $plugin ) use( $user ) { foreach ( $plugins as $plugin ) { $actions_to_take[ $plugin->ID ] ??= [ 'id' => [], 'users' => [], 'action' => '' ]; - $actions_to_take[ $plugin->ID ]['id'][] = $bounce->id; + $actions_to_take[ $plugin->ID ]['id'][] = (int) $bounce['id']; $actions_to_take[ $plugin->ID ]['users'][ $user->ID ] = $bounce_message; } if ( ! $plugins && $closed_plugins ) { - $just_close_it[] = $bounce->id; + $just_close_it[] = (int) $bounce['id']; } echo "\n"; @@ -284,11 +377,15 @@ function( $plugin ) use( $user ) { $plugin = get_post( $post_id ); - $helpscout_url = ''; - foreach ( $data['id'] as $hs_id ) { - $helpscout_url .= " https://secure.helpscout.net/conversation/{$hs_id}"; - } - $helpscout_url = trim( $helpscout_url ); + $conversation_urls = implode( + ' ', + array_map( + function ( $conversation_id ) { + return Helpdesk::conversation_url( $conversation_id, 0, true ); + }, + $data['id'] + ) + ); echo "Plugin: {$plugin->post_name}\n"; echo "\t" . get_permalink( $plugin ) . "\n"; @@ -305,7 +402,7 @@ function( $plugin ) use( $user ) { } Tools::audit_log( - "Removing {$user->user_login} as a committer due to Email bounce. See {$helpscout_url}.\n{$bounce_message}Automated by bin/bounced-emails.php", + "Removing {$user->user_login} as a committer due to Email bounce. See {$conversation_urls}.\n{$bounce_message}Automated by bin/bounced-emails.php", $plugin ); Tools::revoke_plugin_committer( $plugin, $user ); @@ -313,18 +410,10 @@ function( $plugin ) use( $user ) { $stats['revoked-commit']++; } - // Close those HS tickets. + // Close those conversations. if ( 'live' == OPERATION_MODE ) { - foreach ( $data['id'] as $hs_id ) { - HelpScout::instance()->api( - '/v2/conversations/' . $hs_id, - [ - 'op' => 'replace', - 'path' => '/status', - 'value' => 'closed', - ], - 'PATCH' - ); + foreach ( $data['id'] as $conversation_id ) { + close_conversation( $conversation_id ); } } @@ -339,11 +428,15 @@ function( $plugin ) use( $user ) { $plugin = get_post( $post_id ); - $helpscout_url = ''; - foreach ( $data['id'] as $hs_id ) { - $helpscout_url .= " https://secure.helpscout.net/conversation/{$hs_id}"; - } - $helpscout_url = trim( $helpscout_url ); + $conversation_urls = implode( + ' ', + array_map( + function ( $conversation_id ) { + return Helpdesk::conversation_url( $conversation_id, 0, true ); + }, + $data['id'] + ) + ); echo "Plugin: {$plugin->post_name}\n"; echo "\t" . get_permalink( $plugin ) . "\n"; @@ -358,7 +451,7 @@ function( $plugin ) use( $user ) { } Tools::audit_log( - "Closing due to Email bounce. See {$helpscout_url}.\n{$bounce_message}Automated by bin/bounced-emails.php", + "Closing due to Email bounce. See {$conversation_urls}.\n{$bounce_message}Automated by bin/bounced-emails.php", $plugin ); @@ -396,17 +489,9 @@ function( $plugin ) use( $user ) { 'post_status' => 'closed', ] ); - // Close those HS tickets. - foreach ( $data['id'] as $hs_id ) { - HelpScout::instance()->api( - '/v2/conversations/' . $hs_id, - [ - 'op' => 'replace', - 'path' => '/status', - 'value' => 'closed', - ], - 'PATCH' - ); + // Close those conversations. + foreach ( $data['id'] as $conversation_id ) { + close_conversation( $conversation_id ); } $stats['closed']++; @@ -414,22 +499,14 @@ function( $plugin ) use( $user ) { echo "\n"; } -// Close those HS tickets. -foreach ( $just_close_it as $hs_id ) { - $stats['hs-closed']++; +// Close those conversations. +foreach ( $just_close_it as $conversation_id ) { + $stats['conversations-closed']++; - echo "Marking https://secure.helpscout.net/conversation/{$hs_id} as closed as all plugins are closed.\n"; + echo 'Marking ' . Helpdesk::conversation_url( $conversation_id, 0, true ) . " as closed as all plugins are closed.\n"; if ( 'live' == OPERATION_MODE ) { - HelpScout::instance()->api( - '/v2/conversations/' . $hs_id, - [ - 'op' => 'replace', - 'path' => '/status', - 'value' => 'closed', - ], - 'PATCH' - ); + close_conversation( $conversation_id ); } } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php index 63e2599e29..d5aa52132d 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php @@ -3,13 +3,14 @@ use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Tools; -use WordPressdotorg\MU_Plugins\Utilities\HelpScout; // NOTE: NOT the same as the HelpScout class in this plugin. +use WordPressdotorg\Plugin_Directory\Clients\FreeScout; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; /** * This script will process a given CSV and close/email plugin authors. * - * NOTE: The emails created in HelpScout will not be sent. We must also send it (Which this script does). - * As a result, replies to the email will NOT thread properly. + * The emails are sent from FreeScout's Plugins mailbox, as new conversations, so replies thread there. + * Their texts are the mailbox's saved replies, which the WPOrgApi module hands over. * * The CSV should contain multiple fields. * - At least one must be the 'slug', this is the plugin it will be looking for. @@ -31,7 +32,7 @@ // Comment out once you're sure you know what you're doing :) die( 'Please check the source, and validate this will work as you anticipate.' ); -$opts = getopt( '', array( 'url:', 'abspath:', 'csv:', 'only-warn', 'doit' ) ); +$opts = getopt( '', array( 'url:', 'abspath:', 'csv:', 'only-warn', 'doit', 'from:', 'assign:' ) ); if ( empty( $opts['url'] ) ) { $opts['url'] = 'https://wordpress.org/plugins/'; @@ -62,6 +63,10 @@ die( "Need a --csv param." ); } +if ( ! Helpdesk::is_freescout() || ! FreeScout::api_key() ) { + die( "Define FREESCOUT_PLUGINS_MAILBOX_ID and FREESCOUT_API_KEY first.\n" ); +} + if ( 'live' == OPERATION_MODE ) { echo "Running in live mode. Changes will be made.\nProceeding in 10s...\n"; sleep( 10 ); @@ -70,28 +75,32 @@ echo "Running in dry-run mode. No changes will be made. Pass --doit parameter to make changes.\n"; } -// Load the HelpScout API helper methods. -require_once API_WPORGPATH . '/dotorg/helpscout/common.php'; - // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); -const ATTRIBUTE_TO_USER = 544543; // dd32 - This is who should show as having sent it. Fetch it from your HS profile edit url. -const ASSIGN_TO_USER = 433937; // plugin security team - this is who should be assigned for responses -const WARN_THRESHOLD = 10000; // active installs to go from instant close => warn w/ 30days. +// The FreeScout agent the emails come from, by default the one the plugin directory writes as. See their profile's URL. +define( __NAMESPACE__ . '\ATTRIBUTE_TO_USER', (int) ( $opts['from'] ?? FreeScout::user_id() ) ); +// Who to assign replies to, like the plugin security team; its ID is in its page's URL. Unassigned by default. +define( __NAMESPACE__ . '\ASSIGN_TO_USER', (int) ( $opts['assign'] ?? 0 ) ); +const WARN_THRESHOLD = 10000; // active installs to go from instant close => warn w/ 30days. + +if ( ! ATTRIBUTE_TO_USER ) { + die( "Need a --from param, or FREESCOUT_USER_ID.\n" ); +} /** - * Fetch the email template from HelpScout. + * Fetch the email template, a saved reply of FreeScout's Plugins mailbox. */ function get_email_template( $name ) { - static $ids; static $cache = []; - $api = HelpScout::instance(); - $ids ??= wp_list_pluck( $api->get( '/v2/mailboxes/' . $api->get_mailbox_id( 'plugins' ) . '/saved-replies' ), 'id', 'name' ); - $cache[ $name ] ??= $api->get( '/v2/mailboxes/' . $api->get_mailbox_id( 'plugins' ) . '/saved-replies/' . $ids[ $name ] ); + $cache[ $name ] ??= FreeScout::saved_reply( $name ); + if ( null === $cache[ $name ] ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. + die( "Couldn't fetch the saved reply \"$name\" from FreeScout.\n" ); + } - return $cache[ $name ]->text ?? false; + return $cache[ $name ]; } function fetch_all_from_csv() { @@ -140,64 +149,49 @@ function format_vulns_for_email( $vulns, $plugin ) { } /** - * Send an email, both by wp_mail() and log it into HelpScout. - * - * HelpScout allows creation of emails through the API, but does not actually allow you to send an email. - * Therefor, we send the email, and create it within HS. - * Unfortunately, replies do not thread to the created email.. but at least we have a log.. + * Send an email, as a new conversation in FreeScout's Plugins mailbox, which sends it. */ function send_email( $args ) { global $opts; - $filter = function() { return 'text/html'; }; - add_filter( 'wp_mail_content_type', $filter ); - - wp_mail( - $args['to'] ?? '', - $args['subject'] ?? '', - str_replace( "
", "
\n", $args['body'] ?? '' ), - array_filter( [ - 'From: WordPress.org Plugin Directory ', - 'Return-Path: ', - ( $args['cc'] ? 'CC: ' . implode( ', ', $args['cc'] ) : '' ), - ( $args['bcc'] ? 'BCC: ' . implode( ', ', (array) $args['bcc'] ) : '' ), - ] ) - ); - remove_filter( 'wp_mail_content_type', $filter ); - - $api = HelpScout::instance(); $payload = [ 'type' => 'email', - 'assignTo' => ASSIGN_TO_USER, - 'mailboxId' => $api->get_mailbox_id( 'plugins' ), + 'mailboxId' => Helpdesk::freescout_mailbox_id(), 'status' => 'closed', - 'imported' => true, 'customer' => array_filter( [ 'email' => $args['to'] ?? '', 'firstName' => explode( ' ', $args['name'] ?? '', 2 )[0] ?? '', 'lastName' => explode( ' ', $args['name'] ?? '', 2 )[1] ?? '', ] ), 'subject' => $args['subject'] ?? '', + // Newest first. 'threads' => [ [ 'type' => 'note', - 'text' => 'This was automated by bin/email-bulk-security-vulnerabilities.php processing a CSV file: ' . ( $opts['csv'] ?? '' ) . '', - 'user' => ATTRIBUTE_TO_USER + 'text' => 'This was automated by bin/email-bulk-security-vulnerabilities.php processing a CSV file: ' . esc_html( $opts['csv'] ?? '' ) . '', + 'user' => ATTRIBUTE_TO_USER, ], [ - 'type' => 'reply', - 'cc' => (array) ( $args['cc'] ?? [] ), - 'bcc' => (array) ( $args['bcc'] ?? [] ), - 'text' => $args['body'] ?? '', - 'customer' => [ - 'email' => 'plugins@wordpress.org', - ], - 'user' => ATTRIBUTE_TO_USER - ] - ] + 'type' => 'message', + 'cc' => (array) ( $args['cc'] ?? [] ), + 'bcc' => (array) ( $args['bcc'] ?? [] ), + 'text' => $args['body'] ?? '', + 'user' => ATTRIBUTE_TO_USER, + ], + ], ]; - return $api->post( '/v2/conversations', $payload ); + if ( ASSIGN_TO_USER ) { + $payload['assignTo'] = ASSIGN_TO_USER; + } + + $result = FreeScout::api( 'conversations', $payload, 'POST', $response_code ); + if ( 201 !== $response_code ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. + echo "ERROR: FreeScout didn't create the conversation: $response_code " . wp_json_encode( $result ) . "\n"; + } + + return 201 === $response_code; } $stats = [ @@ -266,7 +260,8 @@ function send_email( $args ) { $body = $email_template; $body = str_replace( 'PLUGIN_LINK', get_permalink( $plugin ), $body ); - $body = str_replace( 'LINK

DETAILS', '
' . $vuln_desc . '
', $body ); + // FreeScout's editor may write the line breaks differently; a callback, as the details may hold a "$1". + $body = preg_replace_callback( '#LINK(\s*\s*)+DETAILS#i', fn() => '
' . $vuln_desc . '
', $body ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. echo "Subject: $subject\n\n"; @@ -281,7 +276,10 @@ function send_email( $args ) { continue; } - send_email( compact( 'subject', 'to', 'name', 'cc', 'bcc', 'body' ) ); + if ( ! send_email( compact( 'subject', 'to', 'name', 'cc', 'bcc', 'body' ) ) ) { + $stats['error']++; + continue; + } $stats['warned']++; @@ -296,7 +294,7 @@ function send_email( $args ) { $body = $email_template; $body = str_replace( 'PLUGIN_LINK', get_permalink( $plugin ), $body ); - $body = str_replace( 'REPORT

OPTIONAL_ADDITIONAL', '
' . $vuln_desc . '
', $body ); + $body = preg_replace_callback( '#REPORT(\s*\s*)+OPTIONAL_ADDITIONAL#i', fn() => '
' . $vuln_desc . '
', $body ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. echo "Subject: $subject\n\n"; @@ -311,7 +309,11 @@ function send_email( $args ) { continue; } - send_email( compact( 'subject', 'to', 'cc', 'bcc', 'body' ) ); + // Don't close a plugin without telling its author. + if ( ! send_email( compact( 'subject', 'to', 'cc', 'bcc', 'body' ) ) ) { + $stats['error']++; + continue; + } // Record why it's closed update_post_meta( $plugin->ID, '_close_reason', 'security-issue' ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php index f3dc668eeb..e62491b9f9 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php @@ -94,4 +94,55 @@ public static function api( string $path, array $args = array(), string $method return is_array( $data ) ? $data : null; } + + /** + * A saved reply of the Plugins mailbox, by name, from the WPOrgApi module. + * + * The Saved Replies module has no API of its own; WPOrgApi reads it for requests signed with `FREESCOUT_SECRET`. + * + * @param string $name The saved reply's name. + * @return string|null Its HTML; null if there's no such reply, or it couldn't be read. + */ + public static function saved_reply( string $name ): ?string { + $secret = defined( 'FREESCOUT_SECRET' ) ? (string) FREESCOUT_SECRET : ''; + + /** + * Filters the secret the plugin directory signs its requests to FreeScout's WPOrgApi module with. + * + * @param string $secret Shared secret. + */ + $secret = (string) apply_filters( 'wporg_plugins_freescout_secret', $secret ); + if ( '' === $secret ) { + return null; + } + + $body = (string) wp_json_encode( + array( + 'mailbox_id' => Helpdesk::freescout_mailbox_id(), + 'name' => $name, + 'endpoint' => 'saved-reply', + 'sent_at' => time(), + ) + ); + + $response = wp_remote_post( + Helpdesk::freescout_url() . 'wporgapi/saved-reply', + array( + 'timeout' => self::TIMEOUT, + 'headers' => array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-WPOrg-Signature' => hash_hmac( 'sha256', $body, $secret ), + ), + 'body' => $body, + ) + ); + + $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) || ! isset( $data['text'] ) ) { + return null; + } + + return (string) $data['text']; + } } From bb5832e4c305c3de74aecf1ac14dd74f92502d68 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 15:40:29 -0500 Subject: [PATCH 3/8] FreeScout: Trim the email scripts' and WPOrgApi's comments Drop comments that restate the code, and shorten the rest to what the code can't say. Co-Authored-By: Claude Opus 5.5 --- .../WPOrgApi/Http/Controllers/SavedReplyController.php | 3 +-- .../Modules/WPOrgApi/Services/SignedRequest.php | 5 ++--- .../plugins/plugin-directory/bin/bounced-emails.php | 6 +----- .../bin/email-bulk-security-vulnerabilities.php | 6 +++--- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php b/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php index 5dea0bfd7e..338ee8d2e5 100644 --- a/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php +++ b/freescout.wordpress.net/Modules/WPOrgApi/Http/Controllers/SavedReplyController.php @@ -16,8 +16,7 @@ use Modules\WPOrgApi\Services\SignedRequest; /** - * A mailbox's saved reply, by name, from the Saved Replies module, so WordPress.org's scripts write what agents keep - * up to date there. + * Serves a mailbox's saved replies by name, from the Saved Replies module. */ final class SavedReplyController extends Controller { diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php index 00147566b2..56877b3d8e 100644 --- a/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php +++ b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php @@ -14,9 +14,8 @@ /** * Reads a request's JSON body once its signature, freshness, and endpoint check out. * - * WordPress.org signs the body with HMAC-SHA256 and the secret it shares with the modules, stamps it with when it was - * sent, and names the endpoint, so a request is only good there, and not for long. Like the modules' requests to - * api.wordpress.org, the other way around. + * WordPress.org signs the body with HMAC-SHA256 and the shared secret, and stamps it with the endpoint and when it was + * sent. */ final class SignedRequest { diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php index 7109dec541..af1b302a34 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php @@ -75,7 +75,6 @@ die(); } -// Load the helpdesk's bounce helpers. require_once API_WPORGPATH . '/dotorg/freescout/common.php'; // Set a user to run as. @@ -116,7 +115,7 @@ function get_bounces() { } /** - * Describe a bounce the way FreeScout's sidebar sends a conversation, which the bounce helpers read. + * Shape a bounce like FreeScout's sidebar payload, which the bounce helpers read. * * Attachments that may hold the bounced email are downloaded, as the API only links them. * @@ -410,7 +409,6 @@ function ( $conversation_id ) { $stats['revoked-commit']++; } - // Close those conversations. if ( 'live' == OPERATION_MODE ) { foreach ( $data['id'] as $conversation_id ) { close_conversation( $conversation_id ); @@ -489,7 +487,6 @@ function ( $conversation_id ) { 'post_status' => 'closed', ] ); - // Close those conversations. foreach ( $data['id'] as $conversation_id ) { close_conversation( $conversation_id ); } @@ -499,7 +496,6 @@ function ( $conversation_id ) { echo "\n"; } -// Close those conversations. foreach ( $just_close_it as $conversation_id ) { $stats['conversations-closed']++; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php index d5aa52132d..95087494b2 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php @@ -78,9 +78,9 @@ // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); -// The FreeScout agent the emails come from, by default the one the plugin directory writes as. See their profile's URL. +// FreeScout agent the emails come from; FREESCOUT_USER_ID by default. define( __NAMESPACE__ . '\ATTRIBUTE_TO_USER', (int) ( $opts['from'] ?? FreeScout::user_id() ) ); -// Who to assign replies to, like the plugin security team; its ID is in its page's URL. Unassigned by default. +// FreeScout user or team to assign the conversations to; unassigned by default. define( __NAMESPACE__ . '\ASSIGN_TO_USER', (int) ( $opts['assign'] ?? 0 ) ); const WARN_THRESHOLD = 10000; // active installs to go from instant close => warn w/ 30days. @@ -164,7 +164,7 @@ function send_email( $args ) { 'lastName' => explode( ' ', $args['name'] ?? '', 2 )[1] ?? '', ] ), 'subject' => $args['subject'] ?? '', - // Newest first. + // The API takes threads newest first. 'threads' => [ [ 'type' => 'note', From 22cb0da31dcc15128eaa29274d6df8a040715c3d Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 16:15:37 -0500 Subject: [PATCH 4/8] FreeScout: Run the email scripts as wordpressdotorg, without a setting Co-Authored-By: Claude Opus 5.5 --- .../plugins/plugin-directory/bin/bounced-emails.php | 8 ++++---- .../bin/email-bulk-security-vulnerabilities.php | 8 ++------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php index af1b302a34..b03dc18bad 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php @@ -24,7 +24,7 @@ * - In both cases, an Audit log entry is added referencing this script. * - In the case of revoke, the audit log entry contains the bounce message. * - In the case of a plugin closure, the Author notice is set to the next steps for the user AND a copy of the bounce is included. - * - If an action is taken, the conversation is closed, by the agent in FREESCOUT_USER_ID. + * - If an action is taken, the conversation is closed, as wordpressdotorg. */ // This script should only be called in a CLI environment. @@ -70,8 +70,8 @@ } -if ( ! Helpdesk::is_freescout() || ! FreeScout::api_key() || ! FreeScout::user_id() ) { - fwrite( STDERR, "Error! Define FREESCOUT_PLUGINS_MAILBOX_ID, FREESCOUT_API_KEY, and FREESCOUT_USER_ID first.\n" ); +if ( ! Helpdesk::is_freescout() || ! FreeScout::api_key() ) { + fwrite( STDERR, "Error! Define FREESCOUT_PLUGINS_MAILBOX_ID and FREESCOUT_API_KEY first.\n" ); die(); } @@ -173,7 +173,7 @@ function close_conversation( $conversation_id ) { 'conversations/' . $conversation_id, [ 'status' => 'closed', - 'byUser' => FreeScout::user_id(), + 'byUser' => FreeScout::USER_ID, ], 'PUT', $response_code diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php index 95087494b2..4b386d2359 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php @@ -78,16 +78,12 @@ // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); -// FreeScout agent the emails come from; FREESCOUT_USER_ID by default. -define( __NAMESPACE__ . '\ATTRIBUTE_TO_USER', (int) ( $opts['from'] ?? FreeScout::user_id() ) ); +// FreeScout agent the emails come from; wordpressdotorg's by default. +define( __NAMESPACE__ . '\ATTRIBUTE_TO_USER', (int) ( $opts['from'] ?? FreeScout::USER_ID ) ); // FreeScout user or team to assign the conversations to; unassigned by default. define( __NAMESPACE__ . '\ASSIGN_TO_USER', (int) ( $opts['assign'] ?? 0 ) ); const WARN_THRESHOLD = 10000; // active installs to go from instant close => warn w/ 30days. -if ( ! ATTRIBUTE_TO_USER ) { - die( "Need a --from param, or FREESCOUT_USER_ID.\n" ); -} - /** * Fetch the email template, a saved reply of FreeScout's Plugins mailbox. */ From 67bfc33df7bc0e3190c70198bd555ed5a8d1b3a0 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 16:26:59 -0500 Subject: [PATCH 5/8] FreeScout: Name WPOrgApi's hidden environment keys $hidden Co-Authored-By: Claude Opus 5.5 --- .../WPOrgApi/Providers/WPOrgApiServiceProvider.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php index 677c7cb4f5..f9114e025a 100644 --- a/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgApi/Providers/WPOrgApiServiceProvider.php @@ -48,16 +48,16 @@ public function boot(): void { * @return void */ private function hide_secret_from_debug_pages(): void { - $blacklist = (array) config( 'app.debug_blacklist', array() ); + $hidden = (array) config( 'app.debug_blacklist', array() ); foreach ( array( '_ENV', '_SERVER' ) as $key ) { - $blacklist[ $key ] = (array) ( $blacklist[ $key ] ?? array() ); + $hidden[ $key ] = (array) ( $hidden[ $key ] ?? array() ); - if ( ! in_array( 'WPORG_API_SECRET', $blacklist[ $key ], true ) ) { - $blacklist[ $key ][] = 'WPORG_API_SECRET'; + if ( ! in_array( 'WPORG_API_SECRET', $hidden[ $key ], true ) ) { + $hidden[ $key ][] = 'WPORG_API_SECRET'; } } - config( array( 'app.debug_blacklist' => $blacklist ) ); + config( array( 'app.debug_blacklist' => $hidden ) ); } } From 0c1e962eb58535767022467435001ef1f653bfae Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 13 Oct 2026 16:27:57 -0500 Subject: [PATCH 6/8] FreeScout: Hardcode how long WPOrgApi takes a signed request Co-Authored-By: Claude Opus 5.5 --- .../Modules/WPOrgApi/Config/config.php | 7 ++----- .../Modules/WPOrgApi/Services/SignedRequest.php | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php b/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php index 9eb44c5660..7359cc99cd 100644 --- a/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php +++ b/freescout.wordpress.net/Modules/WPOrgApi/Config/config.php @@ -8,11 +8,8 @@ declare( strict_types = 1 ); return array( - 'name' => 'WPOrgApi', + 'name' => 'WPOrgApi', // Shared with WPOrgSidebar and WPOrgWebhooks: must match FREESCOUT_SECRET on WordPress.org. - 'secret' => env( 'WPORG_API_SECRET', '' ), - - // How long a signed request is good for, in seconds. - 'max_age' => 300, + 'secret' => env( 'WPORG_API_SECRET', '' ), ); diff --git a/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php index 56877b3d8e..e9fe6f1dcb 100644 --- a/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php +++ b/freescout.wordpress.net/Modules/WPOrgApi/Services/SignedRequest.php @@ -26,6 +26,20 @@ final class SignedRequest { */ public const SIGNATURE_HEADER = 'X-WPOrg-Signature'; + /** + * How long a request is good for after it was sent, in seconds. + * + * @var int + */ + private const MAX_AGE = 300; + + /** + * How far ahead of FreeScout's clock WordPress.org's may be, in seconds. + * + * @var int + */ + private const MAX_CLOCK_SKEW = 10; + /** * Signs a body. * @@ -58,7 +72,7 @@ public static function payload( Request $request, string $endpoint ): ?array { } $age = time() - (int) ( $payload['sent_at'] ?? 0 ); - if ( $age < -10 || $age > (int) config( 'wporgapi.max_age' ) ) { + if ( $age < -self::MAX_CLOCK_SKEW || $age > self::MAX_AGE ) { return null; } From 25a7cc4bfddb0b7bd10d66edcc5595c8b3fe5512 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 20 Oct 2026 10:39:13 -0500 Subject: [PATCH 7/8] Plugin Directory: Read FreeScout's `
` line breaks in bounce messages FreeScout stores plain-text emails as `nl2br()` output, so each line of a bounce ended in a literal `
` in the audit log and Author Notice. Co-Authored-By: Claude Opus 5.5 --- .../wp-content/plugins/plugin-directory/bin/bounced-emails.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php index b03dc18bad..44b52977ee 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/bounced-emails.php @@ -203,7 +203,7 @@ function extract_bounce_message( $email ) { break; } - $body = preg_replace( '#
#i', "\n", $body ); + $body = preg_replace( '##i', "\n", $body ); $body = str_replace( "\r", '', $body ); // Trim the body to header-like lines. From ba788805d86232798234c1db8d446b89df4b0894 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 10 Nov 2026 15:02:12 -0600 Subject: [PATCH 8/8] Plugin Directory: Reject a non-numeric --from or --assign in the bulk security emails script Both are cast to int, so a typo like `--from=plugins` became user 0, and every conversation FreeScout was asked to create failed, one error each. Stop at startup instead. Co-Authored-By: Claude Opus 5.5 --- .../bin/email-bulk-security-vulnerabilities.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php index 4b386d2359..34d25e1d42 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/email-bulk-security-vulnerabilities.php @@ -78,6 +78,13 @@ // Set a user to run as. wp_set_current_user( get_user_by( 'slug', 'wordpressdotorg' )->ID ); +foreach ( array( 'from', 'assign' ) as $opt ) { + if ( isset( $opts[ $opt ] ) && ! ctype_digit( (string) $opts[ $opt ] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CLI script; the php_sapi_name() guard above exits for web requests and this is console output. + die( "--$opt must be a numeric FreeScout ID.\n" ); + } +} + // FreeScout agent the emails come from; wordpressdotorg's by default. define( __NAMESPACE__ . '\ATTRIBUTE_TO_USER', (int) ( $opts['from'] ?? FreeScout::USER_ID ) ); // FreeScout user or team to assign the conversations to; unassigned by default.