Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ jobs:
OPENEDX_API_CLIENT_ID: fake_client_id
OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret
SECRET_KEY: local_unsafe_key # pragma: allowlist secret
MITOL_PAYMENT_GATEWAY_STRIPE_API_KEY: skc_test_123456 # pragma: allowlist secret

- name: Migration and OpenAPI spec checks
run: ./scripts/test/python_checks.sh
Expand Down Expand Up @@ -152,6 +153,7 @@ jobs:
OPENEDX_API_CLIENT_ID: fake_client_id
OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret
SECRET_KEY: local_unsafe_key # pragma: allowlist secret
MITOL_PAYMENT_GATEWAY_STRIPE_API_KEY: skc_test_123456 # pragma: allowlist secret

- name: Tests
run: |
Expand Down
32 changes: 31 additions & 1 deletion .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,37 @@
"is_verified": false,
"line_number": 39
}
],
"ecommerce/fixtures.py": [
{
"type": "Base64 High Entropy String",
"filename": "ecommerce/fixtures.py",
"hashed_secret": "0238bb6f137906f10fb484b43052922b3f6c7c53",
"is_verified": false,
"line_number": 25
},
{
"type": "Base64 High Entropy String",
"filename": "ecommerce/fixtures.py",
"hashed_secret": "5125fa106b00e9340b550df3804fff9e0abb660b",
"is_verified": false,
"line_number": 115
},
{
"type": "Base64 High Entropy String",
"filename": "ecommerce/fixtures.py",
"hashed_secret": "16e3415b5ffa319f4b74714a7e840d0e7403ee0d",
"is_verified": false,
"line_number": 120
},
{
"type": "Secret Keyword",
"filename": "ecommerce/fixtures.py",
"hashed_secret": "16e3415b5ffa319f4b74714a7e840d0e7403ee0d",
"is_verified": false,
"line_number": 120
}
]
},
"generated_at": "2025-06-10T16:32:59Z"
"generated_at": "2026-07-21T22:02:42Z"
}
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 1.162.2
---------------

- 12036 update mitxonline internal links to product pages to point to learn (#3795)
- B2B Enrollment email deliverability indications via webhooks (#3775)
- Support multiple payment gateways (#3751)
- Guarantee enrollment fixture covers programs with and without run enrollments (#3812)
- Add CyberSource export compliance app and legal address profile fields (#3785)

Version 1.162.1 (Released August 05, 2026)
---------------

Expand Down
135 changes: 135 additions & 0 deletions b2b/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""API functions for B2B operations."""

import hashlib
import hmac
import logging
from collections.abc import Iterable
from datetime import UTC, datetime
from decimal import Decimal
from typing import Union
from uuid import uuid4
Expand All @@ -15,6 +18,8 @@
from django.db.models import Count, Manager, Prefetch, Q
from mitol.common.utils import now_in_utc
from opaque_keys.edx.keys import CourseKey
from pydantic import BaseModel, ConfigDict, Field
from pydantic import ValidationError as PydanticValidationError
from wagtail.models import Page

from b2b.constants import (
Expand All @@ -29,9 +34,14 @@
from b2b.exceptions import SourceCourseIncompleteError
from b2b.keycloak_admin_api import KCAM_ORGANIZATIONS, get_keycloak_model
from b2b.keycloak_admin_dataclasses import OrganizationRepresentation
from b2b.mail import ENROLLMENT_CODE_ASSINGMENT_TAG
from b2b.models import (
EMAIL_STATUS_FAILED,
EMAIL_STATUS_FAILED_TEMPORARY_SEVERITY,
MAILGUN_EMAIL_EVENT_TYPES,
ContractPage,
ContractProgramItem,
DiscountContractAttachmentRedemption,
OrganizationIndexPage,
OrganizationPage,
UserOrganization,
Expand Down Expand Up @@ -1878,3 +1888,128 @@ def process_remove_org_membership(user, organization):
user=user,
organization=organization,
).get().delete()


def verify_mailgun_signature(api_key, token, timestamp, signature):
# Cribbed from https://www.mailgun.com/blog/email/your-guide-to-webhooks/.
if not settings.MAILGUN_WEBHOOK_VALIDATE_SIGNATURE:
return True

message = f"{timestamp}{token}"
expected_signature = hmac.new(
key=api_key.encode(), msg=message.encode(), digestmod=hashlib.sha256
).hexdigest()
return signature == expected_signature


class MailgunWebhookSignature(BaseModel):
"""The signature block Mailgun attaches to every webhook payload."""

token: str
timestamp: str
signature: str


class MailgunWebhookMessageHeaders(BaseModel):
"""The subset of message headers Mailgun includes with an event."""

model_config = ConfigDict(extra="allow")

message_id: str = Field(alias="message-id")


class MailgunWebhookMessage(BaseModel):
"""The message block describing the email an event pertains to."""

model_config = ConfigDict(extra="allow")

headers: MailgunWebhookMessageHeaders


class MailgunWebhookEventData(BaseModel):
"""The event-data block of a Mailgun webhook payload."""

model_config = ConfigDict(extra="allow")

event: str
tags: list[str] = Field(default_factory=list)
message: MailgunWebhookMessage
severity: str | None = None
timestamp: float


class MailgunWebhookPayload(BaseModel):
"""A synthetic or real Mailgun webhook payload, as built by build_payload."""

model_config = ConfigDict(extra="allow")

signature: MailgunWebhookSignature
event_data: MailgunWebhookEventData = Field(alias="event-data")


def is_potentially_valid_mailgun_webhook(payload):
signing_secret = settings.MAILGUN_WEBHOOK_SIGNING_SECRET
# If we are supposed to validate signatures but don't have a secret, fail closed
# If we aren't validating signatures (such as in local development w/ synthetic data),
# it doesnt matter if we have a secret, treat everything as potentially valid
if not signing_secret and settings.MAILGUN_WEBHOOK_VALIDATE_SIGNATURE:
return False

try:
webhook = MailgunWebhookPayload.model_validate(payload)
except PydanticValidationError:
return False

# Check for the right message tag - if it's not there, do nothing else.
# We want to throw out unrelated messages as fast as possible
return ENROLLMENT_CODE_ASSINGMENT_TAG in webhook.event_data.tags


# We may want to move some of the cheapest checks to the web tier, but the actual queries need to happen in a task.
def process_mailgun_webhook_for_enrollment_code_emails(payload):
if not is_potentially_valid_mailgun_webhook(payload):
return None

signing_secret = settings.MAILGUN_WEBHOOK_SIGNING_SECRET
event_data = payload["event-data"]
# Now that we've run the cheapest check, validate the event signature.
signature_param = payload["signature"]
token = signature_param["token"]
timestamp = signature_param["timestamp"]
signature = signature_param["signature"]

if not verify_mailgun_signature(signing_secret, token, timestamp, signature):
return None

# We only want to store some email statuses. If it's not one of the ones we care about, we can toss the event
event_type = event_data["event"]
if event_type not in MAILGUN_EMAIL_EVENT_TYPES:
return None

if event_type == EMAIL_STATUS_FAILED:
# This field is only present on temporary and permanent failures.
# We don't want to show temporary ones to contract managers since there's nothing to do but wait for resolution
severity = event_data.get("severity", "")
if severity == EMAIL_STATUS_FAILED_TEMPORARY_SEVERITY:
return None

# At this point we know that the payload is for the email we care about, it's from mailgun, and it's one of the events we care about
# Save it to the row corresponding to the event we just got and move on with our lives
message_id = event_data["message"]["headers"]["message-id"]
message_timestamp = datetime.fromtimestamp(event_data["timestamp"], tz=UTC)
assignment = DiscountContractAttachmentRedemption.objects.get(
email_message_id=message_id
)
saved_event_timestamp = assignment.email_status_event_timestamp

# We want to store event with the most recent timestamp we get from mailgun.
# Event receipt/processing is not guaranteed to be chronologically ordered, so this prevents older events from
# clobbering ones which actually occurred later.
if saved_event_timestamp and saved_event_timestamp >= message_timestamp:
return assignment

assignment.email_status = event_type
assignment.email_status_event_timestamp = message_timestamp
assignment.save()

return assignment
39 changes: 25 additions & 14 deletions b2b/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import uuid

from django.conf import settings
from mitol.common.utils.datetime import now_in_utc
from mitol.mail.api import get_message_sender
from mitol.mail.messages import TemplatedMessage

Expand All @@ -12,10 +13,12 @@
ENROLLMENT_CODE_ASSINGMENT_TAG = "enrollment-code-assignment"


class EnrollmentCodeAssignmentMessage(TemplatedMessage):
class BaseEnrollmentCodeAssignmentMessage(TemplatedMessage):
template_name = "mail/enrollment_code_assignment"
name = "Enrollment Code Assignment"


class EnrollmentCodeAssignmentMessage(BaseEnrollmentCodeAssignmentMessage):
@staticmethod
def get_default_headers() -> dict:
base_headers = TemplatedMessage.get_default_headers()
Expand All @@ -24,15 +27,16 @@ def get_default_headers() -> dict:
return headers


def get_learn_hostname():
from courses.api import ENV_TO_LEARN_HOSTNAME_MAP # noqa: PLC0415

return ENV_TO_LEARN_HOSTNAME_MAP.get(settings.ENVIRONMENT, "learn.mit.edu")


def send_email_helper(email, code, code_url, organization_name, contract_name):
def send_email_helper( # noqa: PLR0913
email, code, code_url, organization_name, contract_name, *, is_test=False
):
message_type = (
BaseEnrollmentCodeAssignmentMessage
if is_test
else EnrollmentCodeAssignmentMessage
)
try:
with get_message_sender(EnrollmentCodeAssignmentMessage) as sender:
with get_message_sender(message_type) as sender:
message = sender.build_message(
email,
{
Expand Down Expand Up @@ -75,27 +79,34 @@ def send_enrollment_code_assignment_email(assignment_record_ids):
).select_related("discount", "contract")
)

learn_hostname = get_learn_hostname()
for assignment in assignments:
code = assignment.discount.discount_code
code_url = f"https://{learn_hostname}/enrollmentcode/{code}"
code_url = f"{settings.MIT_LEARN_ATTACH_URL}{code}"
organization_name = assignment.contract.organization.name
send_email_helper(
message_id = send_email_helper(
assignment.assigned_email,
code,
code_url,
organization_name,
assignment.contract.name,
)
if message_id:
# If we got a message ID from mailgun, we'll treat the message as sent
# If anything goes wrong after that, it'll come in as a webhook
# We are going to perform these saves as eagerly as possibly as there's technically
# a race condition between saving the message ID and webhooks coming in.
assignment.email_message_id = message_id
assignment.last_reminder_sent_on = now_in_utc()
assignment.save(update_fields=["email_message_id", "last_reminder_sent_on"])


def send_test_enrollment_code_assignment_email(email, contract_record_id):
contract = ContractPage.objects.get(pk=contract_record_id)
learn_hostname = get_learn_hostname()
send_email_helper(
email,
"PLACEHOLDER_CODE",
f"https://{learn_hostname}/enrollmentcode/PLACEHOLDER_CODE",
f"{settings.MIT_LEARN_ATTACH_URL}PLACEHOLDER_CODE",
contract.organization.name,
contract.name,
is_test=True,
)
Loading
Loading