Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------

Expand Down
10 changes: 9 additions & 1 deletion authentication/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions authentication/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
45 changes: 45 additions & 0 deletions main/middleware/apisix_user_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion main/settings_pluggy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions main/templates/email/welcome_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
{% extends "email/email_base.html" %}

{% block content %}
<tr>
<td
style="
padding: 32px 32px 0;
font-family: 'Source Sans Pro', sans-serif;
font-size: 14px;
line-height: 22px;
color: #212326;
"
>
<p style="margin: 0 0 16px">
Hi
{{ display_name }},
</p>
<span style="margin: 0 0 16px">
<strong>Welcome to MIT Learn. Your account is ready.</strong>
</span>
<p style="margin: 0 0 16px">
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.
</p>
<p style="margin: 0 0 16px">
With your account, you can make MIT Learn your own:
</p>
<ul
style="
margin: 0 0 16px 18px;
padding: 0;
font-size: 12px;
line-height: 18px;
color: #212326;
"
>
<li style="margin: 0 0 4px">
Follow topics and departments to get updates when
<span style="white-space: nowrap">new MIT</span><br />
learning resources are published
</li>
<li style="margin: 0 0 4px">
Bookmark resources and create lists so you can return to them later
</li>
<li style="margin: 0">
Use your dashboard to find recommendations based on your interests
</li>
</ul>
<p style="margin: 0 0 24px">
Use your account to save what matters, follow your interests, and pick up
your learning where you left off.
</p>
<table
align="left"
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
style="float: left"
>
<tr>
<td
align="left"
style="
border-radius: 4px;
background: #a31f34;
text-align: left;
box-shadow:
0 2px 4px rgba(37, 38, 43, 0.1),
0 3px 8px rgba(37, 38, 43, 0.12);
"
>
<a
class="button-a button-a-primary"
href="{{ APP_BASE_URL }}/dashboard"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The dashboard URL in the welcome email is built by concatenating {{ APP_BASE_URL }} and /dashboard, which can create a double-slash if APP_BASE_URL has a trailing slash.
Severity: MEDIUM

Suggested Fix

Use the urljoin filter from Django's future template tags to correctly join the URL parts. Alternatively, strip the trailing slash from APP_BASE_URL before it is passed to the template context in profiles/utils.py, for example by using settings.APP_BASE_URL.rstrip('/').

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: main/templates/email/welcome_email.html#L77

Potential issue: The welcome email template at `main/templates/email/welcome_email.html`
constructs the dashboard URL as `{{ APP_BASE_URL }}/dashboard`. When the `APP_BASE_URL`
setting is configured with a trailing slash (e.g., `https://learn.mit.edu/`), this
results in a URL with a double slash, like `https://learn.mit.edu//dashboard`. While
many web servers tolerate this, stricter server configurations might interpret this as a
malformed path, leading to a 404 error or an incorrect redirect. This could break the
primary call-to-action link for all new users receiving the welcome email. Other parts
of the codebase handle this by stripping the trailing slash, but this template omits
that step.

Did we get this right? 👍 / 👎 to inform future reviews.

align="left"
style="
background: #a31f34;
border: 1px solid #a31f34;
font-family: 'Source Sans Pro', sans-serif;
font-weight: 500;
font-size: 14px;
line-height: 14px;
text-decoration: none;
text-align: center;
padding: 16px;
color: #fff;
display: block;
border-radius: 4px;
"
>Go To Your Dashboard</a
>
</td>
</tr>
</table>
<div style="clear: both; height: 24px"></div>
<p
style="
margin: 0 0 24px;
font-size: 14px;
line-height: 18px;
color: #212326;
"
>
<strong>The MIT Learn Team</strong>
</p>
<p style="margin: 0; border-top: 1px solid #d9dde3"></p>
<p
style="
margin: 24px 0 0;
font-family: 'Source Sans Pro', sans-serif;
font-size: 12px;
line-height: 17px;
color: #212326;
text-align: center;
"
>
<strong>MIT Learn</strong> &bull; 77 Massachusetts Avenue &bull;
Cambridge, MA 02139 &bull; USA
</p>
</td>
</tr>
{% endblock %}

{% block footer %}{% endblock %}
{% block footer-address %}{% endblock %}
16 changes: 16 additions & 0 deletions profiles/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.apps import apps

from profiles.api import ensure_profile
from profiles.tasks import send_welcome_email


class CreateProfilePlugin:
Expand All @@ -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)
17 changes: 17 additions & 0 deletions profiles/plugins_test.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 41 additions & 0 deletions profiles/tasks.py
Original file line number Diff line number Diff line change
@@ -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},
)
Loading
Loading