Skip to content

Commit d52602d

Browse files
feat: add SES routing for account activation emails with fallback support
1 parent c36b0f8 commit d52602d

File tree

3 files changed

+131
-13
lines changed

3 files changed

+131
-13
lines changed

common/djangoapps/student/views/management.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
from common.djangoapps.util.json_request import JsonResponse
9090
from common.djangoapps.student.signals import USER_EMAIL_CHANGED
9191
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
92+
from openedx.core.djangoapps.ace_common.utils import apply_ses_routing_if_enabled
9293

9394
log = logging.getLogger("edx.student")
9495

@@ -230,6 +231,7 @@ def compose_activation_email(
230231
user_context=message_context,
231232
)
232233

234+
msg = apply_ses_routing_if_enabled(msg)
233235
return msg
234236

235237

openedx/core/djangoapps/ace_common/utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,51 @@
22
Utility functions for edx-ace.
33
"""
44
import logging
5+
from django.conf import settings
6+
from edx_toggles.toggles import WaffleFlag
7+
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
8+
59

610
log = logging.getLogger(__name__)
711

12+
# .. toggle_name: user_authn.enable_ses_for_account_activation
13+
# .. toggle_implementation: WaffleFlag
14+
# .. toggle_default: False
15+
# .. toggle_description: Route account activation emails via SES using ACE.
16+
# .. toggle_use_cases: opt_in, temporary
17+
# .. toggle_creation_date: 2026-03-31
18+
# .. toggle_target_removal_date: None
19+
# .. toggle_warning: Controls SES routing for account activation emails.
20+
21+
ENABLE_SES_FOR_ACCOUNT_ACTIVATION = WaffleFlag(
22+
'user_authn.enable_ses_for_account_activation',
23+
__name__,
24+
)
25+
26+
27+
def apply_ses_routing_if_enabled(msg):
28+
"""
29+
Apply SES routing to ACE message if flag is enabled.
30+
"""
31+
if not ENABLE_SES_FOR_ACCOUNT_ACTIVATION.is_enabled():
32+
return msg
33+
34+
if msg.options is None:
35+
msg.options = {}
36+
37+
msg.options.update({
38+
'transactional': True,
39+
'override_default_channel': 'django_email',
40+
'from_address': configuration_helpers.get_value(
41+
'ACTIVATION_EMAIL_FROM_ADDRESS'
42+
) or configuration_helpers.get_value(
43+
'email_from_address',
44+
settings.DEFAULT_FROM_EMAIL
45+
),
46+
})
47+
48+
return msg
49+
850

951
def setup_firebase_app(firebase_credentials, app_name='fcm-app'):
1052
"""

openedx/core/djangoapps/user_authn/tasks.py

Lines changed: 87 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
1919
from openedx.core.djangoapps.user_authn.utils import check_pwned_password
2020
from openedx.core.lib.celery.task_utils import emulate_http_request
21+
from openedx.core.djangoapps.ace_common.utils import ENABLE_SES_FOR_ACCOUNT_ACTIVATION
2122

2223
log = logging.getLogger('edx.celery.task')
2324

@@ -60,6 +61,9 @@ def send_activation_email(self, msg_string, from_address=None, site_id=None):
6061
max_retries = settings.RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS
6162
retries = self.request.retries
6263

64+
if msg.options is None:
65+
msg.options = {}
66+
6367
if from_address is None:
6468
from_address = configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS') or (
6569
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):
7175
site = Site.objects.get(id=site_id) if site_id else Site.objects.get_current()
7276
user = User.objects.get(id=msg.recipient.lms_user_id)
7377

78+
route_via_ses = ENABLE_SES_FOR_ACCOUNT_ACTIVATION.is_enabled()
79+
sent_via_ses = False
80+
81+
if route_via_ses:
82+
msg.options.update({
83+
'override_default_channel': 'django_email',
84+
'transactional': True,
85+
'from_address': configuration_helpers.get_value(
86+
'ACTIVATION_EMAIL_FROM_ADDRESS'
87+
) or configuration_helpers.get_value(
88+
'email_from_address',
89+
settings.DEFAULT_FROM_EMAIL
90+
),
91+
})
92+
7493
try:
7594
with emulate_http_request(site=site, user=user):
7695
ace.send(msg)
96+
sent_via_ses = route_via_ses
97+
7798
except RecoverableChannelDeliveryError:
78-
log.info('Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format(
79-
dest_addr=dest_addr,
80-
attempt=retries,
81-
max_attempts=max_retries
82-
))
99+
log.warning(
100+
"SES send failed for %s, falling back to default ACE channel",
101+
dest_addr,
102+
exc_info=True,
103+
)
104+
105+
if not route_via_ses:
106+
log.info(
107+
'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format(
108+
dest_addr=dest_addr,
109+
attempt=retries,
110+
max_attempts=max_retries
111+
)
112+
)
113+
try:
114+
self.retry(
115+
countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT,
116+
max_retries=max_retries
117+
)
118+
except MaxRetriesExceededError:
119+
log.error(
120+
'Unable to send activation email to user from "%s" to "%s"',
121+
from_address,
122+
dest_addr,
123+
exc_info=True
124+
)
125+
return
126+
127+
_remove_ses_overrides(msg)
128+
83129
try:
84-
self.retry(countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT, max_retries=max_retries)
85-
except MaxRetriesExceededError:
86-
log.error(
87-
'Unable to send activation email to user from "%s" to "%s"',
88-
from_address,
89-
dest_addr,
90-
exc_info=True
130+
with emulate_http_request(site=site, user=user):
131+
ace.send(msg)
132+
sent_via_ses = False
133+
134+
except RecoverableChannelDeliveryError:
135+
log.info(
136+
'Retrying sending email to user {dest_addr}, attempt # {attempt} of {max_attempts}'.format(
137+
dest_addr=dest_addr,
138+
attempt=retries,
139+
max_attempts=max_retries
140+
)
91141
)
142+
try:
143+
self.retry(
144+
countdown=settings.RETRY_ACTIVATION_EMAIL_TIMEOUT,
145+
max_retries=max_retries
146+
)
147+
except MaxRetriesExceededError:
148+
log.error(
149+
'Unable to send activation email to user from "%s" to "%s"',
150+
from_address,
151+
dest_addr,
152+
exc_info=True
153+
)
92154
except Exception:
93155
log.exception(
94156
'Unable to send activation email to user from "%s" to "%s"',
95157
from_address,
96158
dest_addr,
97159
)
98-
raise Exception # lint-amnesty, pylint: disable=raise-missing-from
160+
raise
161+
162+
log.info(
163+
'Activation email for %s sent via %s',
164+
dest_addr,
165+
'SES' if sent_via_ses else 'default ACE channel',
166+
)
167+
168+
169+
def _remove_ses_overrides(msg):
170+
msg.options.pop('override_default_channel', None)
171+
msg.options.pop('transactional', None)
172+
msg.options.pop('from_address', None)

0 commit comments

Comments
 (0)