From 41472e5fbcda736e9066c9a44ef4fb7abc769843 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 6 Nov 2026 14:32:29 -0600 Subject: [PATCH 1/3] FreeScout: Leave users deleted on a source disabled when connecting them Connecting a CSV row created or switched on the row's user, with access to the mailboxes their conversations are in, even when the source had deleted that user. Rows for deleted users are still connected and credited, but their user isn't switched on or given access, and one created for them is disabled. When someone's deleted and current users name the same account, the current one's row still switches the user on, in either order. The source's API doesn't give a user's status; core appends `_deleted` and the time to a deleted user's email, which is what tells them apart. Co-Authored-By: Claude Opus 5.5 --- .../Modules/WPOrgFreeScoutImport/README.md | 2 +- .../Resources/views/connect.blade.php | 3 +++ .../Resources/views/users.blade.php | 2 +- .../WPOrgFreeScoutImport/Services/People.php | 14 ++++++++++++-- .../Services/WordPressOrgAccounts.php | 10 ++++++++-- .../Modules/WPOrgFreeScoutImport/module.json | 2 +- .../WPOrgFreeScoutImport/tests/ImportTest.php | 18 +++++++++++++----- 7 files changed, 39 insertions(+), 12 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md index 828a8354d3..bbfddec49d 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md @@ -18,7 +18,7 @@ Copies mailboxes' conversations from other FreeScouts, like WordCamp US's at sco - **What doesn't:** spam, drafts, and deleted conversations; other line items, like moves and merges; saved replies, workflows, custom folders, and mailbox settings, which the API doesn't give: set those up again by hand. Senders come across with their name and email only. - **Conversations agents worked on here:** once a conversation has a reply, note, or change made here, importing it again only adds the new threads. Its status and assignee stay this FreeScout's. What automatic workflows note when they run doesn't count. A conversation moved to another mailbox there moves here too, unless agents worked on it. - **Users:** every user's replies, notes, and assignments are credited to a user here: the one chosen on the source's **Users** page, under **Manage » FreeScout Import**, or else the one with the same email, or else a new, disabled one that keeps their name. Active users get access to the mailboxes an import credits them with something in. - - **Connecting in bulk:** on a source's Users page, **Download CSV** lists its users with their users here. Fill in its `wporg_username` column for those who'll work here, upload or paste it, and **Check** it: that shows, for each row, the WordPress.org account's name and email next to the user's, and what connecting would do, without doing it. **Connect** then connects each one's user here to the account, creating it if they have none yet (before their mailboxes are imported, too), or switching it on, with access to the mailboxes their imported conversations are in, if an import created it disabled. Someone whose account is connected to a user already is credited to that user instead, so they don't get a second one. + - **Connecting in bulk:** on a source's Users page, **Download CSV** lists its users with their users here. Fill in its `wporg_username` column for those who'll work here, upload or paste it, and **Check** it: that shows, for each row, the WordPress.org account's name and email next to the user's, and what connecting would do, without doing it. **Connect** then connects each one's user here to the account, creating it if they have none yet (before their mailboxes are imported, too), or switching it on, with access to the mailboxes their imported conversations are in, if an import created it disabled. Someone deleted on the source is connected and credited, but their user isn't switched on or given access, and one created for them is disabled. Someone whose account is connected to a user already is credited to that user instead, so they don't get a second one. - Connecting lets the account's owner log in as that user. So only users an import created, or whose email is the account's, are connected in bulk, and administrators only by their email; anyone else is connected on their profile. - A CSV connects each user once, each account once, and each user here to one account; the check flags rows that would do more. More rows naming an account, like someone's older, deleted users there, are credited to the user its first row connects, or to the one it's connected to already, so they're one user here. - A row whose account has neither the user's name nor their email there is highlighted, and only connected if its box is checked. diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/connect.blade.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/connect.blade.php index 73dfe4e17d..e6bb08e8cd 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/connect.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/connect.blade.php @@ -69,6 +69,9 @@ {{ $step['error'] }} @else {{ $actions[ $step['action'] ] }} + @if ( $step['deleted'] ) +
{{ __('Deleted on :name: not switched on', [ 'name' => $name ]) }} + @endif @if ( $step['mismatch'] )
@endif diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php index af568f9858..18b5be635c 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php @@ -38,7 +38,7 @@

{{ __('Connect to WordPress.org accounts') }}

-

{{ __('Download :name’s users, fill in the wporg_username column for those who’ll work here, and check it here. Each one’s user here is connected to that account, created if they have none yet, and switched on if an import created them disabled: their name, email, and avatar come from WordPress.org, and they can log in with it. Someone whose account is connected to a user already is credited to that user.', [ 'name' => $name ]) }}

+

{{ __('Download :name’s users, fill in the wporg_username column for those who’ll work here, and check it here. Each one’s user here is connected to that account, created if they have none yet, and switched on if an import created them disabled, unless they’re deleted there: their name, email, and avatar come from WordPress.org, and they can log in with it. Someone whose account is connected to a user already is credited to that user.', [ 'name' => $name ]) }}

{{ __('Download CSV') }}

@if ( $can_connect )
diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/People.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/People.php index 9ae6cb7e1c..3b7c55c34f 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/People.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/People.php @@ -296,7 +296,7 @@ public static function remote_user( RemoteApi $api, int $remote_user_id ): ?arra } /** - * The user for a remote user who'll work here: the one they have already, or a new, active one. + * The user for a remote user who'll work here: the one they have already, or a new one, disabled if they're deleted there. * * @param int $source_id Source. * @param array $remote_user Remote user. @@ -305,7 +305,17 @@ public static function remote_user( RemoteApi $api, int $remote_user_id ): ?arra public static function find_or_create( int $source_id, array $remote_user ): User { $existing = self::existing( $source_id, $remote_user ); - return $existing ? $existing['user'] : self::match_or_create( $source_id, $remote_user, true ); + return $existing ? $existing['user'] : self::match_or_create( $source_id, $remote_user, ! self::is_deleted( $remote_user ) ); + } + + /** + * Whether a remote user is deleted there: FreeScout appends a suffix and the time to a deleted user's email. + * + * @param array $remote_user Remote user. + * @return bool + */ + public static function is_deleted( array $remote_user ): bool { + return 1 === preg_match( '/' . preg_quote( User::EMAIL_DELETED_SUFFIX, '/' ) . '\d{14}$/', (string) ( $remote_user['email'] ?? '' ) ); } /** diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php index fc8cdd5207..9181670c13 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php @@ -177,7 +177,8 @@ public static function parse( string $csv ): array { * @param array[] $rows Rows, from parse(). * @return array[] Each with `row`, `remote_user` (or null), `wporg_user` (WordPressOrgUser, or null), `action` * (one of the constants, or null), `user` (the user here it's for, or null for a new one), - * `mismatch`, and `error` (why nothing can be done, or ''). + * `mismatch`, `deleted` (whether the remote user is deleted there, so isn't switched on), and + * `error` (why nothing can be done, or ''). */ public function plan( array $rows ): array { $client = Client::from_config(); @@ -268,7 +269,9 @@ function () use ( $step, $connected, $username, $id ): User { } People::choose( $this->source_id, $id, $user ); - People::activate( $user ); + if ( ! $step['deleted'] ) { + People::activate( $user ); + } return $user; } @@ -337,6 +340,7 @@ private function plan_row( array $row, Client $client ): array { 'action' => null, 'user' => null, 'mismatch' => false, + 'deleted' => false, 'error' => '', ); @@ -344,6 +348,8 @@ private function plan_row( array $row, Client $client ): array { return array( 'error' => __( 'No user there with that ID or email.' ) ) + $step; } + $step['deleted'] = People::is_deleted( $step['remote_user'] ); + try { $step['wporg_user'] = $this->lookup( $row['username'], $client ); } catch ( \Throwable $e ) { diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/module.json b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/module.json index 4ba31586b1..97e83bc784 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/module.json +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/module.json @@ -3,7 +3,7 @@ "alias": "wporgfreescoutimport", "img": "/modules/wporgfreescoutimport/img/icon.svg", "description": "Imports other FreeScouts' mailboxes, so their history moves here with them.", - "version": "1.1.0", + "version": "1.1.1", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgFreeScoutImport", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/tests/ImportTest.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/tests/ImportTest.php index 23a8702b74..9930dca795 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/tests/ImportTest.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/tests/ImportTest.php @@ -321,7 +321,8 @@ public function test_a_run_imports_every_page_and_finds_conversations_by_their_o /** * A CSV connects users who'll work here: one an import created disabled is switched on, with access to its mailbox, - * and someone new gets a user; one who was here before, under another email than the account's, isn't connected. + * and someone new gets a user, disabled if they're deleted there; one who was here before, under another email than + * the account's, isn't connected. * * @return void */ @@ -332,6 +333,7 @@ public function test_csv_connects_users_who_will_work_here(): void { 'adaagent' => array( 'Ada', 'Agent', 'ada@wordpress.example' ), 'noranote' => array( 'Nora', 'Note', 'nora@wordpress.example' ), 'bonew' => array( 'Bo', 'New', 'bo@wordpress.example' ), + 'ezragone' => array( 'Ezra', 'Gone', 'ezra@wordpress.example' ), ) ); $this->remote->json( @@ -342,6 +344,7 @@ public function test_csv_connects_users_who_will_work_here(): void { self::user( 55, 'Ada', 'Agent', 'agent@example.org' ), self::user( 66, 'Nora', 'Note', 'nora@wcus.test' ), self::user( 77, 'Bo', 'New', 'bo@wcus.test' ), + self::user( 99, 'Ezra', 'Gone', 'ezra@wcus.test_deleted20210601184317' ), ) ) ); @@ -351,11 +354,11 @@ public function test_csv_connects_users_who_will_work_here(): void { ->post( route( 'wporgfreescoutimport.users.connect', array( 'id' => $this->source->id ) ), array( - 'csv' => "remote_id,wporg_username\n55,adaagent\n66,noranote\n77,bonew\n", + 'csv' => "remote_id,wporg_username\n55,adaagent\n66,noranote\n77,bonew\n99,ezragone\n", 'apply' => 1, ) ) - ->assertSessionHas( 'flash_success', 'Connected 2 users to WordPress.org accounts.' ); + ->assertSessionHas( 'flash_success', 'Connected 3 users to WordPress.org accounts.' ); $nora = User::query()->findOrFail( (int) RemoteUser::query()->where( 'source_id', $this->source->id )->where( 'remote_user_id', 66 )->value( 'user_id' ) ); $this->assertSame( 'noranote', Account::username_for( (int) $nora->id ) ); @@ -366,12 +369,16 @@ public function test_csv_connects_users_who_will_work_here(): void { $this->assertSame( 'bonew', Account::username_for( (int) $bo->id ) ); $this->assertSame( User::STATUS_ACTIVE, (int) $bo->status ); + $ezra = User::query()->findOrFail( (int) RemoteUser::query()->where( 'source_id', $this->source->id )->where( 'remote_user_id', 99 )->value( 'user_id' ) ); + $this->assertSame( 'ezragone', Account::username_for( (int) $ezra->id ) ); + $this->assertSame( User::STATUS_DISABLED, (int) $ezra->status ); + $this->assertSame( '', Account::username_for( (int) $this->agent->id ) ); } /** * Rows naming the same account, like someone's older, deleted users there, are all credited to the one user the - * account is connected to. + * account is connected to, switched on even when the row of a deleted one comes first. * * @return void */ @@ -393,7 +400,7 @@ public function test_csv_credits_rows_naming_the_same_account_to_one_user(): voi ->post( route( 'wporgfreescoutimport.users.connect', array( 'id' => $this->source->id ) ), array( - 'csv' => "remote_id,wporg_username\n66,noranote\n88,noranote\n", + 'csv' => "remote_id,wporg_username\n88,noranote\n66,noranote\n", 'apply' => 1, ) ) @@ -402,6 +409,7 @@ public function test_csv_credits_rows_naming_the_same_account_to_one_user(): voi $users = RemoteUser::query()->where( 'source_id', $this->source->id )->pluck( 'user_id', 'remote_user_id' )->map( 'intval' )->all(); $this->assertSame( $users[66], $users[88] ?? null ); $this->assertSame( 'noranote', Account::username_for( $users[66] ) ); + $this->assertSame( User::STATUS_ACTIVE, (int) User::query()->findOrFail( $users[66] )->status ); } /** From c0dd5318956eaf520a009557d455ccd00d2f3693 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 6 Nov 2026 14:39:12 -0600 Subject: [PATCH 2/3] FreeScout: Keep mailbox access for active users of deleted rows A deleted remote user's row skipped People::activate() entirely, so an already-active user it was credited to, like a connected agent or one an earlier row switched on, didn't get access to the mailboxes that user's conversations are in. Only a disabled user is left alone now. Co-Authored-By: Claude Opus 5.5 --- .../WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php index 9181670c13..4a0f0a9305 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Services/WordPressOrgAccounts.php @@ -269,7 +269,8 @@ function () use ( $step, $connected, $username, $id ): User { } People::choose( $this->source_id, $id, $user ); - if ( ! $step['deleted'] ) { + // A deleted remote user's row doesn't switch their user on, but still gives an active one access. + if ( ! $step['deleted'] || User::STATUS_ACTIVE === (int) $user->status ) { People::activate( $user ); } From 17432798a1971c28e9745f0cacb871180ac68a32 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 6 Nov 2026 14:41:49 -0600 Subject: [PATCH 3/3] FreeScout: Say a connected user logs in only once switched on The Users page said every connected user can log in with their WordPress.org account, but one deleted on the source stays disabled. The README said such a user isn't given mailbox access either, while an already active user still gets access to the mailboxes they're credited in, so it now says only that a disabled one stays disabled. Co-Authored-By: Claude Opus 5.5 --- freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md | 2 +- .../WPOrgFreeScoutImport/Resources/views/users.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md index bbfddec49d..349dfa058b 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/README.md @@ -18,7 +18,7 @@ Copies mailboxes' conversations from other FreeScouts, like WordCamp US's at sco - **What doesn't:** spam, drafts, and deleted conversations; other line items, like moves and merges; saved replies, workflows, custom folders, and mailbox settings, which the API doesn't give: set those up again by hand. Senders come across with their name and email only. - **Conversations agents worked on here:** once a conversation has a reply, note, or change made here, importing it again only adds the new threads. Its status and assignee stay this FreeScout's. What automatic workflows note when they run doesn't count. A conversation moved to another mailbox there moves here too, unless agents worked on it. - **Users:** every user's replies, notes, and assignments are credited to a user here: the one chosen on the source's **Users** page, under **Manage » FreeScout Import**, or else the one with the same email, or else a new, disabled one that keeps their name. Active users get access to the mailboxes an import credits them with something in. - - **Connecting in bulk:** on a source's Users page, **Download CSV** lists its users with their users here. Fill in its `wporg_username` column for those who'll work here, upload or paste it, and **Check** it: that shows, for each row, the WordPress.org account's name and email next to the user's, and what connecting would do, without doing it. **Connect** then connects each one's user here to the account, creating it if they have none yet (before their mailboxes are imported, too), or switching it on, with access to the mailboxes their imported conversations are in, if an import created it disabled. Someone deleted on the source is connected and credited, but their user isn't switched on or given access, and one created for them is disabled. Someone whose account is connected to a user already is credited to that user instead, so they don't get a second one. + - **Connecting in bulk:** on a source's Users page, **Download CSV** lists its users with their users here. Fill in its `wporg_username` column for those who'll work here, upload or paste it, and **Check** it: that shows, for each row, the WordPress.org account's name and email next to the user's, and what connecting would do, without doing it. **Connect** then connects each one's user here to the account, creating it if they have none yet (before their mailboxes are imported, too), or switching it on, with access to the mailboxes their imported conversations are in, if an import created it disabled. Someone deleted on the source is connected and credited, but isn't switched on: a user created for them, or one an import created disabled, stays disabled. Someone whose account is connected to a user already is credited to that user instead, so they don't get a second one. - Connecting lets the account's owner log in as that user. So only users an import created, or whose email is the account's, are connected in bulk, and administrators only by their email; anyone else is connected on their profile. - A CSV connects each user once, each account once, and each user here to one account; the check flags rows that would do more. More rows naming an account, like someone's older, deleted users there, are credited to the user its first row connects, or to the one it's connected to already, so they're one user here. - A row whose account has neither the user's name nor their email there is highlighted, and only connected if its box is checked. diff --git a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php index 18b5be635c..1d9df850a2 100644 --- a/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgFreeScoutImport/Resources/views/users.blade.php @@ -38,7 +38,7 @@

{{ __('Connect to WordPress.org accounts') }}

-

{{ __('Download :name’s users, fill in the wporg_username column for those who’ll work here, and check it here. Each one’s user here is connected to that account, created if they have none yet, and switched on if an import created them disabled, unless they’re deleted there: their name, email, and avatar come from WordPress.org, and they can log in with it. Someone whose account is connected to a user already is credited to that user.', [ 'name' => $name ]) }}

+

{{ __('Download :name’s users, fill in the wporg_username column for those who’ll work here, and check it here. Each one’s user here is connected to that account, created if they have none yet, and switched on if an import created them disabled, unless they’re deleted there: their name, email, and avatar come from WordPress.org, and once their user is on, they can log in with it. Someone whose account is connected to a user already is credited to that user.', [ 'name' => $name ]) }}

{{ __('Download CSV') }}

@if ( $can_connect )