diff --git a/RELEASE.rst b/RELEASE.rst
index cdd32f7d60..330fc6a03d 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,11 @@
Release Notes
=============
+Version 0.66.11
+---------------
+
+- feat: Welcome to learn email on new account setup (#3189)
+
Version 0.66.10 (Released May 07, 2026)
---------------
diff --git a/authentication/api.py b/authentication/api.py
index 7ac1ca07bc..a8f73ea18f 100644
--- a/authentication/api.py
+++ b/authentication/api.py
@@ -33,9 +33,17 @@ def create_user(username, email, profile_data=None, user_extra=None):
defaults.update({"username": username})
with transaction.atomic():
- user, _ = User.objects.get_or_create(email=email, defaults=defaults)
+ user, created = User.objects.get_or_create(email=email, defaults=defaults)
profile_api.ensure_profile(user, profile_data=profile_data)
+ if created:
+ transaction.on_commit(
+ lambda: user_created_actions(
+ user=user,
+ is_new=True,
+ details=profile_data or {},
+ )
+ )
return user
diff --git a/authentication/api_test.py b/authentication/api_test.py
index aef0f9fafe..29ded90065 100644
--- a/authentication/api_test.py
+++ b/authentication/api_test.py
@@ -37,6 +37,35 @@ def test_create_user(profile_data):
assert user.profile.name is None
+@pytest.mark.django_db(transaction=True)
+def test_create_user_triggers_plugins_for_new_users(mocker):
+ """create_user should trigger user_created plugins for brand new users."""
+ mock_pm = mocker.Mock()
+ mocker.patch("authentication.api.get_plugin_manager", return_value=mock_pm)
+
+ user = api.create_user("new-user", "new@localhost", {"name": "New User"})
+
+ mock_pm.hook.user_created.assert_called_once_with(
+ user=user,
+ user_data={"profile": {"name": "New User"}},
+ )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_create_user_does_not_retrigger_plugins_for_existing_users(mocker):
+ """create_user should not trigger user_created plugins for existing users."""
+ user = UserFactory.create(email="existing@localhost")
+ mock_pm = mocker.Mock()
+ mocker.patch("authentication.api.get_plugin_manager", return_value=mock_pm)
+
+ resolved = api.create_user(
+ "another-username", user.email, {"name": "Existing User"}
+ )
+
+ assert resolved.id == user.id
+ mock_pm.hook.user_created.assert_not_called()
+
+
@pytest.mark.parametrize(
"mock_method",
["profiles.api.ensure_profile"],
diff --git a/main/middleware/apisix_user_test.py b/main/middleware/apisix_user_test.py
index 50cdbdc31c..aed867a296 100644
--- a/main/middleware/apisix_user_test.py
+++ b/main/middleware/apisix_user_test.py
@@ -84,6 +84,51 @@ def test_get_request_no_posthog_key(mocker, mock_login, settings):
mock_posthog_cls.assert_not_called()
+@pytest.mark.django_db(transaction=True)
+def test_get_request_queues_welcome_email_for_new_sso_user(mocker, mock_login):
+ """New APISIX users should queue welcome email once."""
+ close_old_connections()
+ mock_delay = mocker.patch("profiles.plugins.send_welcome_email.delay")
+ mock_request = mocker.Mock(
+ META={
+ "HTTP_X_USERINFO": b64encode(json.dumps(apisix_user_info).encode()),
+ },
+ user=AnonymousUser(),
+ )
+ apisix_middleware = ApisixUserMiddleware(mocker.Mock())
+ apisix_middleware.process_request(mock_request)
+
+ user = User.objects.get(email=apisix_user_info["email"])
+ mock_login.assert_called_once()
+ mock_delay.assert_called_once_with(user.id)
+
+
+@pytest.mark.django_db(transaction=True)
+def test_get_request_does_not_queue_welcome_email_for_existing_sso_user(
+ mocker, mock_login
+):
+ """Existing APISIX users should not queue welcome email again."""
+ close_old_connections()
+ existing_user = UserFactory.create(
+ email=apisix_user_info["email"],
+ username=apisix_user_info["preferred_username"],
+ global_id=apisix_user_info["sub"],
+ )
+ mock_delay = mocker.patch("profiles.plugins.send_welcome_email.delay")
+ mock_request = mocker.Mock(
+ META={
+ "HTTP_X_USERINFO": b64encode(json.dumps(apisix_user_info).encode()),
+ },
+ user=AnonymousUser(),
+ )
+ apisix_middleware = ApisixUserMiddleware(mocker.Mock())
+ apisix_middleware.process_request(mock_request)
+
+ existing_user.refresh_from_db()
+ mock_login.assert_called_once()
+ mock_delay.assert_not_called()
+
+
@pytest.mark.django_db(transaction=True)
def test_get_request_existing_user_no_globalid(mocker, mock_login):
"""Test that a valid request updates existing user with same email, no global_id."""
diff --git a/main/settings.py b/main/settings.py
index ee45d02bce..81005c45b3 100644
--- a/main/settings.py
+++ b/main/settings.py
@@ -35,7 +35,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings
-VERSION = "0.66.10"
+VERSION = "0.66.11"
log = logging.getLogger()
diff --git a/main/settings_pluggy.py b/main/settings_pluggy.py
index da6aad907f..9b121b402d 100644
--- a/main/settings_pluggy.py
+++ b/main/settings_pluggy.py
@@ -2,7 +2,7 @@
MITOL_AUTHENTICATION_PLUGINS = get_string(
"MITOL_AUTHENTICATION_PLUGINS",
- "learning_resources.plugins.FavoritesListPlugin,profiles.plugins.CreateProfilePlugin",
+ "learning_resources.plugins.FavoritesListPlugin,profiles.plugins.CreateProfilePlugin,profiles.plugins.WelcomeEmailPlugin",
)
MITOL_LEARNING_RESOURCES_PLUGINS = get_string(
"MITOL_LEARNING_RESOURCES_PLUGINS",
diff --git a/main/templates/email/welcome_email.html b/main/templates/email/welcome_email.html
new file mode 100644
index 0000000000..a606ecf97b
--- /dev/null
+++ b/main/templates/email/welcome_email.html
@@ -0,0 +1,128 @@
+{% extends "email/email_base.html" %}
+
+{% block content %}
+
+ |
+
+ Hi
+ {{ display_name }},
+
+
+ Welcome to MIT Learn. Your account is ready.
+
+
+ The MIT Learn platform brings together courses, programs and learning
+ materials from across MIT. With advanced search and AskTIM's AI-powered
+ guidance, quickly discover the most relevant content for your learning
+ goals.
+
+
+ With your account, you can make MIT Learn your own:
+
+
+ -
+ Follow topics and departments to get updates when
+ new MIT
+ learning resources are published
+
+ -
+ Bookmark resources and create lists so you can return to them later
+
+ -
+ Use your dashboard to find recommendations based on your interests
+
+
+
+ Use your account to save what matters, follow your interests, and pick up
+ your learning where you left off.
+
+
+
+
+ The MIT Learn Team
+
+
+
+ MIT Learn • 77 Massachusetts Avenue •
+ Cambridge, MA 02139 • USA
+
+ |
+
+{% endblock %}
+
+{% block footer %}{% endblock %}
+{% block footer-address %}{% endblock %}
diff --git a/profiles/plugins.py b/profiles/plugins.py
index 7faccab4e3..9286f3e28a 100644
--- a/profiles/plugins.py
+++ b/profiles/plugins.py
@@ -3,6 +3,7 @@
from django.apps import apps
from profiles.api import ensure_profile
+from profiles.tasks import send_welcome_email
class CreateProfilePlugin:
@@ -19,3 +20,18 @@ def user_created(self, user, user_data):
"""
profile_data = user_data.get("profile", {})
ensure_profile(user, profile_data)
+
+
+class WelcomeEmailPlugin:
+ hookimpl = apps.get_app_config("authentication").hookimpl
+
+ @hookimpl
+ def user_created(self, user, user_data): # noqa: ARG002
+ """
+ Send a welcome email when a new user account is created.
+
+ Args:
+ user(User): the user that was created
+ user_data(dict): the user data
+ """
+ send_welcome_email.delay(user.id)
diff --git a/profiles/plugins_test.py b/profiles/plugins_test.py
new file mode 100644
index 0000000000..99a9171652
--- /dev/null
+++ b/profiles/plugins_test.py
@@ -0,0 +1,17 @@
+"""Tests for profiles plugins."""
+
+import pytest
+
+from main.factories import UserFactory
+from profiles.plugins import WelcomeEmailPlugin
+
+
+@pytest.mark.django_db
+def test_welcome_email_plugin_user_created(mocker):
+ """WelcomeEmailPlugin should queue welcome email task on user creation."""
+ user = UserFactory.create(email="new.user@example.com", first_name="New")
+ mocked_delay = mocker.patch("profiles.plugins.send_welcome_email.delay")
+
+ WelcomeEmailPlugin().user_created(user, user_data={})
+
+ mocked_delay.assert_called_once_with(user.id)
diff --git a/profiles/tasks.py b/profiles/tasks.py
new file mode 100644
index 0000000000..31764e8b0c
--- /dev/null
+++ b/profiles/tasks.py
@@ -0,0 +1,41 @@
+"""Tasks for profiles."""
+
+import logging
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ObjectDoesNotExist
+
+from main.celery import app
+from profiles.utils import send_template_email
+
+log = logging.getLogger(__name__)
+User = get_user_model()
+
+
+@app.task
+def send_welcome_email(user_id):
+ """
+ Send a welcome email to a user by id.
+ """
+ user = User.objects.filter(id=user_id).first()
+ if not user:
+ log.warning("User %s not found for welcome email", user_id)
+ return
+ if not user.email:
+ log.warning("User %s has blank email, skipping welcome email", user_id)
+ return
+
+ try:
+ profile_name = user.profile.name
+ except ObjectDoesNotExist:
+ profile_name = None
+ full_name = " ".join(
+ part for part in [user.first_name, user.last_name] if part
+ ).strip()
+ display_name = profile_name or full_name or user.username or "there"
+ send_template_email(
+ [user.email],
+ "MIT Learn - Welcome to MIT Learn",
+ "email/welcome_email.html",
+ context={"display_name": display_name},
+ )
diff --git a/profiles/tasks_test.py b/profiles/tasks_test.py
new file mode 100644
index 0000000000..9f7f01f425
--- /dev/null
+++ b/profiles/tasks_test.py
@@ -0,0 +1,112 @@
+"""Tests for profile tasks."""
+
+import pytest
+
+from main.factories import UserFactory
+from profiles.tasks import send_welcome_email
+
+
+@pytest.mark.django_db
+def test_send_welcome_email_sends_template_email(mocker):
+ """send_welcome_email should send rendered welcome template for valid user."""
+ user = UserFactory.create(email="new.user@example.com", first_name="New")
+ user.profile.name = "Full Name"
+ user.profile.save()
+ mocked_send = mocker.patch("profiles.tasks.send_template_email")
+
+ send_welcome_email(user.id)
+
+ mocked_send.assert_called_once_with(
+ ["new.user@example.com"],
+ "MIT Learn - Welcome to MIT Learn",
+ "email/welcome_email.html",
+ context={"display_name": "Full Name"},
+ )
+
+
+@pytest.mark.django_db
+def test_send_welcome_email_missing_user(mocker):
+ """send_welcome_email should no-op when user does not exist."""
+ mocked_send = mocker.patch("profiles.tasks.send_template_email")
+
+ send_welcome_email(999999)
+
+ mocked_send.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_send_welcome_email_blank_email(mocker):
+ """send_welcome_email should no-op when user has blank email."""
+ user = UserFactory.create(email="")
+ mocked_send = mocker.patch("profiles.tasks.send_template_email")
+
+ send_welcome_email(user.id)
+
+ mocked_send.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_send_welcome_email_uses_full_name_when_profile_name_missing(mocker):
+ """Falls back to first+last name if profile.name is missing."""
+ user = UserFactory.create(
+ email="full.name@example.com",
+ first_name="Full",
+ last_name="Name",
+ )
+ user.profile.name = None
+ user.profile.save()
+ mocked_send = mocker.patch("profiles.tasks.send_template_email")
+
+ send_welcome_email(user.id)
+
+ mocked_send.assert_called_once_with(
+ ["full.name@example.com"],
+ "MIT Learn - Welcome to MIT Learn",
+ "email/welcome_email.html",
+ context={"display_name": "Full Name"},
+ )
+
+
+@pytest.mark.django_db
+def test_send_welcome_email_uses_username_when_names_missing(mocker):
+ """Falls back to username when profile/full name are not available."""
+ user = UserFactory.create(
+ email="username.only@example.com",
+ first_name="",
+ last_name="",
+ username="username-only",
+ )
+ user.profile.name = None
+ user.profile.save()
+ mocked_send = mocker.patch("profiles.tasks.send_template_email")
+
+ send_welcome_email(user.id)
+
+ mocked_send.assert_called_once_with(
+ ["username.only@example.com"],
+ "MIT Learn - Welcome to MIT Learn",
+ "email/welcome_email.html",
+ context={"display_name": "username-only"},
+ )
+
+
+@pytest.mark.django_db
+def test_send_welcome_email_handles_missing_profile_relation(mocker):
+ """Falls back cleanly when the reverse profile relation is missing."""
+ user = UserFactory.create(
+ email="missing.profile@example.com",
+ first_name="",
+ last_name="",
+ username="profile-missing",
+ )
+ user.profile.delete()
+ mocked_send = mocker.patch("profiles.tasks.send_template_email")
+
+ send_welcome_email(user.id)
+
+ mocked_send.assert_called_once_with(
+ ["missing.profile@example.com"],
+ "MIT Learn - Welcome to MIT Learn",
+ "email/welcome_email.html",
+ context={"display_name": "profile-missing"},
+ )