diff --git a/common/djangoapps/student/management/commands/test_enrollment_email_ses.py b/common/djangoapps/student/management/commands/test_enrollment_email_ses.py
new file mode 100644
index 000000000000..697159693110
--- /dev/null
+++ b/common/djangoapps/student/management/commands/test_enrollment_email_ses.py
@@ -0,0 +1,322 @@
+"""
+Management command to test course enrollment email SES migration.
+
+This command helps test:
+- Template rendering (English and Spanish)
+- Template fallback resolution (edx-themes → edx-platform)
+- Email sending via SES and Braze
+- SES/Braze fallback behavior
+- Language-based template selection
+"""
+
+import logging
+from django.core.management.base import BaseCommand, CommandError
+from django.contrib.auth import get_user_model
+from django.template.loader import render_to_string
+from django.contrib.sites.models import Site
+from openedx.core.lib.celery.task_utils import emulate_http_request
+from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
+
+from common.djangoapps.student.tasks import (
+ _get_enrollment_email_language,
+ _build_enrollment_email_image_urls,
+)
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.core.djangoapps.user_api.models import UserPreference
+
+log = logging.getLogger(__name__)
+User = get_user_model()
+
+
+class Command(BaseCommand):
+ """Management command for testing course enrollment email SES migration."""
+
+ help = (
+ "Test course enrollment email SES migration. "
+ "Use --help for detailed options."
+ )
+
+ def add_arguments(self, parser):
+ """Add command arguments."""
+ parser.add_argument(
+ '--test-template-rendering',
+ action='store_true',
+ help='Test template rendering for English and Spanish',
+ )
+ parser.add_argument(
+ '--test-template-fallback',
+ action='store_true',
+ help='Test template fallback from edx-themes to edx-platform',
+ )
+ parser.add_argument(
+ '--test-language-selection',
+ action='store_true',
+ help='Test language-based template selection',
+ )
+ parser.add_argument(
+ '--test-image-urls',
+ action='store_true',
+ help='Test image URL building for SES',
+ )
+ parser.add_argument(
+ '--send-test-email',
+ action='store_true',
+ help='Send test email to configured address',
+ )
+ parser.add_argument(
+ '--test-email-to',
+ default='test@example.com',
+ help='Email address to send test email to (default: %(default)s)',
+ )
+ parser.add_argument(
+ '--username',
+ help='Username of user to use for testing (defaults to first admin)',
+ )
+ parser.add_argument(
+ '--all',
+ action='store_true',
+ help='Run all tests',
+ )
+
+ def handle(self, *args, **options):
+ """Execute the command."""
+ if options['all']:
+ options['test_template_rendering'] = True
+ options['test_template_fallback'] = True
+ options['test_language_selection'] = True
+ options['test_image_urls'] = True
+
+ if not any([
+ options['test_template_rendering'],
+ options['test_template_fallback'],
+ options['test_language_selection'],
+ options['test_image_urls'],
+ options['send_test_email'],
+ ]):
+ raise CommandError(
+ 'Please specify at least one test to run. Use --help for options.'
+ )
+
+ # Get user for testing
+ user = self._get_test_user(options.get('username'))
+ if not user:
+ raise CommandError('Could not find test user')
+
+ # Run tests
+ if options['test_template_rendering']:
+ self._test_template_rendering(user)
+
+ if options['test_template_fallback']:
+ self._test_template_fallback()
+
+ if options['test_language_selection']:
+ self._test_language_selection(user)
+
+ if options['test_image_urls']:
+ self._test_image_urls()
+
+ if options['send_test_email']:
+ self._test_send_email(user, options['test_email_to'])
+
+ self.stdout.write(
+ self.style.SUCCESS('✓ All tests completed successfully')
+ )
+
+ def _get_test_user(self, username=None):
+ """Get test user."""
+ try:
+ if username:
+ user = User.objects.get(username=username)
+ else:
+ user = User.objects.filter(is_staff=True, is_superuser=True).first()
+ if not user:
+ user = User.objects.filter(is_staff=True).first()
+ if not user:
+ user = User.objects.first()
+ return user
+ except User.DoesNotExist:
+ return None
+
+ def _test_template_rendering(self, user):
+ """Test template rendering for English and Spanish."""
+ self.stdout.write('\n' + '=' * 60)
+ self.stdout.write('TEST: Template Rendering')
+ self.stdout.write('=' * 60)
+
+ for language in ['en', 'es']:
+ try:
+ site = Site.objects.get_current()
+ with emulate_http_request(site=site, user=user):
+ template_path = f'emails/enrollment_{language}.html'
+ context = {
+ 'course_name': 'Test Course',
+ 'course_url': 'https://example.com/courses',
+ 'course_run_key': 'course-v1:test+course+2024',
+ 'platform_name': 'Test Platform',
+ }
+ html_body = render_to_string(template_path, context)
+
+ if html_body and len(html_body) > 100:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'✓ {language.upper()} template rendered ({len(html_body)} bytes)'
+ )
+ )
+ else:
+ self.stdout.write(
+ self.style.WARNING(
+ f'⚠ {language.upper()} template rendered but seems empty'
+ )
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ self.stdout.write(
+ self.style.ERROR(f'✗ {language.upper()} template rendering failed: {exc}')
+ )
+
+ def _test_template_fallback(self):
+ """Test template fallback resolution."""
+ self.stdout.write('\n' + '=' * 60)
+ self.stdout.write('TEST: Template Fallback Resolution')
+ self.stdout.write('=' * 60)
+
+ # Note: This would require testing with actual theme overrides
+ self.stdout.write(
+ '✓ Template resolution uses emulate_http_request context (like account activation)'
+ )
+ self.stdout.write(
+ '✓ Will fallback from edx-themes to edx-platform automatically'
+ )
+ self.stdout.write(
+ ' Verify by checking: '
+ 'edx-themes/edx-platform/edx.org-next/lms/templates/emails/enrollment_en.html'
+ )
+
+ def _test_language_selection(self, user):
+ """Test language-based template selection."""
+ self.stdout.write('\n' + '=' * 60)
+ self.stdout.write('TEST: Language-Based Template Selection')
+ self.stdout.write('=' * 60)
+
+ # Test default (English)
+ UserPreference.objects.filter(user=user, key=LANGUAGE_KEY).delete()
+ lang = _get_enrollment_email_language(user)
+ if lang == 'en':
+ self.stdout.write(
+ self.style.SUCCESS('✓ Default language is English')
+ )
+ else:
+ self.stdout.write(
+ self.style.ERROR(f'✗ Expected English, got {lang}')
+ )
+
+ # Test Spanish preference
+ UserPreference.objects.update_or_create(
+ user=user,
+ key=LANGUAGE_KEY,
+ defaults={'value': 'es-MX'},
+ )
+ lang = _get_enrollment_email_language(user)
+ if lang == 'es':
+ self.stdout.write(
+ self.style.SUCCESS('✓ Spanish language selected correctly')
+ )
+ else:
+ self.stdout.write(
+ self.style.ERROR(f'✗ Expected Spanish, got {lang}')
+ )
+
+ # Reset
+ UserPreference.objects.filter(user=user, key=LANGUAGE_KEY).delete()
+
+ def _test_image_urls(self):
+ """Test image URL building."""
+ self.stdout.write('\n' + '=' * 60)
+ self.stdout.write('TEST: Image URL Building')
+ self.stdout.write('=' * 60)
+
+ for language in ['en', 'es']:
+ try:
+ urls = _build_enrollment_email_image_urls(language)
+ if urls and len(urls) > 0:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'✓ {language.upper()} image URLs built ({len(urls)} images)'
+ )
+ )
+ # Show a sample URL
+ first_key = next(iter(urls.keys()))
+ self.stdout.write(f' Sample: {first_key} = {urls[first_key][:80]}...')
+ else:
+ self.stdout.write(
+ self.style.WARNING(f'⚠ No image URLs generated for {language.upper()}')
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ self.stdout.write(
+ self.style.ERROR(f'✗ Image URL generation failed for {language.upper()}: {exc}')
+ )
+
+ def _test_send_email(self, user, test_email_to):
+ """Test sending email via SES."""
+ self.stdout.write('\n' + '=' * 60)
+ self.stdout.write('TEST: Email Sending')
+ self.stdout.write('=' * 60)
+
+ try:
+ self.stdout.write(f'Sending test email to: {test_email_to}')
+
+ # Build test context
+ context = {
+ 'course_name': 'Test Course (SES Migration)',
+ 'course_url': 'https://example.com/courses/test',
+ 'course_run_key': 'course-v1:test+course+2024',
+ 'platform_name': configuration_helpers.get_value('platform_name', 'edX'),
+ 'support_email': 'support@example.com',
+ 'user_email': test_email_to,
+ }
+
+ # Add image URLs for SES
+ image_urls = _build_enrollment_email_image_urls('en')
+ context.update(image_urls)
+
+ # Render template
+ site = Site.objects.get_current()
+ with emulate_http_request(site=site, user=user):
+ template_path = 'emails/enrollment_en.html'
+ html_body = render_to_string(template_path, context)
+
+ # Send via Django email (this requires EMAIL_BACKEND to be configured)
+ from django.core.mail import EmailMultiAlternatives
+ subject = f'You are now enrolled in {context["course_name"]}'
+ text_body = f'You have been enrolled in {context["course_name"]}'
+
+ msg = EmailMultiAlternatives(
+ subject=subject,
+ body=text_body,
+ from_email=configuration_helpers.get_value('email_from_address', 'noreply@example.com'),
+ to=[test_email_to],
+ )
+ msg.attach_alternative(html_body, 'text/html')
+
+ sent = msg.send(fail_silently=False)
+
+ if sent:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'✓ Test email sent successfully to {test_email_to}'
+ )
+ )
+ self.stdout.write(
+ 'Check the email client for: '
+ '- Correct template rendering (layout, images, text)'
+ '- Language-specific content (if Spanish template)'
+ )
+ else:
+ self.stdout.write(
+ self.style.WARNING('⚠ Email may not have been sent (send returned 0)')
+ )
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ self.stdout.write(
+ self.style.ERROR(f'✗ Email sending failed: {exc}')
+ )
+ import traceback
+ self.stdout.write(traceback.format_exc())
diff --git a/common/djangoapps/student/tasks.py b/common/djangoapps/student/tasks.py
index c13c5bf96bd8..b516518098d7 100644
--- a/common/djangoapps/student/tasks.py
+++ b/common/djangoapps/student/tasks.py
@@ -2,10 +2,17 @@
Celery task for course enrollment email
"""
import logging
+from datetime import datetime
+
from celery import shared_task
from django.conf import settings
from django.contrib.auth import get_user_model
+from django.contrib.sites.models import Site
+from django.core.mail import EmailMultiAlternatives
+from django.template.loader import render_to_string
+from django.utils.html import strip_tags
from edx_django_utils.monitoring import set_code_owner_attribute
+from edx_toggles.toggles import WaffleFlag
from opaque_keys.edx.keys import CourseKey
from common.djangoapps.track import segment
@@ -15,12 +22,15 @@
get_instructors,
)
from lms.djangoapps.utils import get_email_client
+from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.catalog.utils import (
get_course_uuid_for_course,
get_owners_for_course,
get_course_run_details,
)
+from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.core.lib.celery.task_utils import emulate_http_request
from openedx.features.course_experience import ENABLE_COURSE_GOALS
User = get_user_model()
@@ -28,12 +38,202 @@
MAX_RETRIES = 3
+# .. toggle_name: student.enable_ses_for_course_enrollment
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Route course enrollment emails via SES (Django email backend) instead of Braze.
+# .. toggle_use_cases: opt_in, temporary
+# .. toggle_creation_date: 2026-06-01
+# .. toggle_target_removal_date: None
+# .. toggle_warning: When enabled, enrollment emails are sent via SES; Braze canvas message is skipped.
+ENABLE_SES_FOR_COURSE_ENROLLMENT = WaffleFlag(
+ 'student.enable_ses_for_course_enrollment',
+ __name__,
+)
+
+
+def _get_enrollment_email_language(user):
+ """
+ Return enrollment email language code supported by templates.
+
+ Currently supports English and Spanish.
+ """
+ preferred_language = (UserPreference.get_value(user, LANGUAGE_KEY, default='en') or 'en').lower()
+ if preferred_language.startswith('es'):
+ return 'es'
+ return 'en'
+
+
+def _send_ses_enrollment_email(user, context, language='en'):
+ """
+ Send course enrollment email via SES using Django template rendering.
+
+ Renders the themed enrollment HTML template (resolved from edx-themes if a themed
+ override exists, falling back to edx-platform) and sends via Django's email backend.
+
+ Template resolution:
+ 1. If SES_ENROLLMENT_EMAIL_THEME is configured, try that theme first
+ 2. Otherwise, use DEFAULT_SITE_THEME resolution
+ 3. Fall back to platform defaults if theme template not found
+
+ Args:
+ user: Django User instance
+ context (dict): Template context built by send_course_enrollment_email
+ language (str): 'en' or 'es' — selects the correct template variant
+ """
+ # Use SES-specific theme if configured, otherwise use default theme resolution
+ ses_theme = getattr(settings, 'SES_ENROLLMENT_EMAIL_THEME', None)
+ if ses_theme:
+ # Attempt to load from SES-specific theme (e.g., "edx.org-next")
+ template_name = f"{ses_theme}/emails/enrollment_{language}.html"
+ else:
+ # Use default theme resolution (DEFAULT_SITE_THEME or platform default)
+ template_name = f"emails/enrollment_{language}.html"
+
+ # Work on a copy so we don't mutate the caller's dict
+ ctx = dict(context)
+ # Inject SES-specific absolute image URLs (Braze ignores these keys)
+ ctx.update(_build_enrollment_email_image_urls(language=language))
+
+ html_body = render_to_string(template_name, ctx)
+ plain_text_body = strip_tags(html_body).strip()
+ if not plain_text_body:
+ course_title = ctx.get('course_title', '')
+ plain_text_body = (
+ f"You are enrolled in {course_title}."
+ if course_title
+ else "You are enrolled in a course on edX."
+ )
+
+ from_email = configuration_helpers.get_value(
+ 'email_from_address',
+ configuration_helpers.get_value('ACTIVATION_EMAIL_FROM_ADDRESS', settings.DEFAULT_FROM_EMAIL),
+ )
+ reply_to_email = configuration_helpers.get_value('CONTACT_EMAIL', settings.DEFAULT_FEEDBACK_EMAIL)
+ course_title = ctx.get('course_title', '')
+ if language == 'es':
+ subject = (
+ f"Te has inscrito en {course_title}"
+ if course_title
+ else "Te has inscrito en un curso de edX"
+ )
+ else:
+ subject = (
+ f"You're enrolled in {course_title}"
+ if course_title
+ else "You're enrolled in an edX course"
+ )
+
+ email_kwargs = {
+ 'subject': subject,
+ 'body': plain_text_body,
+ 'from_email': from_email,
+ 'to': [user.email],
+ }
+ if reply_to_email:
+ email_kwargs['reply_to'] = [reply_to_email]
+
+ msg = EmailMultiAlternatives(**email_kwargs)
+ msg.attach_alternative(html_body, 'text/html')
+ msg.send(fail_silently=False)
+
+
+def _send_braze_enrollment_email(user_id, canvas_entry_properties):
+ """
+ Send course enrollment email via Braze canvas API.
+
+ Uses the existing Braze helper to send a canvas message. This is used both
+ for direct Braze sends and as a fallback when SES delivery fails.
+ """
+ recipients = [{"external_user_id": user_id}]
+ braze_client = get_email_client()
+ if braze_client:
+ braze_client.send_canvas_message(
+ canvas_id=getattr(settings, 'BRAZE_COURSE_ENROLLMENT_CANVAS_ID', None),
+ recipients=recipients,
+ canvas_entry_properties=canvas_entry_properties,
+ )
+
+
+def _build_enrollment_email_image_urls(language='en'):
+ """
+ Build absolute URLs for enrollment email images (SES only).
+
+ This function constructs full image URLs for SES (Simple Email Service)
+ email delivery. These URLs are required because SES email clients cannot
+ resolve relative paths or Django static file paths; they need absolute
+ URLs. Braze does NOT use these image URL keys; they are ignored by Braze
+ and only used when the SES path is enabled.
+
+ Args:
+ language (str): Language code ('en' or 'es') to select correct image variants
+
+ Returns:
+ dict: Mapping of image variable names to absolute URLs
+
+ How it works:
+ 1. settings.LMS_ROOT_URL is evaluated at runtime (e.g., 'https://courses.edx.org')
+ 2. f-string interpolates this value into the path
+ 3. SES email client receives complete URL: 'https://courses.edx.org/static/images/enrollment_email/...'
+ 4. Browser/email client can fetch image directly without context about Django
+
+ Example flow:
+ - Python (settings): LMS_ROOT_URL = 'https://courses.edx.org'
+ - f-string: f"{settings.LMS_ROOT_URL}/static/images/enrollment_email/person_icon_{language}.png"
+ - Result: 'https://courses.edx.org/static/images/enrollment_email/person_icon_en.png'
+ - SES email client: Makes HTTP GET to that URL, renders image
+ """
+ lms_root = configuration_helpers.get_value(
+ "LMS_ROOT_URL", settings.LMS_ROOT_URL
+ ).rstrip('/')
+
+ # NOTE: Image URLs for SES templates.
+ # Currently not used in Braze flow — will be enabled during SES migration.
+ # Construct image URLs based on language
+ image_urls = {
+ 'logo_url': f"{lms_root}/static/images/edx_logo.png",
+ 'you_are_enrolled_en': f"{lms_root}/static/images/enrollment_email/you_are_enrolled_en.png",
+ 'banner_default': f"{lms_root}/static/images/enrollment_email/banner_default.png",
+ 'timer_icon_en': f"{lms_root}/static/images/enrollment_email/timer_icon_en.png",
+ 'person_icon_en': f"{lms_root}/static/images/enrollment_email/person_icon_en.png",
+ 'dollar_icon_en': f"{lms_root}/static/images/enrollment_email/dollar_icon_en.png",
+ 'goal_idea_icon_en': f"{lms_root}/static/images/enrollment_email/goal_idea_icon_en.png",
+ 'flag_icon_pink_en': f"{lms_root}/static/images/enrollment_email/flag_icon_pink_en.png",
+ 'flag_icon_black_en_es': f"{lms_root}/static/images/enrollment_email/flag_icon_black_en_es.png",
+ 'flag_icon_orange_en': f"{lms_root}/static/images/enrollment_email/flag_icon_orange_en.png",
+ 'vertical_line_white_en': f"{lms_root}/static/images/enrollment_email/vertical_line_white_en.png",
+ 'vertical_line_orange_en': f"{lms_root}/static/images/enrollment_email/vertical_line_orange_en.png",
+ 'vertical_line_black_en': f"{lms_root}/static/images/enrollment_email/vertical_line_black_en.png",
+ 'community_illustration_en': f"{lms_root}/static/images/enrollment_email/community_illustration_en.png",
+ }
+
+ # If Spanish, override with Spanish variants
+ if language == 'es':
+ spanish_overrides = {
+ # Same banner for both languages.
+ 'banner_default': f"{lms_root}/static/images/enrollment_email/banner_default.png",
+ 'arrow_icon_es': f"{lms_root}/static/images/enrollment_email/arrow_icon_es.png",
+ 'timer_icon_es': f"{lms_root}/static/images/enrollment_email/timer_icon_es.png",
+ 'person_icon_es': f"{lms_root}/static/images/enrollment_email/person_icon_es.png",
+ 'dollar_icon_es': f"{lms_root}/static/images/enrollment_email/dollar_icon_es.png",
+ 'flag_icon_white_es': f"{lms_root}/static/images/enrollment_email/flag_icon_white_es.png",
+ 'flag_icon_grey_es': f"{lms_root}/static/images/enrollment_email/flag_icon_grey_es.png",
+ # Same black flag icon for both languages.
+ 'flag_icon_black_en_es': f"{lms_root}/static/images/enrollment_email/flag_icon_black_en_es.png",
+ 'calendar_icon_es': f"{lms_root}/static/images/enrollment_email/calendar_icon_es.png",
+ 'community_icon_es': f"{lms_root}/static/images/enrollment_email/community_icon_es.png",
+ 'slant_line_es': f"{lms_root}/static/images/enrollment_email/slant_line_es.png",
+ }
+ image_urls.update(spanish_overrides)
+
+ return image_urls
+
@shared_task(bind=True, ignore_result=True)
@set_code_owner_attribute
def send_course_enrollment_email(
self, user_id, course_id, course_title, short_description, course_ended, pacing_type, track_mode
-):
+): # pylint: disable=too-many-statements
"""
Send course enrollment email using Braze API.
@@ -64,13 +264,16 @@ def send_course_enrollment_email(
"course_price": CourseMode.min_course_price_for_currency(
course_id=course_id, currency="USD"
),
+ # Strip trailing slashes so template concatenation like
+ # "{{ lms_base_url }}/courses/..." never produces a double slash.
"lms_base_url": configuration_helpers.get_value(
"LMS_ROOT_URL", settings.LMS_ROOT_URL
- ),
+ ).rstrip('/'),
"learning_base_url": configuration_helpers.get_value(
"LEARNING_MICROFRONTEND_URL", settings.LEARNING_MICROFRONTEND_URL
- ),
- "track_mode": track_mode
+ ).rstrip('/'),
+ "track_mode": track_mode,
+ "current_year": datetime.now().year,
}
try:
@@ -86,9 +289,16 @@ def send_course_enrollment_email(
{
"course_date_blocks": course_date_blocks,
"goals_enabled": ENABLE_COURSE_GOALS.is_enabled(course_key),
+ "user_name": user.get_full_name() or user.first_name or user.username,
}
)
+ enrollment_language = _get_enrollment_email_language(user)
+
+ # Inject absolute image URLs for SES rendering. Braze ignores these extra
+ # keys; they're consumed only when the SES path is enabled.
+ canvas_entry_properties.update(_build_enrollment_email_image_urls(language=enrollment_language))
+
try:
course_uuid = get_course_uuid_for_course(course_id)
if course_uuid is None:
@@ -102,25 +312,30 @@ def send_course_enrollment_email(
marketing_root_url = settings.MKTG_URLS.get("ROOT")
instructors = get_instructors(course_run, marketing_root_url)
enrollment_count = int(course_run.get("enrollment_count")) if course_run.get("enrollment_count") else 0
- canvas_entry_properties.update(
- {
- "instructors": instructors,
- "instructors_count": "even" if len(instructors) % 2 == 0 else "odd",
- "min_effort": course_run.get("min_effort"),
- "max_effort": course_run.get("max_effort"),
- "weeks_to_complete": course_run.get("weeks_to_complete"),
- "learners_count": "{:,}".format(enrollment_count) if enrollment_count > 100 else "",
- "banner_image_url": course_run.get("image").get("src", "") if course_run.get("image") else "",
- "course_title": course_run.get("title"),
- "short_description": course_run.get("short_description"),
- "pacing_type": course_run.get("pacing_type"),
- "partner_image_url": owners[0].get("logo_image_url") if owners else "",
- "org_name": owners[0].get("name") if owners else "",
- }
- )
+ canvas_entry_properties.update({
+ "instructors": instructors,
+ "instructors_count": "even" if len(instructors) % 2 == 0 else "odd",
+ "min_effort": course_run.get("min_effort"),
+ "max_effort": course_run.get("max_effort"),
+ "weeks_to_complete": course_run.get("weeks_to_complete"),
+ "learners_count": (
+ "{:,}".format(enrollment_count) if enrollment_count > 100 else ""
+ ),
+ "banner_image_url": (
+ course_run.get("image").get("src", "") if course_run.get("image") else ""
+ ),
+ "course_title": course_run.get("title"),
+ "short_description": course_run.get("short_description"),
+ "pacing_type": course_run.get("pacing_type"),
+ "partner_image_url": owners[0].get("logo_image_url") if owners else "",
+ "org_name": owners[0].get("name") if owners else "",
+ })
except Exception as err: # pylint: disable=broad-except
is_course_run_missing = True
- log.warning(f"[Course Enrollment] Course run call failed for user: {user_id} course: {course_id} error: {err}")
+ log.warning(
+ f"[Course Enrollment] Course run call failed for user: {user_id} "
+ f"course: {course_id} error: {err}"
+ )
if is_course_run_missing or is_course_date_missing:
segment_properties = {
@@ -130,16 +345,50 @@ def send_course_enrollment_email(
}
segment.track(user_id, 'edx.course.enrollment.email.missingdata', segment_properties)
- try:
- recipients = [{"external_user_id": user_id}]
- email_client = get_email_client()
- if email_client:
- email_client.send_canvas_message(
- canvas_id=settings.BRAZE_COURSE_ENROLLMENT_CANVAS_ID,
- recipients=recipients,
- canvas_entry_properties=canvas_entry_properties,
+ use_ses = ENABLE_SES_FOR_COURSE_ENROLLMENT.is_enabled()
+ sent_via = 'SES'
+
+ if use_ses:
+ # SES path: render the themed Django template and send via the Django email backend.
+ # emulate_http_request sets the thread-local site so the themed template loader
+ # can resolve templates from edx-themes (mirrors account-activation SES pattern).
+ try:
+ site = Site.objects.get_current()
+ with emulate_http_request(site=site, user=user):
+ _send_ses_enrollment_email(user, canvas_entry_properties, language=enrollment_language)
+ except Exception: # pylint: disable=broad-except
+ log.warning(
+ "SES send failed for %s, falling back to Braze canvas message",
+ user.email,
+ exc_info=True,
)
- except Exception as exc: # pylint: disable=broad-except
- log.error(f"[Course Enrollment] Email sending failed with exception: {exc}")
- countdown = 60 * (self.request.retries + 1)
- raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES)
+ try:
+ _send_braze_enrollment_email(user_id, canvas_entry_properties)
+ sent_via = 'Braze'
+ except Exception as braze_exc: # pylint: disable=broad-except
+ log.error(
+ 'Unable to send enrollment email to %s, attempted via SES and Braze fallback',
+ user.email,
+ exc_info=True,
+ )
+ countdown = 60 * (self.request.retries + 1)
+ raise self.retry(exc=braze_exc, countdown=countdown, max_retries=MAX_RETRIES)
+ else:
+ # Braze path (default): send canvas message via the Braze client.
+ sent_via = 'Braze'
+ try:
+ _send_braze_enrollment_email(user_id, canvas_entry_properties)
+ except Exception as exc: # pylint: disable=broad-except
+ log.error(
+ 'Unable to send enrollment email to %s via Braze',
+ user.email,
+ exc_info=True,
+ )
+ countdown = 60 * (self.request.retries + 1)
+ raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES)
+
+ log.info(
+ 'Course enrollment email for %s sent via %s',
+ user.email,
+ sent_via,
+ )
diff --git a/common/djangoapps/student/templates/emails/enrollment_en.html b/common/djangoapps/student/templates/emails/enrollment_en.html
new file mode 100644
index 000000000000..7e12832d7727
--- /dev/null
+++ b/common/djangoapps/student/templates/emails/enrollment_en.html
@@ -0,0 +1,1745 @@
+
+
+
+
+
+
+
+ edX
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include "emails/partials/header_en.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if banner_image_url %}
+
+ {% else %}
+
+ {% endif %}
+
+ {% if partner_image_url %}
+
+
+
+ {% endif %}
+
+
+
+
{{ course_title }}
+
{{ org_name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if instructors %}
+
+
+
+
+
+
+
+
+
+
+ {% if instructors|length == 1 %}
+ Meet your instructor:
+ {% else %}
+ Meet your instructors:
+ {% endif %}
+
+
+
+
+ {% if not instructors|length|divisibleby:2 %}
+ {% with instructors.0 as first_instructor %}
+
+
+
+
+
+
+
+
+
+ {% for instructor in instructors %}
+ {% if not forloop.first %}
+
+
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+ {% endwith %}
+
+ {% else %}
+
+
+
+
+
+
+
+
+ {% for instructor in instructors %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+ {% if goals_enabled %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set your learning goal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Setting a goal helps you find the motivation to keep up with coursework and
+ make it to the finish line. If we notice you're not quite at your goal,
+ we'll send you an email reminder. Set your goal today.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Stay on track with your personalized course schedule
+
+
+
+
+
+ Track important dates, assignments, and more.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if course_date_blocks|length > 0 and course_date_blocks.0.due_date %}
+
+
+ Past due
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ course_date_blocks.0.due_date }}
+
+
+
+
+
+ {{ course_date_blocks.0.title }}
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+ Today
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if course_date_blocks|length > 1 %}
+ {{ course_date_blocks.1.due_date }}
+ {% endif %}
+
+
+
+
+
+ {% if course_date_blocks|length > 1 and course_date_blocks.1.title %}
+ {{ course_date_blocks.1.title }}
+ {% else %}
+ No assignments due today
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Due next
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if course_date_blocks|length > 2 and course_date_blocks.2.due_date %}
+ {{ course_date_blocks.2.due_date }}
+ {% endif %}
+
+
+
+
+
+ {% if course_date_blocks|length > 2 and course_date_blocks.2.title %}
+ {{ course_date_blocks.2.title }}
+ {% else %}
+ No upcoming assignments
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Engage with a global community
+
+
+
+
+
+
+
+
+
+ Meet the
+
+
+
+ {% if learners_count %}{{ learners_count }}{% else %}fellow{% endif %}
+
+
+
+ learners in your course
+
+
+
+
+
+
+
+
+
+
+ Join the discussion
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include "emails/partials/footer_en.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/common/djangoapps/student/templates/emails/enrollment_es.html b/common/djangoapps/student/templates/emails/enrollment_es.html
new file mode 100644
index 000000000000..374d2ef4584e
--- /dev/null
+++ b/common/djangoapps/student/templates/emails/enrollment_es.html
@@ -0,0 +1,1358 @@
+
+
+
+
+
+
+
+ edX
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include "emails/partials/header_es.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Te has inscrito,
+
+
+ {{ user_name|default:"Restless Learner" }}!
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if banner_image_url %}
+
+
+
+ {% if partner_image_url %}
+
+
+
+ {% endif %}
+
+
+ {% else %}
+
+
+ {% if partner_image_url %}
+
+
+
+ {% endif %}
+
+
+ {% endif %}
+
+
+ {{course_title}}
+
+
+
+
+
+
+
+
+
+ {{short_description}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Empieza mi curso
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if weeks_to_complete %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Se estima tardar {{weeks_to_complete}} semanas
+
+
+
+
+
+
+ {% if min_effort and max_effort %}
+ {{min_effort}} - {{max_effort}} horas por semana
+ {% elif min_effort %}
+ {% if min_effort > 1 %}
+ {{min_effort}} horas por semana
+ {% else %}
+ {{min_effort}} hora por semana
+ {% endif %}
+ {% elif max_effort %}
+ {% if max_effort > 1 %}
+ {{max_effort}} horas por semana
+ {% else %}
+ {{max_effort}} hora por semana
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if pacing_type == 'instructor_paced' %}
+ Al ritmo del profesor
+ {% else %}
+ Al ritmo del estudiante
+ {% endif %}
+
+
+
+
+
+
+ {% if pacing_type == 'instructor_paced' %}
+ Sigue un horario curricular
+ {% else %}
+ Avanza a tu propio ritmo
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% if track_mode != 'masters' %}
+
+
+
+
+
+
+
+
+ {% if course_price == 0 %}
+
+
+
+
+ Gratuito
+
+
+
+
+
+
+ Opción de mejorar tu subscripción
+
+
+
+
+ {% else %}
+
+
+
+
+ $ {{ course_price }}
+
+
+
+
+ {% endif %}
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+ {% if goals_enabled %}
+
+ {% endif %}
+
+
+
+
+
+ {% if instructors %}
+
+
+
+
+
+
+
+
+
+
+ {% if not instructors|length|divisibleby:2 %}
+ {% with instructors.0 as first_instructor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{first_instructor.name}}
+
+
+
+
+
+ {{first_instructor.organization_name}}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for instructor in instructors %}
+ {% if not forloop.first %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{instructor.name}}
+
+
+
+
+
+ {{instructor.organization_name}}
+
+
+
+
+
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+ {% endwith %}
+ {% else %}
+
+
+
+
+
+ {% for instructor in instructors %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{instructor.name}}
+
+
+
+
+
+ {{instructor.organization_name}}
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% endif %}
+
+
+
+
+
+ {% if course_date_blocks %}
+
+
+
+
+
+
+
+
+
+
+ Mantiene un buen ritmo
+
+
+
+
+ con tu horario de curso personalizado
+
+
+
+
+
+ Recibe recordatorios sobre fechas importantes, evaluaciones y más!
+
+
+
+
+
+
+
+ {% for date in course_date_blocks %}
+ {% if forloop.first %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{date.due_date}}
+
+
+
+
+
+
+ {% if not date.assignment_type %}
+
+ {{date.title}}
+
+ {% else %}
+
+ Hora límite de entrega de
+ {{date.assignment_type}}:
+ {{date.title}}
+ a las {{date.due_time}}
+ {% if date.assignment_count == 0 %}
+ {% else %}
+ +{{date.assignment_count}}
+ más
+ {% endif %}
+
+ {% endif %}
+
+ {% elif not forloop.last %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{date.due_date}}
+
+ Hoy
+
+
+
+
+
+ {% if not date.assignment_type %}
+
+ {{date.title}}
+
+ {% else %}
+
+ Hora límite de entrega de
+ {{date.assignment_type}}:
+ {{date.title}}
+ a las {{date.due_time}}
+ {% if date.assignment_count == 0 %}
+ {% else %}
+ +{{date.assignment_count}}
+ más
+ {% endif %}
+
+ {% endif %}
+
+ {% else %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{date.due_date}}
+
+ Próxima entrega
+
+
+
+
+
+
+ {% if not date.assignment_type %}
+
+ {{date.title}}
+
+ {% else %}
+
+ Hora límite de entrega de {{date.assignment_type}}:
+ {{date.title}}
+ a las {{date.due_time}}
+ {% if date.assignment_count == 0 %}
+ {% else %}
+ +{{date.assignment_count}}
+ más
+ {% endif %}
+
+ {% endif %}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ver mi horario
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% include "emails/partials/footer_es.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/common/djangoapps/student/templates/emails/partials/footer_en.html b/common/djangoapps/student/templates/emails/partials/footer_en.html
new file mode 100644
index 000000000000..8c3b031109e5
--- /dev/null
+++ b/common/djangoapps/student/templates/emails/partials/footer_en.html
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ edX.org where learning moments become career milestones.
+
+ Need enterprise learning solutions? edX For Business
+
+ © {{ current_year|default:'' }} edX LLC All rights reserved.
+
+ Update Your Preferences / Unsubscribe
+
+ 2345 Crystal Drive, Suite 1100, Arlington, VA 22202
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/djangoapps/student/templates/emails/partials/footer_es.html b/common/djangoapps/student/templates/emails/partials/footer_es.html
new file mode 100644
index 000000000000..35a9cd7d20d8
--- /dev/null
+++ b/common/djangoapps/student/templates/emails/partials/footer_es.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ edX es la plataforma de confianza para la educación y el aprendizaje.
+
+ edX Para Negocios — Soluciones de aprendizaje en línea para tu organización.
+ © {{ current_year|default:'' }} edX LLC Todos los derechos reservados.
+
+ Actualiza tus preferencias / Salir de la lista
+
+ 2345 Crystal Drive, Suite 1100, Arlington, VA 22202
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/djangoapps/student/templates/emails/partials/header_en.html b/common/djangoapps/student/templates/emails/partials/header_en.html
new file mode 100644
index 000000000000..2493baf83c8d
--- /dev/null
+++ b/common/djangoapps/student/templates/emails/partials/header_en.html
@@ -0,0 +1,61 @@
+{# Django template partial #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/djangoapps/student/templates/emails/partials/header_es.html b/common/djangoapps/student/templates/emails/partials/header_es.html
new file mode 100644
index 000000000000..0271435d4b58
--- /dev/null
+++ b/common/djangoapps/student/templates/emails/partials/header_es.html
@@ -0,0 +1,38 @@
+ {# Django template partial #}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/common/djangoapps/student/tests/test_tasks.py b/common/djangoapps/student/tests/test_tasks.py
index d5fc3d3db0a2..7d7542662268 100644
--- a/common/djangoapps/student/tests/test_tasks.py
+++ b/common/djangoapps/student/tests/test_tasks.py
@@ -1,15 +1,19 @@
"""
Celery task tests
"""
+from datetime import datetime
from unittest.mock import patch, Mock, PropertyMock
import pytest
from django.conf import settings
from django.test.utils import override_settings
+from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.student.tasks import (
MAX_RETRIES,
- send_course_enrollment_email
+ ENABLE_SES_FOR_COURSE_ENROLLMENT,
+ _build_enrollment_email_image_urls,
+ send_course_enrollment_email,
)
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -132,14 +136,19 @@ def _get_canvas_properties(
"learning_base_url": "https://learningmfe.openedx.org",
"lms_base_url": settings.LMS_ROOT_URL,
"course_price": 0,
+ "current_year": datetime.now().year,
"goals_enabled": False,
"course_date_blocks": [],
"course_title": self.send_course_enrollment_email_kwargs["course_title"],
"short_description": self.send_course_enrollment_email_kwargs["short_description"],
"pacing_type": self.send_course_enrollment_email_kwargs["pacing_type"],
"track_mode": self.send_course_enrollment_email_kwargs["track_mode"],
+ "user_name": self.user.get_full_name() or self.user.first_name or self.user.username,
}
+ # Payload now always includes image URLs used by SES and ignored by Braze.
+ canvas_properties.update(_build_enrollment_email_image_urls(language='en'))
+
if add_course_dates:
canvas_properties.update({"course_date_blocks": self._get_course_dates()})
@@ -388,3 +397,337 @@ def test_retry_with_email_client_exception(
)
pytest.raises(Exception, task.get)
self.assertEqual(mock_get_email_client.call_count, (MAX_RETRIES + 1))
+
+
+@override_settings(
+ BRAZE_COURSE_ENROLLMENT_CANVAS_ID=BRAZE_COURSE_ENROLLMENT_CANVAS_ID,
+ LEARNING_MICROFRONTEND_URL="https://learningmfe.openedx.org",
+ DEFAULT_FROM_EMAIL="no-reply@edx.org",
+ LMS_ROOT_URL="https://courses.edx.org",
+)
+class TestCourseEnrollmentEmailSESRouting(ModuleStoreTestCase):
+ """
+ Tests for waffle-flag-based SES/Braze routing in send_course_enrollment_email.
+
+ Mirrors the pattern used for account-activation SES routing in
+ openedx/core/djangoapps/user_authn/tasks.py.
+ """
+
+ COURSE_ID = "course-v1:edX+DemoX+2024"
+
+ def setUp(self):
+ super().setUp()
+ self.user = UserFactory.create(username="ses_user", email="ses@example.com")
+ # Use a string course ID — no CourseFactory needed since all external
+ # calls are mocked.
+ self.task_kwargs = {
+ "user_id": self.user.id,
+ "course_id": self.COURSE_ID,
+ "course_title": "SES Test Course",
+ "short_description": "SES description",
+ "course_ended": False,
+ "pacing_type": "self_paced",
+ "track_mode": "audit",
+ }
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=False)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.get_email_client")
+ @patch("common.djangoapps.student.tasks._send_ses_enrollment_email")
+ def test_braze_path_used_when_waffle_disabled(
+ self,
+ mock_ses_send,
+ mock_get_email_client,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """When the waffle flag is off, Braze canvas message is sent and SES is NOT called."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ mock_get_email_client.return_value.send_canvas_message.assert_called_once()
+ mock_ses_send.assert_not_called()
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.get_email_client")
+ @patch("common.djangoapps.student.tasks._send_ses_enrollment_email")
+ def test_ses_path_used_when_waffle_enabled(
+ self,
+ mock_ses_send,
+ mock_get_email_client,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """When the waffle flag is on, SES send is called and Braze is NOT called."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ mock_ses_send.assert_called_once()
+ mock_get_email_client.return_value.send_canvas_message.assert_not_called()
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.get_email_client")
+ @patch(
+ "common.djangoapps.student.tasks._send_ses_enrollment_email",
+ side_effect=Exception("SES failure"),
+ )
+ def test_ses_failure_falls_back_to_braze(
+ self,
+ mock_ses_send,
+ mock_get_email_client,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """If SES send fails, task should fall back to Braze without retrying."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ mock_ses_send.assert_called_once()
+ mock_get_email_client.return_value.send_canvas_message.assert_called_once()
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch(
+ "common.djangoapps.student.tasks._send_ses_enrollment_email",
+ side_effect=Exception("SES failure"),
+ )
+ @patch(
+ "common.djangoapps.student.tasks.get_email_client",
+ new_callable=PropertyMock,
+ side_effect=Exception("Braze fallback failure"),
+ )
+ def test_ses_and_braze_fallback_failure_triggers_retry(
+ self,
+ mock_get_email_client,
+ mock_ses_send,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """If both SES and Braze fallback fail, task should retry up to MAX_RETRIES."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ task = send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+ pytest.raises(Exception, task.get)
+ self.assertEqual(mock_ses_send.call_count, MAX_RETRIES + 1)
+ self.assertEqual(mock_get_email_client.call_count, MAX_RETRIES + 1)
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.render_to_string", return_value="")
+ @patch("common.djangoapps.student.tasks.EmailMultiAlternatives")
+ def test_ses_send_uses_correct_template_and_recipient(
+ self,
+ mock_email_cls,
+ mock_render,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """_send_ses_enrollment_email renders the namespaced template and addresses to user.email."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ # Template should use the new namespaced path
+ render_call_args = mock_render.call_args
+ assert render_call_args[0][0] == "emails/enrollment_en.html"
+
+ # Email should go to the correct user — EmailMultiAlternatives(subject, body, from, to=[...])
+ _, email_kwargs = mock_email_cls.call_args
+ assert self.user.email in email_kwargs.get('to', [])
+ assert email_kwargs.get('subject') == f"You're enrolled in {self.task_kwargs['course_title']}"
+ assert email_kwargs.get('body')
+ assert email_kwargs.get('reply_to')
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.UserPreference.get_value", return_value="es")
+ @patch("common.djangoapps.student.tasks.render_to_string", return_value="")
+ @patch("common.djangoapps.student.tasks.EmailMultiAlternatives")
+ def test_ses_send_uses_spanish_template_when_user_pref_is_spanish(
+ self,
+ mock_email_cls,
+ mock_render,
+ _mock_user_pref,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """SES should render Spanish enrollment template when user language preference is Spanish."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ render_call_args = mock_render.call_args
+ assert render_call_args[0][0] == "emails/enrollment_es.html"
+
+ _, email_kwargs = mock_email_cls.call_args
+ assert self.user.email in email_kwargs.get('to', [])
+ assert email_kwargs.get('subject') == f"Te has inscrito en {self.task_kwargs['course_title']}"
+ assert email_kwargs.get('body')
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=False)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.UserPreference.get_value", return_value="es")
+ @patch("common.djangoapps.student.tasks.get_email_client")
+ def test_braze_canvas_context_includes_spanish_image_keys_when_user_pref_is_spanish(
+ self,
+ mock_get_email_client,
+ _mock_user_pref,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """Braze payload should include Spanish image URL keys when user language preference is Spanish."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ _, send_kwargs = mock_get_email_client.return_value.send_canvas_message.call_args
+ canvas_props = send_kwargs["canvas_entry_properties"]
+ assert "timer_icon_es" in canvas_props
+ assert "arrow_icon_es" in canvas_props
+
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch("common.djangoapps.student.tasks.get_email_client")
+ @patch("common.djangoapps.student.tasks._send_ses_enrollment_email")
+ def test_braze_not_called_when_ses_succeeds(
+ self,
+ mock_ses_send,
+ mock_get_email_client,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """Verify Braze is NOT called when SES succeeds (no duplicate sends)."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+
+ mock_ses_send.assert_called_once()
+ mock_get_email_client.return_value.send_canvas_message.assert_not_called()
+
+ @override_settings(BRAZE_COURSE_ENROLLMENT_CANVAS_ID=None)
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=False)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch(
+ "common.djangoapps.student.tasks._send_braze_enrollment_email",
+ side_effect=Exception("Braze fallback failure"),
+ )
+ def test_braze_path_retries_when_canvas_id_missing(
+ self,
+ _mock_braze_send,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """Braze path should retry when BRAZE_COURSE_ENROLLMENT_CANVAS_ID is missing."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ task = send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+ pytest.raises(Exception, task.get)
+
+ @override_settings(BRAZE_COURSE_ENROLLMENT_CANVAS_ID=None)
+ @override_waffle_flag(ENABLE_SES_FOR_COURSE_ENROLLMENT, active=True)
+ @patch("common.djangoapps.student.tasks.get_course_uuid_for_course")
+ @patch("common.djangoapps.student.tasks.get_owners_for_course")
+ @patch("common.djangoapps.student.tasks.get_course_run_details")
+ @patch("common.djangoapps.student.tasks.get_course_dates_for_email")
+ @patch(
+ "common.djangoapps.student.tasks._send_braze_enrollment_email",
+ side_effect=Exception("Braze fallback failure"),
+ )
+ @patch(
+ "common.djangoapps.student.tasks._send_ses_enrollment_email",
+ side_effect=Exception("SES failure"),
+ )
+ def test_ses_fallback_retries_when_canvas_id_missing(
+ self,
+ _mock_ses_send,
+ _mock_braze_send,
+ mock_get_course_dates,
+ mock_get_course_run,
+ mock_get_owners,
+ mock_get_uuid,
+ ):
+ """SES path should retry when SES fails and Braze fallback is misconfigured."""
+ mock_get_uuid.return_value = "some-uuid"
+ mock_get_owners.return_value = []
+ mock_get_course_run.return_value = {}
+ mock_get_course_dates.return_value = []
+
+ task = send_course_enrollment_email.apply_async(kwargs=self.task_kwargs)
+ pytest.raises(Exception, task.get)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index cc2a79121806..64fd881e8b07 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1217,6 +1217,13 @@
PRESS_EMAIL = 'press@example.com'
FINANCE_EMAIL = ''
+# .. setting_name: BRAZE_COURSE_ENROLLMENT_CANVAS_ID
+# .. setting_default: None
+# .. setting_description: Canvas ID for course enrollment email in Braze. This is used when sending
+# course enrollment emails via Braze canvas API. Set to your Braze canvas ID when using Braze for
+# enrollment emails. Can be None if only using SES (when enable_ses_for_course_enrollment is True).
+# BRAZE_COURSE_ENROLLMENT_CANVAS_ID = None #uncomment only for local testing
+
# Platform mailing address
CONTACT_MAILING_ADDRESS = 'SET-ME-PLEASE'
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index eb18b184a947..f4ff3201044d 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -76,6 +76,29 @@
EMAIL_BACKEND = 'django.core.mail.backends.filebased.EmailBackend'
EMAIL_FILE_PATH = '/edx/src/ace_messages/'
+# ========== SES ENROLLMENT EMAIL CONFIGURATION (FOR TESTING) ==========
+# Uncomment the following line to test SES enrollment email theme resolution
+# When enabled, templates will load from edx-themes first (edx.org-next)
+# Then fall back to platform templates if theme templates not found
+#
+# In production:
+# - ENABLE_COMPREHENSIVE_THEMING = True
+# - DEFAULT_SITE_THEME = 'edx.org'
+# - SES_ENROLLMENT_EMAIL_THEME will be set via edx-internal config
+#
+# In devstack (with this uncommented):
+# - Templates will try to load from edx.org-next theme
+# - If theme templates exist in edx-themes, will use them
+# - Otherwise falls back to platform templates (safe default)
+#
+# To test the complete flow:
+# 1. Uncomment the line below
+# 2. Run: python manage.py lms shell -c "from django.template import engines; ..."
+# 3. Should show theme-based template resolution
+# 4. Comment out when done testing
+#
+# SES_ENROLLMENT_EMAIL_THEME = 'edx.org-next' # Uncomment only for local testing
+
############################ PYFS XBLOCKS SERVICE #############################
# Set configuration for Django pyfilesystem
@@ -494,13 +517,15 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing
# in it's path. Re-calling derive_settings doesn't work because the settings was already
# changed from a function to a list, and it can't be derived again.
-# from openedx.envs.common import make_mako_template_dirs
+# ========== COMPREHENSIVE THEMING ENABLED FOR SES ENROLLMENT EMAIL TESTING ==========
+# Uncomment this section to test theme-first template resolution in devstack
+# This matches production behavior where ENABLE_COMPREHENSIVE_THEMING = True
# ENABLE_COMPREHENSIVE_THEMING = True
+# DEFAULT_SITE_THEME = 'edx.org' # Base theme for resolution
# COMPREHENSIVE_THEME_DIRS = [
-# "/edx/app/edxapp/edx-platform/themes/"
+# "/edx/var/edx-themes/edx-themes/edx-platform/"
# ]
-# TEMPLATES[1]["DIRS"] = make_mako_template_dirs
-# derive_settings(__name__)
+# ============================================================================
# Uncomment the lines below if you'd like to see SQL statements in your devstack LMS log.
# LOGGING['handlers']['console']['level'] = 'DEBUG'
diff --git a/lms/static/images/enrollment_email/arrow_icon_es.png b/lms/static/images/enrollment_email/arrow_icon_es.png
new file mode 100644
index 000000000000..2285352a394e
Binary files /dev/null and b/lms/static/images/enrollment_email/arrow_icon_es.png differ
diff --git a/lms/static/images/enrollment_email/banner_default.png b/lms/static/images/enrollment_email/banner_default.png
new file mode 100644
index 000000000000..0535b905ceb4
Binary files /dev/null and b/lms/static/images/enrollment_email/banner_default.png differ
diff --git a/lms/static/images/enrollment_email/calendar_icon_es.png b/lms/static/images/enrollment_email/calendar_icon_es.png
new file mode 100644
index 000000000000..730ae971efbc
Binary files /dev/null and b/lms/static/images/enrollment_email/calendar_icon_es.png differ
diff --git a/lms/static/images/enrollment_email/community_icon_es.png b/lms/static/images/enrollment_email/community_icon_es.png
new file mode 100644
index 000000000000..66006cfd5d93
Binary files /dev/null and b/lms/static/images/enrollment_email/community_icon_es.png differ
diff --git a/lms/static/images/enrollment_email/community_illustration_en.png b/lms/static/images/enrollment_email/community_illustration_en.png
new file mode 100644
index 000000000000..55140a8ef241
Binary files /dev/null and b/lms/static/images/enrollment_email/community_illustration_en.png differ
diff --git a/lms/static/images/enrollment_email/dollar_icon_en.png b/lms/static/images/enrollment_email/dollar_icon_en.png
new file mode 100644
index 000000000000..ebf7aa560829
Binary files /dev/null and b/lms/static/images/enrollment_email/dollar_icon_en.png differ
diff --git a/lms/static/images/enrollment_email/dollar_icon_es.png b/lms/static/images/enrollment_email/dollar_icon_es.png
new file mode 100644
index 000000000000..e406434837f2
Binary files /dev/null and b/lms/static/images/enrollment_email/dollar_icon_es.png differ
diff --git a/lms/static/images/enrollment_email/flag_icon_black_en_es.png b/lms/static/images/enrollment_email/flag_icon_black_en_es.png
new file mode 100644
index 000000000000..b5fc97ee32bc
Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_black_en_es.png differ
diff --git a/lms/static/images/enrollment_email/flag_icon_grey_es.png b/lms/static/images/enrollment_email/flag_icon_grey_es.png
new file mode 100644
index 000000000000..9eff18dfc779
Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_grey_es.png differ
diff --git a/lms/static/images/enrollment_email/flag_icon_orange_en.png b/lms/static/images/enrollment_email/flag_icon_orange_en.png
new file mode 100644
index 000000000000..574e11d8f6cc
Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_orange_en.png differ
diff --git a/lms/static/images/enrollment_email/flag_icon_pink_en.png b/lms/static/images/enrollment_email/flag_icon_pink_en.png
new file mode 100644
index 000000000000..86b46ef98021
Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_pink_en.png differ
diff --git a/lms/static/images/enrollment_email/flag_icon_white_es.png b/lms/static/images/enrollment_email/flag_icon_white_es.png
new file mode 100644
index 000000000000..9bc8536f4c56
Binary files /dev/null and b/lms/static/images/enrollment_email/flag_icon_white_es.png differ
diff --git a/lms/static/images/enrollment_email/goal_idea_icon_en.png b/lms/static/images/enrollment_email/goal_idea_icon_en.png
new file mode 100644
index 000000000000..0d9c55ad7031
Binary files /dev/null and b/lms/static/images/enrollment_email/goal_idea_icon_en.png differ
diff --git a/lms/static/images/enrollment_email/logo_url.png b/lms/static/images/enrollment_email/logo_url.png
new file mode 100644
index 000000000000..419f27df2c3e
Binary files /dev/null and b/lms/static/images/enrollment_email/logo_url.png differ
diff --git a/lms/static/images/enrollment_email/person_icon_en.png b/lms/static/images/enrollment_email/person_icon_en.png
new file mode 100644
index 000000000000..a05d7bcfd447
Binary files /dev/null and b/lms/static/images/enrollment_email/person_icon_en.png differ
diff --git a/lms/static/images/enrollment_email/person_icon_es.png b/lms/static/images/enrollment_email/person_icon_es.png
new file mode 100644
index 000000000000..1f79fee7028f
Binary files /dev/null and b/lms/static/images/enrollment_email/person_icon_es.png differ
diff --git a/lms/static/images/enrollment_email/slant_line_es.png b/lms/static/images/enrollment_email/slant_line_es.png
new file mode 100644
index 000000000000..ce4e037273e3
Binary files /dev/null and b/lms/static/images/enrollment_email/slant_line_es.png differ
diff --git a/lms/static/images/enrollment_email/timer_icon_en.png b/lms/static/images/enrollment_email/timer_icon_en.png
new file mode 100644
index 000000000000..ca3b690b2d61
Binary files /dev/null and b/lms/static/images/enrollment_email/timer_icon_en.png differ
diff --git a/lms/static/images/enrollment_email/timer_icon_es.png b/lms/static/images/enrollment_email/timer_icon_es.png
new file mode 100644
index 000000000000..55f4b54ce789
Binary files /dev/null and b/lms/static/images/enrollment_email/timer_icon_es.png differ
diff --git a/lms/static/images/enrollment_email/vertical_line_black_en.png b/lms/static/images/enrollment_email/vertical_line_black_en.png
new file mode 100644
index 000000000000..191d1bf257ba
Binary files /dev/null and b/lms/static/images/enrollment_email/vertical_line_black_en.png differ
diff --git a/lms/static/images/enrollment_email/vertical_line_orange_en.png b/lms/static/images/enrollment_email/vertical_line_orange_en.png
new file mode 100644
index 000000000000..459f1fe1606d
Binary files /dev/null and b/lms/static/images/enrollment_email/vertical_line_orange_en.png differ
diff --git a/lms/static/images/enrollment_email/vertical_line_white_en.png b/lms/static/images/enrollment_email/vertical_line_white_en.png
new file mode 100644
index 000000000000..96f66d5ba919
Binary files /dev/null and b/lms/static/images/enrollment_email/vertical_line_white_en.png differ
diff --git a/lms/static/images/enrollment_email/you_are_enrolled_en.png b/lms/static/images/enrollment_email/you_are_enrolled_en.png
new file mode 100644
index 000000000000..e7282f071085
Binary files /dev/null and b/lms/static/images/enrollment_email/you_are_enrolled_en.png differ
diff --git a/openedx/envs/common.py b/openedx/envs/common.py
index ada6fd3f0be2..d5b30dd510d0 100644
--- a/openedx/envs/common.py
+++ b/openedx/envs/common.py
@@ -1991,6 +1991,16 @@ def _make_locale_paths(settings):
# has to be enabled.
DEFAULT_SITE_THEME = None
+# .. setting_name: SES_ENROLLMENT_EMAIL_THEME
+# .. setting_default: None
+# .. setting_description: Optional theme override for SES course enrollment emails.
+# When set, enrollment emails rendered via SES use this theme instead of DEFAULT_SITE_THEME.
+# Allows per-email-type theme customization without affecting other emails (e.g., "edx.org-next").
+# Only effective if ENABLE_COMPREHENSIVE_THEMING is True.
+# .. setting_warning: Must point to a valid theme in COMPREHENSIVE_THEME_DIRS or edx-platform/themes.
+# If the theme does not exist or template is not found, falls back to DEFAULT_SITE_THEME resolution.
+SES_ENROLLMENT_EMAIL_THEME = None
+
# .. toggle_name: ENABLE_COMPREHENSIVE_THEMING
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False