Problem/Motivation
The CRM module integrates with the Drupal User entity through crm_user_contact_mapping, virtual crm__* computed fields, and form submit handlers that sync mapped contact fields when a user saves their account. However, there is a gap in this integration: the User entity's mail field and the Contact entity's primary email address — stored as a crm_contact_method entity of bundle email, referenced via the Contact's emails field — are not kept in sync.
There is no hook_user_update implementation in the module. When a user updates their email address through the account form, programmatically, or via Drush, the mapped contact's primary email contact method is not updated. Conversely, when a CRM administrator updates the primary email contact method on a contact that has a mapped user, user.mail is not updated. This creates silent data inconsistency between the two entities that undermines the integrity of the user–contact integration.
Steps to reproduce
- Ensure a User entity is mapped to a Contact via
crm_user_contact_mapping, with a primarycrm_contact_methodof bundleemailon the Contact. - Update the User's email address on the user account edit form and save.
- Load the mapped Contact and inspect its
emailsfield — the primary email contact method retains the old email address. - Conversely, edit the primary
crm_contact_methodon the Contact directly and save it. - Load the mapped User —
user.mailretains the old email address.
Proposed resolution
Implement a dedicated UserEmailSyncService (crm.user_email_sync) that owns the complete email sync contract between user.mail and the Contact's primary crm_contact_method of bundle email. All sync logic — change detection via $entity->original, re-entry loop prevention, primary email resolution, directional config checks, and optional Drupal email verification — is centralized in this single service, consistent with the module's existing service-oriented architecture (UserContactMappingService, UserContactDisplaySyncService).
A new config key email_sync_direction is added to crm.user_contact_mapping.settings with four values:
- user_to_contact (default):
user.mailis the authoritative source. Changes to the User's email propagate to the Contact's primary email method. The Contact'semailsfield is access-restricted toeditfor contacts that have a mapped User. - contact_to_user: The Contact's primary email method is the authoritative source. Changes to the primary
crm_contact_methodpropagate touser.mail. The User'smailfield is access-restricted toeditfor users that have a mapping. A companion config keyemail_sync_send_verification(default:FALSE) controls whether a verification email is sent to the user when their email is administratively changed via this path. - bidirectional: Both directions are active. Last save wins. No field access restrictions are applied.
- disabled: No automatic sync. Both entities hold their email independently.
The service is invoked from two hook implementations:
hook_user_updateadded toUserHooks, callingUserEmailSyncService::onUserEmailChanged().hook_crm_contact_method_updateadded toContactMethodHooks, callingUserEmailSyncService::onContactEmailChanged(). Only the primary email method (whereprimary = 1on the Contact'semailsreference field) triggers a user sync.
Re-entry loop prevention is handled by a private bool $syncing flag on the service instance. Each handler returns early if $syncing is TRUE.
Field access restrictions are enforced via the existing hook_entity_field_access() implementation in UserHooks::entityFieldAccess(), extended to cover user.mail when direction is contact_to_user and the user has a mapping, and via a new check in hook_entity_field_access() for the Contact's emails field when direction is user_to_contact and the contact has a mapped user. Access results carry proper cache metadata (addCacheContexts(['user']) and the mapping's cache tags) to ensure correct cache invalidation when mappings change.
Note that multi-email management (adding, editing, and removing secondary email addresses on a Contact) is intentionally out of scope for this service. That use case is already served by exposing the Contact's emails field through the existing crm__* field mapping system. This service exclusively concerns user.mail and the single primary email contact method.
Remaining tasks
- Implement
UserEmailSyncServicewithonUserEmailChanged()andonContactEmailChanged()methods and re-entry guard. - Register
crm.user_email_syncincrm.services.yml. - Add
#[Hook('user_update')]toUserHooks. - Add
#[Hook('crm_contact_method_update')]toContactMethodHooks. - Add
email_sync_directionandemail_sync_send_verificationconfig keys tocrm.user_contact_mapping.settingsconfig schema. - Extend
UserHooks::entityFieldAccess()to restrictuser.mailedits when direction iscontact_to_user. - Add
hook_entity_field_access()handling for the Contact'semailsfield when direction isuser_to_contact. - Add
email_sync_directionandemail_sync_send_verificationfields toUserContactMappingSettingsForm. - Add a Drush command to
CrmCommandsfor bulk one-time sync of all mapped user emails to contacts (useful for existing sites enabling this feature). - Write unit tests for
UserEmailSyncServicecovering all four directions, loop prevention, primary-only filtering, and no-op when email has not changed. - Write kernel tests covering the full save pipeline in both directions.
User interface changes
New email_sync_direction select field and email_sync_send_verification checkbox added to the User Contact Mapping settings form (/admin/config/crm/user-contact-mapping).
When direction is contact_to_user, the mail field on the user account form is rendered as non-editable for users that have a CRM mapping, as a consequence of the field access check. A description is added to the field indicating that the email address is managed by the linked CRM contact.
When direction is user_to_contact, the emails field widget on the contact edit form is rendered as non-editable for contacts that have a mapped user, as a consequence of the field access check.
API changes
New service crm.user_email_sync implementing a new UserEmailSyncInterface with onUserEmailChanged(UserInterface $user): void and onContactEmailChanged(ContactMethodInterface $method): void.
New #[Hook('user_update')] implementation in UserHooks.
New #[Hook('crm_contact_method_update')] implementation in ContactMethodHooks.
Extended hook_entity_field_access() logic in UserHooks::entityFieldAccess() and a new field access check for the Contact's emails field.
Data model changes
Two new keys added to the crm.user_contact_mapping.settings configuration object and its schema:
email_sync_direction(string, default:user_to_contact): controls sync direction.email_sync_send_verification(boolean, default:FALSE): controls whether a Drupal verification email is sent to the user when their email is updated via thecontact_to_usersync path.
No new database tables. No changes to existing entity definitions.
Issue fork crm-3590407
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments