Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ddbff34
ENG-3301: Refactor messaging dispatchers into provider classes (4 of 5)
JadeCara May 6, 2026
ce9bc14
Fix ruff import ordering and rename changelog to match PR #8118
JadeCara May 6, 2026
57727f6
Add clarifying comments for incomplete provider map in PR1
JadeCara May 6, 2026
5cd7e8e
ENG-3301: Migrate AWS SES dispatcher to provider class (5 of 5)
JadeCara May 6, 2026
aa85c56
Add changelog entry for PR #8120
JadeCara May 6, 2026
b92ef93
Remove stale AWS_SES_Service import and update provider map comments
JadeCara May 6, 2026
9a67b75
ENG-3301: Add threading header support to email providers (Story 2)
JadeCara May 6, 2026
8433b68
Add changelog entry for PR #8122
JadeCara May 6, 2026
f07ce63
Fix ruff formatting in test_provider_headers.py
JadeCara May 6, 2026
7122b08
Merge branch 'main' into ENG-3301/messaging-provider-refactor-story2
JadeCara May 11, 2026
1dd76b2
fixed changelog
JadeCara May 11, 2026
da7dac8
Address PR review feedback
JadeCara May 11, 2026
00dc2a9
Fix SendGrid body_text: Mail.contents has no setter
JadeCara May 11, 2026
a026b88
Fix Mailchimp header test for native reply_to field
JadeCara May 11, 2026
4691b97
Update changelog/8122-threading-header-support.yaml
JadeCara May 11, 2026
d6c47e1
Update src/fides/api/service/messaging/messaging_providers/aws_ses_se…
JadeCara May 11, 2026
bb052c2
Address PR review feedback
JadeCara May 11, 2026
cfe132a
Address PR review feedback
JadeCara May 12, 2026
1952a2c
Merge branch 'main' into ENG-3301/messaging-provider-refactor-story2
JadeCara May 12, 2026
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
4 changes: 4 additions & 0 deletions changelog/8122-threading-header-support.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: Added
description: Added threading header support (Reply-To, Message-ID, In-Reply-To, References) and plaintext alternative to all email providers
pr: 8122
labels: []
8 changes: 7 additions & 1 deletion src/fides/api/schemas/messaging/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,14 @@ class EmailForActionType(BaseModel):
"""

subject: str
body: str
body: str # HTML body
template_variables: Optional[Dict[str, Any]] = {}
# Threading / envelope fields (all optional, backward compatible)
reply_to: str | None = None
Comment thread
JadeCara marked this conversation as resolved.
message_id: str | None = None # RFC 5322 Message-ID
in_reply_to: str | None = None
references: str | None = None
body_text: str | None = None # plaintext alternative for multipart/alternative


class MessagingServiceDetails(Enum):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ def get_identity_verification_attributes(
self, Identities: list[str]
) -> dict[str, dict[str, dict[str, str]]]: ...

def send_email(
def send_raw_email(
self,
Source: str,
Comment thread
JadeCara marked this conversation as resolved.
Destination: dict[str, list[str]],
Message: dict[str, Any],
) -> None: ...
Destinations: list[str],
RawMessage: dict[str, bytes],
) -> dict[str, Any]: ...


def _sanitize_aws_error(exc: Exception) -> str:
Expand Down Expand Up @@ -133,10 +133,10 @@ def validate_on_save(self) -> None:
raise MessageDispatchException(f"{identity} is not verified in SES.")

def send_email(self, to: str, message: EmailForActionType) -> None:
"""Send an email using AWS SES simple API.
"""Send an email using AWS SES raw API for custom header support.

Does NOT call validate_email_and_domain_status() — that is done at
config save/test time. SES rejects sends from unverified identities.
Builds a MIME message using ``email.message.EmailMessage`` (modern
Python 3.6+ API).
"""
ses_client = self.get_ses_client()

Expand All @@ -149,14 +149,14 @@ def send_email(self, to: str, message: EmailForActionType) -> None:
from_address = f"noreply@{self.details.domain}"

try:
ses_client.send_email(
msg = self.build_mime(from_address, to.strip(), message)
ses_client.send_raw_email(
Source=from_address,
Comment thread
JadeCara marked this conversation as resolved.
Destination={"ToAddresses": [to.strip()]},
Message={
"Subject": {"Data": message.subject},
"Body": {"Html": {"Data": message.body}},
},
Destinations=[to.strip()],
RawMessage={"Data": msg.as_bytes()},
)
except MessageDispatchException:
raise
except Exception as exc:
logger.error("Email failed to send: {}", str(exc))
raise MessageDispatchException(
Expand Down
49 changes: 49 additions & 0 deletions src/fides/api/service/messaging/messaging_providers/base.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from abc import ABC, abstractmethod
from email import policy as email_policy
from email.message import EmailMessage
from typing import ClassVar

from loguru import logger
Expand Down Expand Up @@ -78,9 +80,56 @@ def _get_optional_secret(self, key: MessagingServiceSecrets) -> str | None:
class BaseEmailProviderService(BaseMessageProviderService):
"""Base class for email provider services."""

HEADER_REPLY_TO = "Reply-To"
HEADER_MESSAGE_ID = "Message-ID"
HEADER_IN_REPLY_TO = "In-Reply-To"
HEADER_REFERENCES = "References"

@abstractmethod
def send_email(self, to: str, message: EmailForActionType) -> None: ...

@staticmethod
def get_threading_headers(
message: EmailForActionType, header_prefix: str = ""
) -> dict[str, str]:
"""Return non-None threading headers from the message."""
candidates = {
BaseEmailProviderService.HEADER_REPLY_TO: message.reply_to,
BaseEmailProviderService.HEADER_MESSAGE_ID: message.message_id,
BaseEmailProviderService.HEADER_IN_REPLY_TO: message.in_reply_to,
BaseEmailProviderService.HEADER_REFERENCES: message.references,
}
return {f"{header_prefix}{k}": v for k, v in candidates.items() if v}

@staticmethod
def build_mime(
from_address: str, to: str, message: EmailForActionType
) -> EmailMessage:
"""Build a MIME EmailMessage with optional threading headers.

Reusable by any provider that sends raw MIME (e.g., SES, SMTP).
"""
msg = EmailMessage(policy=email_policy.SMTP)
msg["From"] = from_address
msg["To"] = to
msg["Subject"] = message.subject

for header, value in BaseEmailProviderService.get_threading_headers(
message
).items():
msg[header] = value

if message.body_text:
# The RFC 2046 multipart/alternative spec says parts should be
# ordered from simplest to richest. The email client picks the
# richest part it can render successfully.
msg.set_content(message.body_text)
msg.add_alternative(message.body, subtype="html")
else:
msg.set_content(message.body, subtype="html")
Comment on lines +122 to +129
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.

Maybe we should include a commen on why we prefer raw text over html?

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 — added a comment explaining the RFC 2046 multipart/alternative ordering convention.


return msg


class BaseSMSProviderService(BaseMessageProviderService):
"""Base class for SMS provider services."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,29 @@ def __init__(self, messaging_config: MessagingConfig):
)

def send_email(self, to: str, message: EmailForActionType) -> None:
msg_payload: dict = {
"from_email": self.from_email,
"subject": message.subject,
"html": message.body,
"to": [{"email": to.strip(), "type": "to"}],
}

# Reply-to uses Mandrill's native field
if message.reply_to:
msg_payload["reply_to"] = message.reply_to

# Threading headers (exclude Reply-To — handled natively above)
threading_headers = self.get_threading_headers(message)
threading_headers.pop(BaseEmailProviderService.HEADER_REPLY_TO, None)
if threading_headers:
msg_payload["headers"] = threading_headers
if message.body_text:
msg_payload["text"] = message.body_text
Comment on lines +31 to +48
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 _build_mime here? if not, is there common logic we could extract? e.g the headers

def get_headers(message):
        headers = {}
        if message.reply_to:
            headers["Reply-To"] = message.reply_to
        if message.message_id:
            headers["Message-ID"] = message.message_id
        if message.in_reply_to:
            headers["In-Reply-To"] = message.in_reply_to
        if message.references:
            headers["References"] = message.references

return headers

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 — extracted get_threading_headers() on BaseEmailProviderService. Mailchimp uses it for the headers dict; reply_to stays on the native Mandrill field.


data = json.dumps(
{
"key": self.api_key,
"message": {
"from_email": self.from_email,
"subject": message.subject,
"html": message.body,
"to": [{"email": to.strip(), "type": "to"}],
},
"message": msg_payload,
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ def send_email(self, to: str, message: EmailForActionType) -> None:
else:
data["html"] = message.body

# Threading / envelope headers
data.update(self.get_threading_headers(message, header_prefix="h:"))
if message.body_text:
data["text"] = message.body_text

response = requests.post(
f"{self.base_url}/{self.api_version}/{self.domain}/messages",
auth=("api", self.api_key),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

import sendgrid
from loguru import logger
from sendgrid.helpers.mail import Content, Email, Mail, Personalization, TemplateId, To
from sendgrid.helpers.mail import (
Content,
Email,
Header,
Mail,
Personalization,
ReplyTo,
TemplateId,
To,
)

from fides.api.common_exceptions import MessageDispatchException
from fides.api.models.messaging import MessagingConfig
Expand Down Expand Up @@ -41,9 +50,21 @@ def send_email(self, to: str, message: EmailForActionType) -> None:
from_email = Email(self.from_email)
to_email = To(to.strip())
mail = self._compose_mail(
from_email, to_email, message.subject, message.body, template_id
from_email,
to_email,
message.subject,
message.body,
template_id,
body_text=message.body_text,
)

# Threading / envelope headers
for key, value in self.get_threading_headers(message).items():
if key == BaseEmailProviderService.HEADER_REPLY_TO:
mail.reply_to = ReplyTo(value)
else:
mail.header = Header(key, value)

response = sg.client.mail.send.post(request_body=mail.get())
if response.status_code >= 400:
logger.error(
Expand Down Expand Up @@ -77,15 +98,26 @@ def _compose_mail(
subject: str,
message_body: str,
template_id: str | None = None,
body_text: str | None = None,
) -> Mail:
"""Composes a SendGrid Mail object, using a template if one exists."""
"""Composes a SendGrid Mail object, using a template if one exists.

When body_text is provided, builds multipart/alternative with text/plain
before text/html (RFC 2046: last part is most preferred).
"""
if template_id:
mail = Mail(from_email=from_email, subject=subject)
mail.template_id = TemplateId(template_id)
personalization = Personalization()
personalization.dynamic_template_data = {"fides_email_body": message_body}
personalization.add_email(to_email)
mail.add_personalization(personalization)
elif body_text:
mail = Mail(from_email=from_email, subject=subject)
mail.add_personalization(Personalization())
mail.personalizations[0].add_email(to_email)
mail.content = Content("text/plain", body_text)
mail.content = Content("text/html", message_body)
Comment on lines +119 to +120
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.

does this actually append both contents?

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.

Yes — the Mail.content setter appends (calls add_content() internally), so both parts are included. This builds a multipart/alternative with text/plain and text/html, same as build_mime() in the base class.

If body_text isn't populated, it just sends HTML like before — no behavior change. The plaintext path is for recipients whose email client or server prefers plain text — Fides just provides both options in the envelope so whatever's on the other end can pick. Examples:

  • Accessibility-focused email setups
  • Corporate environments with strict HTML filtering
  • Plain-text-preferred mail clients

else:
content = Content("text/html", message_body)
mail = Mail(from_email, to_email, subject, content)
Expand Down
Loading
Loading