Summary
Add a self-service and admin-triggered "Change email" feature, mirroring the existing PasswordResetAdmin flow. WorkOS verifies ownership of the new address; WordPress guards against collisions with existing local accounts. Behavior on conflict is configurable — default is block.
Future "claim & merge" handling for the conflict case is tracked separately (see follow-up issue).
Goals
- Let a user change their primary email with WorkOS-driven verification (double opt-in to the new address).
- Let an admin initiate the same change for another user (
edit_user capability), mirroring the password-reset admin pattern.
- Prevent silently overwriting another local WP user's email; expose the conflict policy as an option.
- Keep WorkOS ⇄ WP email state consistent (no double-processing when the WorkOS webhook fires).
- Provide hooks/events so other features (and Issue 2's merge flow) can extend the behavior.
Non-goals
- Account merging / claiming an existing account. That's the follow-up issue.
- Changing the WorkOS organization, profile attributes other than email, or username.
- Bulk email changes.
User flows
Self-service (logged-in user)
- User opens profile/shortcode page, enters new email.
- WP validates format, runs conflict check, applies rate limit.
- WP stores a pending change (token + hash + expiry) in user meta.
- Verification email is sent to the new address with a confirm link.
- (Optional, default on) Notification email is sent to the old address: "An email change was requested. If this wasn't you…" with a cancel link.
- User clicks confirm → WP re-checks conflict (race guard) → WP calls WorkOS
update_user(['email' => ...]) → WP calls wp_update_user() → pending meta is cleared → logged.
- (Optional, default on) Confirmation notice is sent to the old address: "Your email was changed to ✱✱✱@example.com."
Admin-initiated
- "Change email" row action under the existing WorkOS column on
wp-admin/users.php.
- "Change email" panel on user-edit.php (mirrors
PasswordResetAdmin\UserProfilePanel).
- Admin enters the new email; verification still goes to the new address (admins do not bypass verification by default — gated by a filter, see Hooks).
WorkOS API touchpoints
The plugin already has what we need in src/WorkOS/Api/Client.php:
update_user( $workos_user_id, ['email' => $new_email] ) — commits the change in WorkOS.
- Verification is performed on the WP side via our own signed token (same pattern as
PasswordResetAdmin), because WorkOS's email_verification endpoints verify the current email on the WorkOS user, not a pending change.
(Open question: if WorkOS's update_user triggers an automatic user.updated webhook, Sync\UserSync::handle_user_updated() must not race with our confirmation handler. Plan: set a short-lived transient _workos_email_change_in_progress_<user_id> and have UserSync skip if set.)
Settings / options
Stored under the active environment (workos()->option(...)), with sensible defaults:
| Option |
Default |
Purpose |
change_email_enabled |
true |
Master switch |
change_email_conflict_policy |
'block' |
block | allow_orphan | merge_request (see below) |
change_email_token_lifetime |
3600 |
Seconds the confirm token is valid |
change_email_rate_limit_user |
3 per hour |
Per-user initiate cap |
change_email_rate_limit_ip |
10 per hour |
Per-IP initiate cap |
change_email_notify_old_address |
true |
Send "change requested" + "change confirmed" notices to old address |
change_email_require_reauth |
true |
Require password / fresh session before initiating self-service |
change_email_admin_bypass_verification |
false |
When true, an admin with edit_users may commit the change without email verification (audit-logged) |
Conflict policy values
block (default): hard reject. The user sees: "An account with that email already exists."
allow_orphan: allowed only if the conflicting WP user has no _workos_user_id and no posts/comments/logins in the last N days (filterable). Audit-logged as a takeover.
merge_request: rejected for now, but emits workos/change_email/merge_requested for the future merge feature (Issue 2) to handle. Until Issue 2 ships this behaves like block plus a user-facing message: "An account with this email exists. Merging is not yet available."
Architecture (mirrors PasswordResetAdmin)
src/WorkOS/Auth/ChangeEmail/
├── Controller.php DI wiring (registered from src/WorkOS/Controller.php)
├── RestApi.php POST /workos/v1/users/{id}/email-change
│ POST /workos/v1/users/{id}/email-change/confirm
│ POST /workos/v1/users/{id}/email-change/cancel
├── PendingChange.php Storage helper (user_meta `_workos_pending_email_change`)
│ — fields: new_email, token_hash, expires_at, initiated_by, initiated_at
├── ConflictResolver.php Local WP + WorkOS conflict checks + policy enforcement
├── Notifier.php Sends verification / cancel-link / confirmation emails
├── RowActions.php Admin row action on users.php
├── UserProfilePanel.php Admin panel on user-edit.php
├── Shortcode.php [workos:change-email] for self-service
├── Assets.php JS/CSS registration
└── RedirectValidator.php Reuse Auth\PasswordResetAdmin\RedirectValidator if generic enough
src/js/admin-change-email/
└── index.ts Delegated click handler (mirrors admin-password-reset)
templates/change-email/
├── verification-email.php
├── old-address-notice.php
└── confirmation-page.php
tests/wpunit/
├── ChangeEmailRestApiTest.php
├── ChangeEmailConflictResolverTest.php
├── ChangeEmailPendingChangeTest.php
└── ChangeEmailNotifierTest.php
REST endpoints
All under /wp-json/workos/v1/.
POST users/{id}/email-change
- Auth: current user is
{id} OR has edit_user cap for {id}.
- Body:
{ new_email, redirect_url? }.
- Capability + rate-limit checks (reuse
RateLimiter).
- Email format + conflict checks.
- Stores pending change, sends verification, returns enumeration-safe success.
POST users/{id}/email-change/confirm
- Body:
{ token }.
- Validates token + expiry, re-runs conflict check.
- Calls WorkOS
update_user → on success calls wp_update_user → clears meta → logs event.
- Returns the post-change
redirect_url (validated).
POST users/{id}/email-change/cancel
- Body:
{ token } (from the notice-to-old-address link) or capability edit_user.
- Clears pending meta, logs event.
Activity events
Logged via the existing EventLogger:
email_change.initiated
email_change.confirmed
email_change.cancelled
email_change.expired
email_change.conflict_blocked
email_change.admin_bypass (when change_email_admin_bypass_verification triggers)
Each entry stores { user_id, initiated_by, masked_new_email, masked_old_email, policy }.
Hooks
Filters:
workos/change_email/enabled — bool
workos/change_email/conflict_policy — string (overrides setting)
workos/change_email/token_lifetime — int
workos/change_email/can_initiate — bool (with $user_id, $initiator_id)
workos/change_email/notification_recipients — array (extend old/new address logic)
Actions:
workos/change_email/initiated — ( int $user_id, string $new_email, int $initiated_by )
workos/change_email/confirmed — ( int $user_id, string $old_email, string $new_email )
workos/change_email/cancelled — ( int $user_id, string $reason )
workos/change_email/conflict_detected — ( int $user_id, string $new_email, int $conflicting_user_id ) (the hook Issue 2's merge flow listens on)
Security checklist
Tests (slic / WPUnit)
- Initiate: valid input → pending meta written, verification email captured via
pre_http_request/wp_mail mock.
- Initiate: invalid email format →
WP_Error.
- Initiate: conflict with local user →
WP_Error under block; success-but-no-email under merge_request.
- Initiate: rate limit per user / per IP.
- Initiate: capability rejection when admin tries on another user without
edit_user.
- Confirm: valid token → WP user email updated, WorkOS
update_user called, pending meta cleared, event logged.
- Confirm: expired token → error, no API call.
- Confirm: tampered token → error.
- Confirm: race — conflict appears between initiate and confirm → reject and roll back cleanly.
- Cancel: token from old-address notice clears pending state.
- Notifier: sends to new address on initiate, to old address on initiate (when enabled) and on confirm.
- Webhook race:
UserSync::handle_user_updated() for the same email is a no-op while in-progress transient is set.
Documentation
- Add
docs/change-email.md (full feature reference).
- Update
AGENTS.md with a "ChangeEmail" section mirroring "PasswordResetAdmin".
- Update
README.md user-facing section.
- Add shortcode docs.
- Update
readme.txt (wp.org changelog).
Acceptance criteria
Out of scope (tracked separately)
- Account merging / claiming an existing account when the new email collides.
Summary
Add a self-service and admin-triggered "Change email" feature, mirroring the existing PasswordResetAdmin flow. WorkOS verifies ownership of the new address; WordPress guards against collisions with existing local accounts. Behavior on conflict is configurable — default is block.
Future "claim & merge" handling for the conflict case is tracked separately (see follow-up issue).
Goals
edit_usercapability), mirroring the password-reset admin pattern.Non-goals
User flows
Self-service (logged-in user)
update_user(['email' => ...])→ WP callswp_update_user()→ pending meta is cleared → logged.Admin-initiated
wp-admin/users.php.PasswordResetAdmin\UserProfilePanel).WorkOS API touchpoints
The plugin already has what we need in
src/WorkOS/Api/Client.php:update_user( $workos_user_id, ['email' => $new_email] )— commits the change in WorkOS.PasswordResetAdmin), because WorkOS'semail_verificationendpoints verify the current email on the WorkOS user, not a pending change.(Open question: if WorkOS's
update_usertriggers an automaticuser.updatedwebhook,Sync\UserSync::handle_user_updated()must not race with our confirmation handler. Plan: set a short-lived transient_workos_email_change_in_progress_<user_id>and haveUserSyncskip if set.)Settings / options
Stored under the active environment (
workos()->option(...)), with sensible defaults:change_email_enabledtruechange_email_conflict_policy'block'block|allow_orphan|merge_request(see below)change_email_token_lifetime3600change_email_rate_limit_user3 per hourchange_email_rate_limit_ip10 per hourchange_email_notify_old_addresstruechange_email_require_reauthtruechange_email_admin_bypass_verificationfalseedit_usersmay commit the change without email verification (audit-logged)Conflict policy values
block(default): hard reject. The user sees: "An account with that email already exists."allow_orphan: allowed only if the conflicting WP user has no_workos_user_idand no posts/comments/logins in the last N days (filterable). Audit-logged as a takeover.merge_request: rejected for now, but emitsworkos/change_email/merge_requestedfor the future merge feature (Issue 2) to handle. Until Issue 2 ships this behaves likeblockplus a user-facing message: "An account with this email exists. Merging is not yet available."Architecture (mirrors PasswordResetAdmin)
REST endpoints
All under
/wp-json/workos/v1/.POST users/{id}/email-change{id}OR hasedit_usercap for{id}.{ new_email, redirect_url? }.RateLimiter).POST users/{id}/email-change/confirm{ token }.update_user→ on success callswp_update_user→ clears meta → logs event.redirect_url(validated).POST users/{id}/email-change/cancel{ token }(from the notice-to-old-address link) or capabilityedit_user.Activity events
Logged via the existing
EventLogger:email_change.initiatedemail_change.confirmedemail_change.cancelledemail_change.expiredemail_change.conflict_blockedemail_change.admin_bypass(whenchange_email_admin_bypass_verificationtriggers)Each entry stores
{ user_id, initiated_by, masked_new_email, masked_old_email, policy }.Hooks
Filters:
workos/change_email/enabled— boolworkos/change_email/conflict_policy— string (overrides setting)workos/change_email/token_lifetime— intworkos/change_email/can_initiate— bool (with$user_id,$initiator_id)workos/change_email/notification_recipients— array (extend old/new address logic)Actions:
workos/change_email/initiated—( int $user_id, string $new_email, int $initiated_by )workos/change_email/confirmed—( int $user_id, string $old_email, string $new_email )workos/change_email/cancelled—( int $user_id, string $reason )workos/change_email/conflict_detected—( int $user_id, string $new_email, int $conflicting_user_id )(the hook Issue 2's merge flow listens on)Security checklist
Tests (slic / WPUnit)
pre_http_request/wp_mailmock.WP_Error.WP_Errorunderblock; success-but-no-email undermerge_request.edit_user.update_usercalled, pending meta cleared, event logged.UserSync::handle_user_updated()for the same email is a no-op while in-progress transient is set.Documentation
docs/change-email.md(full feature reference).AGENTS.mdwith a "ChangeEmail" section mirroring "PasswordResetAdmin".README.mduser-facing section.readme.txt(wp.org changelog).Acceptance criteria
blockis the default and cannot be bypassed without explicit admin opt-in.UserSync(existing tests pass).Out of scope (tracked separately)