From 6e1e268cf5448e479e9115d8a002ae049183718f Mon Sep 17 00:00:00 2001 From: Cyril Nxumalo <80963114+zwidekalanga@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:00:26 +0200 Subject: [PATCH] feat: suppress activation email for registrations from trusted in-product flows via param --- .../djangoapps/user_authn/views/register.py | 37 ++++++++++++++++-- .../user_authn/views/tests/test_register.py | 38 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 1529cc12f7e2..925e32c8aeff 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -106,6 +106,15 @@ } REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at' IS_MARKETABLE = 'is_marketable' + +# Name of the UserAttribute that records which flow originated a registration. +REGISTRATION_SOURCE = 'registration_source' +# Known registration sources that should NOT trigger the account activation email. +# Learners registered through these flows are auto-activated because another trusted +# system has already vouched for the email address. +REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL = frozenset({ + 'enterprise_sponsor_checkout', +}) # used to announce a registration # providing_args=["user", "registration"] REGISTER_USER = Signal() @@ -241,9 +250,21 @@ def create_account_with_params(request, params): # pylint: disable=too-many-sta if not preferences_api.has_user_preference(user, LANGUAGE_KEY): preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) + # Only accept a registration_source value from callers if it is on the + # allowlist. Arbitrary values supplied by clients are dropped so they + # cannot unilaterally suppress the activation email. + raw_registration_source = params.get('registration_source') + registration_source = ( + raw_registration_source + if raw_registration_source in REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL + else None + ) + if registration_source: + UserAttribute.set_user_attribute(user, REGISTRATION_SOURCE, registration_source) + # Check if system is configured to skip activation email for the current user. skip_email = _skip_activation_email( - user, running_pipeline, third_party_provider, + user, running_pipeline, third_party_provider, registration_source=registration_source, ) if skip_email: @@ -425,7 +446,7 @@ def _track_user_registration(user, profile, params, third_party_provider, regist ) -def _skip_activation_email(user, running_pipeline, third_party_provider): +def _skip_activation_email(user, running_pipeline, third_party_provider, registration_source=None): """ Return `True` if activation email should be skipped. @@ -435,6 +456,8 @@ def _skip_activation_email(user, running_pipeline, third_party_provider): 3. External auth bypassing activation. 4. Have the platform configured to not require e-mail activation. 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) + 6. Registering via a trusted in-product flow that has already vetted the email + (see REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL). Note that this feature is only tested as a flag set one way or the other for *new* systems. we need to be careful about @@ -445,6 +468,9 @@ def _skip_activation_email(user, running_pipeline, third_party_provider): user (User): Django User object for the current user. running_pipeline (dict): Dictionary containing user and pipeline data for third party authentication. third_party_provider (ProviderConfig): An instance of third party provider configuration. + registration_source (str|None): Identifier of the originating registration flow, if + supplied by the caller. Only values in REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL + grant the skip. Returns: (bool): `True` if account activation email should be skipped, `False` if account activation email should be @@ -475,10 +501,15 @@ def _skip_activation_email(user, running_pipeline, third_party_provider): getattr(third_party_provider, "identity_provider_type", None) ) + trusted_registration_source = ( + registration_source in REGISTRATION_SOURCES_SKIP_ACTIVATION_EMAIL + ) + return ( settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) or settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') or - (third_party_provider and third_party_provider.skip_email_verification and valid_email) + (third_party_provider and third_party_provider.skip_email_verification and valid_email) or + trusted_registration_source ) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 396513f49653..31a86b0b5b58 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -21,6 +21,7 @@ from testfixtures import LogCapture from common.djangoapps.student.helpers import authenticate_new_user +from common.djangoapps.student.models import UserAttribute from common.djangoapps.student.tests.factories import AccountRecoveryFactory, UserFactory from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline from common.djangoapps.third_party_auth.tests.utils import ( @@ -1586,6 +1587,43 @@ def test_activation_email(self): f'Action Required: Activate your {settings.PLATFORM_NAME} account' assert f'high-quality {settings.PLATFORM_NAME} courses' in sent_email.body + def test_trusted_registration_source_skips_activation_email(self): + # Registration through a trusted in-product flow should auto-activate the + # account and suppress the welcome/activation email. + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + "registration_source": "enterprise_sponsor_checkout", + }) + self.assertHttpOK(response) + + assert mail.outbox == [] + user = User.objects.get(username=self.USERNAME) + assert user.is_active + assert UserAttribute.get_user_attribute(user, 'registration_source') == \ + 'enterprise_sponsor_checkout' + + def test_unknown_registration_source_does_not_skip_activation_email(self): + # Unknown registration_source values must not grant the skip. The activation + # email still goes out and the attribute is not recorded. + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + "registration_source": "totally-made-up", + }) + self.assertHttpOK(response) + + assert len(mail.outbox) == 1 + user = User.objects.get(username=self.USERNAME) + assert not user.is_active + assert UserAttribute.get_user_attribute(user, 'registration_source') is None + @ddt.data( {"email": ""}, {"email": "invalid"},