From db5db262b4dcf76298b1266b4d960b75940f73f5 Mon Sep 17 00:00:00 2001 From: Nallagatla Thriveni Date: Tue, 31 Mar 2026 09:32:26 +0000 Subject: [PATCH 1/2] feat: add SES routing for account activation emails with fallback support --- common/djangoapps/student/views/management.py | 2 + openedx/core/djangoapps/ace_common/utils.py | 42 ++++++++ openedx/core/djangoapps/user_authn/tasks.py | 100 +++++++++++++++--- 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 4cf8fad8aea5..6f206a640012 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -89,6 +89,7 @@ from common.djangoapps.util.json_request import JsonResponse from common.djangoapps.student.signals import USER_EMAIL_CHANGED from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.ace_common.utils import apply_ses_routing_if_enabled log = logging.getLogger("edx.student") @@ -230,6 +231,7 @@ def compose_activation_email( user_context=message_context, ) + msg = apply_ses_routing_if_enabled(msg) return msg diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py index 7cf38c821976..097b23b05892 100644 --- a/openedx/core/djangoapps/ace_common/utils.py +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -2,9 +2,51 @@ Utility functions for edx-ace. """ import logging +from django.conf import settings +from edx_toggles.toggles import WaffleFlag +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + log = logging.getLogger(__name__) +# .. toggle_name: user_authn.enable_ses_for_account_activation +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Route account activation emails via SES using ACE. +# .. toggle_use_cases: opt_in, temporary +# .. toggle_creation_date: 2026-03-31 +# .. toggle_target_removal_date: None +# .. toggle_warning: Controls SES routing for account activation emails. + +ENABLE_SES_FOR_ACCOUNT_ACTIVATION = WaffleFlag( + 'user_authn.enable_ses_for_account_activation', + __name__, +) + + +def apply_ses_routing_if_enabled(msg): + """ + Apply SES routing to ACE message if flag is enabled. + """ + if not ENABLE_SES_FOR_ACCOUNT_ACTIVATION.is_enabled(): + return msg + + if msg.options is None: + msg.options = {} + + msg.options.update({ + 'transactional': True, + 'override_default_channel': 'django_email', + 'from_address': configuration_helpers.get_value( + 'ACTIVATION_EMAIL_FROM_ADDRESS' + ) or configuration_helpers.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ), + }) + + return msg + def setup_firebase_app(firebase_credentials, app_name='fcm-app'): """ diff --git a/openedx/core/djangoapps/user_authn/tasks.py b/openedx/core/djangoapps/user_authn/tasks.py index c1c781d2f26f..3f2f25b9b294 100644 --- a/openedx/core/djangoapps/user_authn/tasks.py +++ b/openedx/core/djangoapps/user_authn/tasks.py @@ -18,6 +18,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_authn.utils import check_pwned_password from openedx.core.lib.celery.task_utils import emulate_http_request +from openedx.core.djangoapps.ace_common.utils import ENABLE_SES_FOR_ACCOUNT_ACTIVATION log = logging.getLogger('edx.celery.task') @@ -60,6 +61,9 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None): max_retries = settings.RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS retries = self.request.retries + if msg.options is None: + msg.options = {} + if from_address is None: from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS') or ( configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) @@ -71,28 +75,98 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None): site = Site.objects.get(id=site_id) if site_id else Site.objects.get_current() user = User.objects.get(id=msg.recipient.lms_user_id) + route_via_ses = ENABLE_SES_FOR_ACCOUNT_ACTIVATION.is_enabled() + sent_via_ses = False + + if route_via_ses: + msg.options.update({ + 'override_default_channel': 'django_email', + 'transactional': True, + 'from_address': configuration_helpers.get_value( + 'ACTIVATION_EMAIL_FROM_ADDRESS' + ) or configuration_helpers.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ), + }) + try: with emulate_http_request(site=site, user=user): ace.send(msg) + sent_via_ses = route_via_ses + except RecoverableChannelDeliveryError: - log.info('Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( - dest_addr=dest_addr, - attempt=retries, - max_attempts=max_retries - )) + log.warning( + "SES send failed for %s, falling back to default ACE channel", + dest_addr, + exc_info=True, + ) + + if not route_via_ses: + log.info( + 'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( + dest_addr=dest_addr, + attempt=retries, + max_attempts=max_retries + ) + ) + try: + self.retry( + countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT, + max_retries=max_retries + ) + except MaxRetriesExceededError: + log.error( + 'Unable to send activation email to user from "%s" to "%s"', + from_address, + dest_addr, + exc_info=True + ) + return + + _remove_ses_overrides(msg) + try: - self.retry(countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT, max_retries=max_retries) - except MaxRetriesExceededError: - log.error( - 'Unable to send activation email to user from "%s" to "%s"', - from_address, - dest_addr, - exc_info=True + with emulate_http_request(site=site, user=user): + ace.send(msg) + sent_via_ses = False + + except RecoverableChannelDeliveryError: + log.info( + 'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( + dest_addr=dest_addr, + attempt=retries, + max_attempts=max_retries + ) ) + try: + self.retry( + countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT, + max_retries=max_retries + ) + except MaxRetriesExceededError: + log.error( + 'Unable to send activation email to user from "%s" to "%s"', + from_address, + dest_addr, + exc_info=True + ) except Exception: log.exception( 'Unable to send activation email to user from "%s" to "%s"', from_address, dest_addr, ) - raise Exception # lint-amnesty, pylint: disable=raise-missing-from + raise + + log.info( + 'Activation email for %s sent via %s', + dest_addr, + 'SES' if sent_via_ses else 'default ACE channel', + ) + + +def _remove_ses_overrides(msg): + msg.options.pop('override_default_channel', None) + msg.options.pop('transactional', None) + msg.options.pop('from_address', None) From 9bec054333665844c4b2a8119121229c1dd4fe43 Mon Sep 17 00:00:00 2001 From: Nallagatla Thriveni Date: Wed, 15 Apr 2026 03:23:22 +0000 Subject: [PATCH 2/2] refactor: simplify SES routing and fallback logic per review feedback --- common/djangoapps/student/views/management.py | 2 - openedx/core/djangoapps/ace_common/utils.py | 45 ------------ openedx/core/djangoapps/user_authn/tasks.py | 71 +++++++------------ 3 files changed, 25 insertions(+), 93 deletions(-) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 6f206a640012..4cf8fad8aea5 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -89,7 +89,6 @@ from common.djangoapps.util.json_request import JsonResponse from common.djangoapps.student.signals import USER_EMAIL_CHANGED from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from openedx.core.djangoapps.ace_common.utils import apply_ses_routing_if_enabled log = logging.getLogger("edx.student") @@ -231,7 +230,6 @@ def compose_activation_email( user_context=message_context, ) - msg = apply_ses_routing_if_enabled(msg) return msg diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py index 097b23b05892..e84bc2230f7d 100644 --- a/openedx/core/djangoapps/ace_common/utils.py +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -1,51 +1,6 @@ """ Utility functions for edx-ace. """ -import logging -from django.conf import settings -from edx_toggles.toggles import WaffleFlag -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - - -log = logging.getLogger(__name__) - -# .. toggle_name: user_authn.enable_ses_for_account_activation -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Route account activation emails via SES using ACE. -# .. toggle_use_cases: opt_in, temporary -# .. toggle_creation_date: 2026-03-31 -# .. toggle_target_removal_date: None -# .. toggle_warning: Controls SES routing for account activation emails. - -ENABLE_SES_FOR_ACCOUNT_ACTIVATION = WaffleFlag( - 'user_authn.enable_ses_for_account_activation', - __name__, -) - - -def apply_ses_routing_if_enabled(msg): - """ - Apply SES routing to ACE message if flag is enabled. - """ - if not ENABLE_SES_FOR_ACCOUNT_ACTIVATION.is_enabled(): - return msg - - if msg.options is None: - msg.options = {} - - msg.options.update({ - 'transactional': True, - 'override_default_channel': 'django_email', - 'from_address': configuration_helpers.get_value( - 'ACTIVATION_EMAIL_FROM_ADDRESS' - ) or configuration_helpers.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL - ), - }) - - return msg def setup_firebase_app(firebase_credentials, app_name='fcm-app'): diff --git a/openedx/core/djangoapps/user_authn/tasks.py b/openedx/core/djangoapps/user_authn/tasks.py index 3f2f25b9b294..3c425a1de6f4 100644 --- a/openedx/core/djangoapps/user_authn/tasks.py +++ b/openedx/core/djangoapps/user_authn/tasks.py @@ -18,10 +18,24 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_authn.utils import check_pwned_password from openedx.core.lib.celery.task_utils import emulate_http_request -from openedx.core.djangoapps.ace_common.utils import ENABLE_SES_FOR_ACCOUNT_ACTIVATION +from edx_toggles.toggles import WaffleFlag log = logging.getLogger('edx.celery.task') +# .. toggle_name: user_authn.enable_ses_for_account_activation +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Route account activation emails via SES using ACE. +# .. toggle_use_cases: opt_in, temporary +# .. toggle_creation_date: 2026-03-31 +# .. toggle_target_removal_date: None +# .. toggle_warning: Controls SES routing for account activation emails. + +ENABLE_SES_FOR_ACCOUNT_ACTIVATION = WaffleFlag( + 'user_authn.enable_ses_for_account_activation', + __name__, +) + @shared_task @set_code_owner_attribute @@ -79,16 +93,7 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None): sent_via_ses = False if route_via_ses: - msg.options.update({ - 'override_default_channel': 'django_email', - 'transactional': True, - 'from_address': configuration_helpers.get_value( - 'ACTIVATION_EMAIL_FROM_ADDRESS' - ) or configuration_helpers.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL - ), - }) + msg.options['override_default_channel'] = 'django_email' try: with emulate_http_request(site=site, user=user): @@ -96,42 +101,20 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None): sent_via_ses = route_via_ses except RecoverableChannelDeliveryError: - log.warning( - "SES send failed for %s, falling back to default ACE channel", - dest_addr, - exc_info=True, - ) - - if not route_via_ses: - log.info( - 'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( - dest_addr=dest_addr, - attempt=retries, - max_attempts=max_retries - ) + if route_via_ses: + log.warning( + "SES send failed for %s, falling back to default ACE channel", + dest_addr, + exc_info=True, ) - try: - self.retry( - countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT, - max_retries=max_retries - ) - except MaxRetriesExceededError: - log.error( - 'Unable to send activation email to user from "%s" to "%s"', - from_address, - dest_addr, - exc_info=True - ) - return - _remove_ses_overrides(msg) + msg.options.pop('override_default_channel', None) - try: with emulate_http_request(site=site, user=user): ace.send(msg) sent_via_ses = False - except RecoverableChannelDeliveryError: + else: log.info( 'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format( dest_addr=dest_addr, @@ -151,6 +134,8 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None): dest_addr, exc_info=True ) + return + except Exception: log.exception( 'Unable to send activation email to user from "%s" to "%s"', @@ -164,9 +149,3 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None): dest_addr, 'SES' if sent_via_ses else 'default ACE channel', ) - - -def _remove_ses_overrides(msg): - msg.options.pop('override_default_channel', None) - msg.options.pop('transactional', None) - msg.options.pop('from_address', None)