-
Notifications
You must be signed in to change notification settings - Fork 11
feat: add SES routing for account activation emails with fallback support #207
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that this probably doesn't belong in the |
||
| return msg | ||
|
|
||
| if msg.options is None: | ||
| msg.options = {} | ||
|
|
||
| msg.options.update({ | ||
| 'transactional': True, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AccountActivation in the management.py also sets the |
||
| 'override_default_channel': 'django_email', | ||
| 'from_address': configuration_helpers.get_value( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking in the management.py file, it looks like this action is already handled here |
||
| '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'): | ||
| """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = {} | ||
|
|
||
|
jsnwesson marked this conversation as resolved.
|
||
| 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: | ||
|
jsnwesson marked this conversation as resolved.
|
||
| 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 | ||
| ), | ||
| }) | ||
|
|
||
|
jsnwesson marked this conversation as resolved.
|
||
| 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, | ||
| ) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a weird one, because we'd have to remove this log eventually if we either transition fully to AWS SES, right? I think we can maybe wrap this in an |
||
|
|
||
| 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) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fallback happens in the Specifically:
That second |
||
|
|
||
| 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Last comment: I feel like the entire block of the first |
||
| msg.options.pop('override_default_channel', None) | ||
| msg.options.pop('transactional', None) | ||
| msg.options.pop('from_address', None) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. assuming that we don't need to add the |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can probably remove this line and that means the only changes made are in
tasks.pyThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point — I removed the unused logging from utils.py. The file now only contains the existing firebase helper, so all SES-related changes are limited to tasks.py.