Skip to content

ENG-3301: Refactor messaging dispatchers into provider classes#8118

Open
JadeCara wants to merge 19 commits intomainfrom
ENG-3301/messaging-provider-refactor-pr1
Open

ENG-3301: Refactor messaging dispatchers into provider classes#8118
JadeCara wants to merge 19 commits intomainfrom
ENG-3301/messaging-provider-refactor-pr1

Conversation

@JadeCara
Copy link
Copy Markdown
Contributor

@JadeCara JadeCara commented May 6, 2026

Ticket ENG-3301

PR Series

This is PR 1 of 3 in the messaging provider refactor:

PR Branch Description Status
This PR (PR1) messaging-provider-refactor-pr1 Extract 4 dispatchers into provider classes ✅ Review-ready
#8120 (PR2) messaging-provider-refactor-pr2-ses Migrate AWS SES to provider class ✅ Review-ready
#8138 (PR2.5) messaging-service-alignment Wire route endpoints through MessagingService ✅ In review

Base branch: main

Description Of Changes

Refactors 4 of 5 messaging dispatchers into provider classes behind a two-level ABC hierarchy. This is a pure structural refactor — no behavioral changes. AWS SES remains on the legacy dispatcher and will be migrated in a follow-up PR (#8120).

Key changes:

  • Introduces BaseMessageProviderService / BaseEmailProviderService / BaseSMSProviderService ABCs
  • Creates MailgunService, TwilioEmailService, MailchimpTransactionalService, TwilioSmsService provider classes
  • Refactors dispatch_message() to use a provider map instead of _get_dispatcher_from_config_type()
  • AWS SES falls back to the legacy _aws_ses_dispatcher (temporary, removed in ENG-3301: Migrate AWS SES dispatcher to provider class #8120)

Code Changes

  • New src/fides/api/service/messaging/messaging_providers/ package with ABCs and 4 provider classes
  • Rewired dispatch_message() to use _PROVIDER_MAP constant for provider lookup
  • Added legacy _aws_ses_dispatcher fallback for SES (removed in follow-up PR)
  • Deleted old _mailgun_dispatcher, _twilio_email_dispatcher, _twilio_sms_dispatcher, _mailchimp_transactional_dispatcher, _get_dispatcher_from_config_type, validate_config, _compose_twilio_mail, _get_template_id_if_exists functions
  • Updated mock targets in 8 test files

Steps to Confirm

1. Server starts cleanly

docker logs fides 2>&1 | grep -i traceback

Confirm no import errors from the new messaging provider modules.

2. Test Mailgun dispatch

Create config and send a test message:

PUT /api/v1/messaging/default/mailgun
{"domain": "<your-domain>", "api_version": "v3"}

PUT /api/v1/messaging/default/mailgun/secret
{"mailgun_api_key": "<your-key>"}

POST /api/v1/messaging/test/mailgun
{"email": "<your-email>"}

Verify 200 response and email received.

3. Test Twilio Email dispatch

PUT /api/v1/messaging/default/twilio_email
{"twilio_email_from": "<from-address>"}

PUT /api/v1/messaging/default/twilio_email/secret
{"twilio_api_key": "<your-key>"}

POST /api/v1/messaging/test/twilio_email
{"email": "<your-email>"}

Verify 200 response and email received.

4. Test Twilio SMS dispatch

PUT /api/v1/messaging/default/twilio_text
{}

PUT /api/v1/messaging/default/twilio_text/secret
{"twilio_account_sid": "<sid>", "twilio_auth_token": "<token>", "twilio_messaging_service_sid": "<msid>"}

POST /api/v1/messaging/test/twilio_text
{"phone_number": "<your-phone>"}

Verify 200 response and SMS received.

5. Error path — missing config

Delete any existing messaging config, then:

POST /api/v1/messaging/test/mailgun
{"email": "test@example.com"}

Verify a clear 400 error (not a 500/traceback).

Pre-Merge Checklist

  • Issue requirements met
  • All CI pipelines succeeded
  • CHANGELOG.md updated
    • Updates unreleased work already in Changelog, no new entry necessary
  • UX feedback:
    • No UX review needed
  • Followup issues:
    • Followup issues created
  • Database migrations:
    • No migrations
  • Documentation:
    • No documentation updates required

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 6, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
fides-plus-nightly Ignored Ignored Preview May 8, 2026 8:10pm
fides-privacy-center Ignored Ignored May 8, 2026 8:10pm

Request Review

Introduce BaseMessageProviderService / BaseEmailProviderService /
BaseSMSProviderService ABCs and migrate Mailgun, TwilioEmail,
MailchimpTransactional, and TwilioSMS dispatchers to provider classes.
AWS SES remains on the legacy dispatcher and will be migrated in a
follow-up PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 6, 2026

Codecov Report

❌ Patch coverage is 93.54839% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.34%. Comparing base (6d220c9) to head (0cea832).

Files with missing lines Patch % Lines
.../api/service/messaging/message_dispatch_service.py 84.21% 4 Missing and 2 partials ⚠️
...e/messaging/messaging_providers/mailgun_service.py 88.23% 3 Missing and 1 partial ⚠️
.../api/service/messaging/messaging_providers/base.py 94.28% 1 Missing and 1 partial ⚠️
...essaging/messaging_providers/twilio_sms_service.py 93.75% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8118      +/-   ##
==========================================
+ Coverage   85.23%   85.34%   +0.11%     
==========================================
  Files         638      643       +5     
  Lines       42011    42110      +99     
  Branches     4937     4947      +10     
==========================================
+ Hits        35808    35940     +132     
+ Misses       5096     5063      -33     
  Partials     1107     1107              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

JadeCara and others added 2 commits May 6, 2026 12:07
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SES is intentionally excluded from _resolve_provider_map() and
PROVIDER_MAP since it uses the legacy dispatcher until PR2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JadeCara JadeCara changed the title ENG-3301: Refactor messaging dispatchers into provider classes (4 of 5) ENG-3301: Refactor messaging dispatchers into provider classes May 6, 2026
JadeCara and others added 2 commits May 6, 2026 14:47
The erasure email integration tests were asserting the old dispatcher
function call pattern (3 positional args to constructor). Updated to
assert constructor called once + send_email called with (to, message).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SES is always email (never SMS), so message is always
EmailForActionType at this point. Mypy can't narrow the union.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread changelog/8118-messaging-provider-refactor.yaml Outdated
JadeCara and others added 4 commits May 6, 2026 14:57
_resolve_provider_map() is called directly in dispatch_message() and
in tests. The static PROVIDER_MAP served no purpose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same fix as the dynamic erasure email tests — update generic email
and attentive integration tests from old 3-arg dispatcher pattern to
constructor + send_email assertion pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JadeCara
Copy link
Copy Markdown
Contributor Author

JadeCara commented May 6, 2026

/code-review

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code Review: Messaging Provider Refactor (PR #8118)

This is a clean, well-scoped refactor. The two-level ABC hierarchy (BaseMessageProviderServiceBaseEmailProviderService / BaseSMSProviderService) is a logical structure, the test updates are thorough, and the decision to leave AWS SES as a legacy dispatcher with a clear follow-up note is reasonable.

Issues found

Bug (pre-existing, preserved): Two test functions in test_integration_dynamic_erasure_email.py (test_erasure_email_multiple_requests and test_erasure_email_multiple_requests_same_email_different_vendor) contain bare equality expressions instead of assert statements — they are silent no-ops and never actually validate mock call args. See inline comment.

Design concern: _resolve_provider_map() is rebuilt on every dispatch call specifically to accommodate test mocking. This is production code structured around test infrastructure. A module-level constant + standard mock.patch at the import path achieves the same test isolation without this coupling. See inline comment.

Type safety / ClassVar: provider_name: str on the base class is a bare annotation that type checkers and the runtime won't enforce. Should be ClassVar[str], and an __init__-time assertion or default would make missing definitions fail loudly at construction rather than silently in the error message. See inline comment.

KeyError vs MessageDispatchException: Across all four new provider __init__ methods, validation confirms details/secrets is truthy, then immediately subscripts the dict by key. A truthy-but-key-missing config escapes validation and raises a bare KeyError. This is pre-existing behavior from the old dispatchers but is worth fixing now that the structure makes it easy. See inline comment on MailchimpTransactionalService.

Minor test assertion inconsistency: "No identity" test cases assert mock_cls.return_value.send_email.assert_not_called() — this checks the instance method but not whether the constructor itself was called. Since these failures occur before instantiation, mock_cls.assert_not_called() is the more accurate assertion.

What's working well

  • The ABC hierarchy cleanly separates email and SMS concerns.
  • autospec=True on all the mocks is a nice improvement over the raw mock.patch in the original tests.
  • Renaming private helpers to static methods (_get_template_id_if_exists, _compose_mail) on TwilioEmailService is a better fit for their actual usage.
  • MailgunService.send_email correctly re-raises MessageDispatchException before the outer except Exception swallows it — a subtle correctness fix over the original.
  • The _aws_ses_dispatcher is kept as a named legacy function with a clear comment, making the follow-up PR's task obvious.

🔬 Codegraph: connected (49390 nodes)


💡 Write /code-review in a comment to re-run this review.

Comment thread src/fides/api/service/messaging/messaging_providers/base.py
Comment thread src/fides/api/service/messaging/message_dispatch_service.py Outdated
Comment thread tests/ops/integration_tests/test_integration_dynamic_erasure_email.py Outdated
Comment thread tests/ops/service/messaging/message_dispatch_service_test.py
Comment thread src/fides/api/service/messaging/message_dispatch_service.py
- Add ClassVar[str] + __init_subclass__ guard for provider_name on BaseMessageProviderService
- Replace _resolve_provider_map() with module-level _PROVIDER_MAP constant
- Add _get_detail(), _get_secret(), _get_optional_secret() helpers to base class
  to prevent KeyError leaking from misconfigured providers
- Convert all provider constructors to use base class helpers
- Refactor all tests (unit + integration) to mock external HTTP calls instead of
  provider classes — exercises full dispatch → construction → send path
- Add shared mock_mailgun_http fixture and helpers in messaging_fixtures.py
- Fix bare equality bugs (missing assert) in integration tests
- Add parametrized tests for wrong-key config validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JadeCara
Copy link
Copy Markdown
Contributor Author

JadeCara commented May 6, 2026

/code-review

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Code Review: Messaging Provider Refactor (#8118)

This is a solid structural improvement. Moving the dispatcher functions into a two-level ABC hierarchy (BaseMessageProviderService → BaseEmailProviderService / BaseSMSProviderService) makes the extension point much clearer than the previous function-map pattern, and the _PROVIDER_MAP dict is a clean replacement for _get_dispatcher_from_config_type. The test migration from @mock.patch on the internal dispatcher to requests_mock is a genuine improvement — tests now exercise the real HTTP layer rather than stopping at the boundary.

Issues Found

Test coverage regressions (most important)

Two unit tests in message_dispatch_service_test.pytest_email_dispatch_property_specific_templates_enabled_no_template and test_email_dispatch_property_specific_templates_enabled_with_template_no_property_default_request — now have zero assertions. They previously verified mock_mailgun_dispatcher.assert_not_called() to confirm that no email is dispatched when property-specific messaging is enabled but no matching template is found. After the refactor these tests pass trivially and no longer verify the intended behaviour. They need mock_mailgun_http added and assert not mock_mailgun_http.called.

Two integration tests in test_integration_dynamic_erasure_email.py (test_erasure_email_multiple_requests, test_erasure_email_multiple_requests_same_email_different_vendor) replace detailed call-argument assertions with only a POST-count check, leaving recipient and content correctness unverified. (The original assertions were also broken — missing assert — so the original tests were already not catching regressions, but the replacement is an opportunity to fix that properly.)

type: ignore on the AWS SES legacy path

The call to _aws_ses_dispatcher carries a # type: ignore[arg-type] because message is Union[EmailForActionType, str] at that point. A simple isinstance guard before the call would remove the suppression and make the code safer (see inline comment).

_get_detail / _get_secret return type annotation

Both helpers are annotated -> str but actually return whatever type is stored in the config dict. IS_EU_DOMAIN is stored as a boolean, which works at runtime with if is_eu, but the annotation is misleading and will confuse the next person adding a provider who expects a string. Consider -> Any or documenting the convention.

mailchimp_transactional_service.py unguarded response.json()[0]

Pre-existing, but now that the logic lives in an isolated class it's a good time to add a bounds check before indexing into the response list. An empty or malformed response currently raises an unhandled IndexError.

Nits

  • provider_name = "" would bypass the __init_subclass__ enforcement (falsy empty string); a minor hardening concern.
  • The TwilioSmsService.send_sms else branch raises MessageDispatchException inside a try/except TwilioRestException block — it propagates correctly, but a brief comment would help future maintainers avoid accidentally wrapping it.

Summary

The architecture is right and the approach is sound. The main ask before merging is to restore the two unit tests that lost their "not called" assertions, so the property-specific messaging guard is actually tested. The other items are lower priority.

🔬 Codegraph: connected (49390 nodes)


💡 Write /code-review in a comment to re-run this review.

Comment thread src/fides/api/service/messaging/messaging_providers/base.py Outdated
Comment thread src/fides/api/service/messaging/messaging_providers/base.py
Comment thread tests/ops/service/messaging/message_dispatch_service_test.py
Comment thread tests/ops/integration_tests/test_integration_dynamic_erasure_email.py Outdated
Comment thread src/fides/api/service/messaging/message_dispatch_service.py
JadeCara and others added 3 commits May 7, 2026 09:28
- Remove messaging_config/mock_mailgun_http from "no messaging config"
  tests (attentive, request_runner) — these tests validate the failure
  path when no config exists and should not have a config in the DB.

- Fix requeue call_count assertions in dynamic erasure email tests —
  the old code was a bare expression (call_count == 2) without assert,
  which silently passed. requeue_privacy_requests_after_email_send is
  called once per batch with a query containing all privacy requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix _get_detail return type annotation (-> str | bool) for IS_EU_DOMAIN
- Guard against empty Mailchimp Transactional response before indexing
- Add isinstance guard on AWS SES path, removing type: ignore
- Restore assertions on two property-specific messaging tests
- Add recipient/subject content assertions to multi-request erasure tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add send_email tests for MailchimpTransactionalService (success,
  HTTP failure, rejected, empty response, no secrets)
- Add send_email tests for TwilioEmailService (no template, with
  template, failure)
- Extract provider tests into per-provider test files:
  test_mailchimp_transactional_service.py,
  test_twilio_email_service.py, test_twilio_sms_service.py
- Keep dispatch_message integration tests and config validation
  in the original message_dispatch_service_test.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JadeCara JadeCara marked this pull request as ready for review May 7, 2026 17:47
@JadeCara JadeCara requested a review from a team as a code owner May 7, 2026 17:47
@JadeCara JadeCara removed the request for review from a team May 7, 2026 17:47
@JadeCara JadeCara requested a review from galvana May 7, 2026 17:47
@JadeCara JadeCara requested review from erosselli and removed request for galvana May 7, 2026 17:47
JadeCara and others added 2 commits May 7, 2026 11:54
- Mailgun: generic exception (ConnectionError → MessageDispatchException)
- Twilio Email: generic exception (ConnectionError → MessageDispatchException)
- Twilio SMS: success path + TwilioRestException handling
- Base class: __init_subclass__ guard for missing provider_name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…:ethyca/fides into ENG-3301/messaging-provider-refactor-pr1
Comment on lines +24 to +35
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
# Skip intermediate ABCs that define their own abstract methods
has_abstract = any(
getattr(v, "__isabstractmethod__", False) for v in cls.__dict__.values()
)
if not has_abstract and not getattr(cls, "provider_name", None):
raise TypeError(f"{cls.__name__} must define 'provider_name'")

def __init__(self, messaging_config: MessagingConfig):
self.messaging_config = messaging_config
self.validate_config()
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.

instead of overriding __init_subclass__ and having to check that the class isn't abstract etc, can't we just add an assertion in __init__ ? Something like:

Suggested change
def __init_subclass__(cls, **kwargs: object) -> None:
super().__init_subclass__(**kwargs)
# Skip intermediate ABCs that define their own abstract methods
has_abstract = any(
getattr(v, "__isabstractmethod__", False) for v in cls.__dict__.values()
)
if not has_abstract and not getattr(cls, "provider_name", None):
raise TypeError(f"{cls.__name__} must define 'provider_name'")
def __init__(self, messaging_config: MessagingConfig):
self.messaging_config = messaging_config
self.validate_config()
def __init__(self, messaging_config: MessagingConfig):
assert self.provider_name, "BaseMessageProviderService must define provider_name"
self.messaging_config = messaging_config
self.validate_config()

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.

there's also a problem with the existing code that because it only looks at the class' __dict__, an intermediate ABC that inherits abstract methods without declaring new ones would incorrectly trigger the provider_name guard.

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.

Done — removed __init_subclass__ and moved the guard to __init__. Used if not ... raise TypeError instead of assert since asserts are stripped by python -O. Good catch on the intermediate ABC issue with __dict__.

Comment on lines +62 to +64
with requests_mock.Mocker() as m:
m.get(template_url, status_code=404)
m.post(send_url, json={"message": "Queued"}, status_code=200)
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.

can we not use your mock_mailgun_http fixture for the tests on this file?

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.

Done — switched to mock_mailgun_http for all standard Mailgun tests in this file. The one exception is test_email_dispatch_mailgun_failed_email which needs a custom 403 response, so it keeps inline mocking. Also updated TestInitSubclassGuardTestInitGuard to reflect the guard moving to __init__.

BaseEmailProviderService,
)

EMAIL_TEMPLATE_NAME = "fides"
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.

both mailgun and twilio email define this, maybe we should have this be a constant in the base file and import it from both ?

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.

Done — moved EMAIL_TEMPLATE_NAME to base.py and imported from there in both providers.

logger.error(f"Message failed to send. {error_message}")
raise MessageDispatchException(error_message)

def _get_detail(self, key: MessagingServiceDetails) -> str | bool:
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 may be overkill, but str | bool is a bit of a weird type, given that there's only a single detail key that returns bool.... I wonder if we could use function signature overloads like

 @overload       
  def _get_detail(self, key: Literal[MessagingServiceDetails.IS_EU_DOMAIN]) -> bool: ...
                                                                                                                                                                                                                                                    
  @overload
  def _get_detail(self, key: MessagingServiceDetails) -> str: ...         

or if this is too complicated, then maybe we could inline the bool look up in the mailgun class and then keep _get_detail as always returning str ?

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.

Done — inlined the IS_EU_DOMAIN lookup in MailgunService and kept _get_detail as -> str. Simpler than overloads for a single bool key. mypy passes clean on all changed files.

- Replace __init_subclass__ guard with simpler __init__ check
- Use mock_mailgun_http fixture in dispatch service tests
- Move shared EMAIL_TEMPLATE_NAME constant to base module
- Keep _get_detail return type as str, inline IS_EU_DOMAIN bool lookup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@erosselli erosselli left a comment

Choose a reason for hiding this comment

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

thanks for making the changes! I tested with mailgun and got emails as normal

JadeCara and others added 2 commits May 8, 2026 14:02
Covers previously untested guard clauses:
- AWS SES rejects non-email message body
- Unknown service type raises when not in provider map
- Email provider rejects str body
- SMS provider rejects EmailForActionType body

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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