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

  1. Ensure a User entity is mapped to a Contact via crm_user_contact_mapping, with a primary crm_contact_method of bundle email on the Contact.
  2. Update the User's email address on the user account edit form and save.
  3. Load the mapped Contact and inspect its emails field — the primary email contact method retains the old email address.
  4. Conversely, edit the primary crm_contact_method on the Contact directly and save it.
  5. Load the mapped User — user.mail retains 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.mail is the authoritative source. Changes to the User's email propagate to the Contact's primary email method. The Contact's emails field is access-restricted to edit for 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_method propagate to user.mail. The User's mail field is access-restricted to edit for users that have a mapping. A companion config key email_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_update added to UserHooks, calling UserEmailSyncService::onUserEmailChanged().
  • hook_crm_contact_method_update added to ContactMethodHooks, calling UserEmailSyncService::onContactEmailChanged(). Only the primary email method (where primary = 1 on the Contact's emails reference 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 UserEmailSyncService with onUserEmailChanged() and onContactEmailChanged() methods and re-entry guard.
  • Register crm.user_email_sync in crm.services.yml.
  • Add #[Hook('user_update')] to UserHooks.
  • Add #[Hook('crm_contact_method_update')] to ContactMethodHooks.
  • Add email_sync_direction and email_sync_send_verification config keys to crm.user_contact_mapping.settings config schema.
  • Extend UserHooks::entityFieldAccess() to restrict user.mail edits when direction is contact_to_user.
  • Add hook_entity_field_access() handling for the Contact's emails field when direction is user_to_contact.
  • Add email_sync_direction and email_sync_send_verification fields to UserContactMappingSettingsForm.
  • Add a Drush command to CrmCommands for bulk one-time sync of all mapped user emails to contacts (useful for existing sites enabling this feature).
  • Write unit tests for UserEmailSyncService covering 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 the contact_to_user sync path.

No new database tables. No changes to existing entity definitions.

Issue fork crm-3590407

Command icon 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

bluegeek9 created an issue.