Skip to content

B2B Enrollment email deliverability indications via webhooks - #3775

Open
dsubak wants to merge 42 commits into
mainfrom
dansubak/202607_add_email_status_fields
Open

B2B Enrollment email deliverability indications via webhooks#3775
dsubak wants to merge 42 commits into
mainfrom
dansubak/202607_add_email_status_fields

Conversation

@dsubak

@dsubak dsubak commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

https://github.com/mitodl/hq/issues/12114

Description (What does it do?)

At a high level, this PR has:

  • DiscountContractAttachmentRecord model changes to support linking events to messages via mailgun's provided message ID, and fields to save relevant information from that event.
  • An endpoint which can receive Mailgun event webhooks and enqueue processing tasks.
  • A celery task which persists the event status and timestamp to a DiscountContractAttachmentRecord via mailgun's message ID.
  • Tags for enrollment code emails sent through Mailgun via the B2B admin dashboard to enable filtering out events we don't care about.
  • Serializer changes to display event statuses and timestamps on the /codes and related APIs
  • A small number of changes to existing endpoints to handle event lifecycle (notably in the remind and reassign API endpoints)
  • A script to generate synthetic webhooks for local testing

Screenshots (if appropriate):

After populating a synthetic delivered event via the local webhook endpoint w/ signature validation disabled.
Screenshot 2026-07-27 at 3 58 03 PM

How can this be tested?

Webhook status persistence

Included in this PR is a small test script to enable rudimentary testing without the need to set up a mailgun account or webhook. This is probably the most reasonable way to approach testing this functionality.

  • Set signature validation to be disabled.
  • Ensure you've got a contract which can be managed via the b2b admin dash.
  • Using the admin dashboard, assign a code to any email address. Get the ID of the corresponding DiscountContractAttachmentRedemption record.
    • Note, you don't need to actually send an email here if it's not set up as we're faking the webhook round trip.
  • Run ./manage.py b2b_send_mailgun_webhook <record_id> <email_event_status> --host='http://<local_mitxonline_host>:<port>'
    • You can specify any of the values in EMAIL_STATUSES as the status. We don't make any meaningful differentiation between them at the moment, though we may in the future.
  • Observe by hitting the /codes endpoint for the corresponding contract that the code assigned now has an email_status and timestamp populated.

Status reset on reassignment/resending

  • Perform the above steps, making sure not to actually redeem the code.
  • In the admin dash, send a reminder or reassign the pending invite.
  • Observe via /codes that the email status and timestamp are cleared.

Additional Context

I did some initial testing with a mailgun RC domain webhook by exposing the newly created endpoint via ngrok. This, frankly, sucks - exposing the endpoint alone is annoying, but even then you have to go through the rigamarole of spoofing your local data to have DCARs with matching message IDs before the webhook comes in since you're sending from RC.

To wit, I am providing a small script which sends synthetic webhooks against the endpoint for use in testing. Obviously this is a bit less representative, but the payload structure should be consistent and stable, so it should be sufficient for local testing (for now at least).

I am not a fan of how large this PR has become, but it's more or less the minimum amount for anything like an end-to-end test. If folks would prefer to review it in parts, I'm happy to break this down into more independently shippable parts - it just won't be meaningfully testable until several have landed.

Checklist:

  • PR to provision mailgun webhook signing secrets in ol-infra
  • Figure out task delegation
  • Webhook endpoint
  • Delete email deliverability bits from DCAR at appropriate times (reassign, remind)
  • Determine if we want a unique constraint on message ID
  • Small script to punch synthetic events into webhook endpoint for specified DCAR assignment records (far too annoying to test w/o a mailgun acct otherwise)
  • setting to control whether or not we validate signatures for local testing.
  • Finalize webhook endpoint location
  • Find out idiomatic way to validate payload is json in DRF.
  • Change test message to not send mailgun tag. That way we can easily filter it out.
  • E2E tests for new functionality

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
12 changes: 0 error, 0 warning, 12 info
info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/
		added the required property `results/items/email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/
		added the required property `results/items/email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/bulk_assign/
		added the required property `assigned/items/email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/bulk_assign/
		added the required property `assigned/items/email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/assign/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/assign/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API PUT /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/reassign/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API PUT /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/reassign/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/remind/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/remind/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API DELETE /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/revoke/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v0.yaml
	in API DELETE /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/revoke/
		added the required property `email_status_event_timestamp` to the response with the `200` status



## Changes for v1.yaml:
12 changes: 0 error, 0 warning, 12 info
info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/
		added the required property `results/items/email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/
		added the required property `results/items/email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/bulk_assign/
		added the required property `assigned/items/email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/bulk_assign/
		added the required property `assigned/items/email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/assign/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/assign/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API PUT /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/reassign/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API PUT /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/reassign/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/remind/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/remind/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API DELETE /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/revoke/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v1.yaml
	in API DELETE /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/revoke/
		added the required property `email_status_event_timestamp` to the response with the `200` status



## Changes for v2.yaml:
12 changes: 0 error, 0 warning, 12 info
info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/
		added the required property `results/items/email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/
		added the required property `results/items/email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/bulk_assign/
		added the required property `assigned/items/email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/bulk_assign/
		added the required property `assigned/items/email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/assign/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/assign/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API PUT /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/reassign/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API PUT /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/reassign/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/remind/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API POST /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/remind/
		added the required property `email_status_event_timestamp` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API DELETE /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/revoke/
		added the required property `email_status` to the response with the `200` status

info	[response-required-property-added] at head/openapi/specs/v2.yaml
	in API DELETE /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/{code}/revoke/
		added the required property `email_status_event_timestamp` to the response with the `200` status



Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@dsubak
dsubak force-pushed the dansubak/202607_add_email_status_fields branch from c78581d to e0f8f9c Compare July 22, 2026 14:30
@dsubak dsubak changed the title Dansubak/202607 add email status fields B2B Enrollment email deliverability indications via webhooks Jul 22, 2026
@gitguardian

gitguardian Bot commented Jul 28, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
9430286 Triggered Generic Password 81caefd .github/workflows/ci.yml View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@dsubak
dsubak marked this pull request as ready for review July 28, 2026 18:36
Comment thread b2b/serializers/v0/manager.py
Comment thread b2b/api.py
Comment thread b2b/views/v0/manager.py
# Just send the email reminder and update the last sent timestamp
queue_send_enrollment_code_assignment_email.delay([assignment_record.id])
assignment_record.last_reminder_sent_on = now_in_utc()
clear_assignment_email_deliverability_fields(assignment_record)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This used to set last_reminder_sent_on = now_in_utc() here, but now it just clears the deliverability fields and that timestamp only gets set later when the send task runs. So the response we return right below still has the old last_sent value (or null if it was never sent), and the comment above about updating the last sent timestamp isn't true anymore.

If the dashboard shows a "reminder sent" time after this call, it'll be stale until the task finishes and the manager refetches. Could we set last_reminder_sent_on = now_in_utc() here too so the response reflects the click right away?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmmm, my rationale for moving it to the task is that this as populated field is currently a bit misleading - it's entirely possible that we kick out the task to send and the mailgun API call outright fails, or the task is super super delayed or something. If that occurs, right now it looks like you sent a reminder to someone but in reality the learner didn't get any email. It's "more accurate" to have it set only once the task runs to completion.

That said, the staleness thing is a straight up oversight on my part - I definitely meant for that helper to also clear last_reminder_sent_on. That'd result in the the response here showing None until the task runs and actually sends it.

Maybe what we could do is leave last_reminder_sent_on set only at the API layer, but indicate to the user that a task is "Pending" or something until we get an accepted webhook from mailgun? Even that's not quite accurate in the event of an outright API call failure, but it's more accurate in the case of a task processing delay. I wasn't planning on configuring accepted webhooks since it's typically a short lived/uninteresting state for users, but it's easy enough to do if we think this is a decent solve. Thoughts?

Comment thread b2b/api.py Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants