Skip to content

Commit 2f9f5e5

Browse files
JadeCaraclaudeerosselli
authored
ENG-3301: Add threading header support to email providers (Story 2) (#8122)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Eliana Rosselli <67162025+erosselli@users.noreply.github.com>
1 parent c82abbc commit 2f9f5e5

9 files changed

Lines changed: 480 additions & 23 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
type: Added
2+
description: Added threading header support (Reply-To, Message-ID, In-Reply-To, References) and plaintext alternative to all email providers
3+
pr: 8122
4+
labels: []

src/fides/api/schemas/messaging/messaging.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,14 @@ class EmailForActionType(BaseModel):
309309
"""
310310

311311
subject: str
312-
body: str
312+
body: str # HTML body
313313
template_variables: Optional[Dict[str, Any]] = {}
314+
# Threading / envelope fields (all optional, backward compatible)
315+
reply_to: str | None = None
316+
message_id: str | None = None # RFC 5322 Message-ID
317+
in_reply_to: str | None = None
318+
references: str | None = None
319+
body_text: str | None = None # plaintext alternative for multipart/alternative
314320

315321

316322
class MessagingServiceDetails(Enum):

src/fides/api/service/messaging/messaging_providers/aws_ses_service.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ def get_identity_verification_attributes(
2929
self, Identities: list[str]
3030
) -> dict[str, dict[str, dict[str, str]]]: ...
3131

32-
def send_email(
32+
def send_raw_email(
3333
self,
3434
Source: str,
35-
Destination: dict[str, list[str]],
36-
Message: dict[str, Any],
37-
) -> None: ...
35+
Destinations: list[str],
36+
RawMessage: dict[str, bytes],
37+
) -> dict[str, Any]: ...
3838

3939

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

135135
def send_email(self, to: str, message: EmailForActionType) -> None:
136-
"""Send an email using AWS SES simple API.
136+
"""Send an email using AWS SES raw API for custom header support.
137137
138-
Does NOT call validate_email_and_domain_status() — that is done at
139-
config save/test time. SES rejects sends from unverified identities.
138+
Builds a MIME message using ``email.message.EmailMessage`` (modern
139+
Python 3.6+ API).
140140
"""
141141
ses_client = self.get_ses_client()
142142

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

151151
try:
152-
ses_client.send_email(
152+
msg = self.build_mime(from_address, to.strip(), message)
153+
ses_client.send_raw_email(
153154
Source=from_address,
154-
Destination={"ToAddresses": [to.strip()]},
155-
Message={
156-
"Subject": {"Data": message.subject},
157-
"Body": {"Html": {"Data": message.body}},
158-
},
155+
Destinations=[to.strip()],
156+
RawMessage={"Data": msg.as_bytes()},
159157
)
158+
except MessageDispatchException:
159+
raise
160160
except Exception as exc:
161161
logger.error("Email failed to send: {}", str(exc))
162162
raise MessageDispatchException(

src/fides/api/service/messaging/messaging_providers/base.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from abc import ABC, abstractmethod
2+
from email import policy as email_policy
3+
from email.message import EmailMessage
24
from typing import ClassVar
35

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

83+
HEADER_REPLY_TO = "Reply-To"
84+
HEADER_MESSAGE_ID = "Message-ID"
85+
HEADER_IN_REPLY_TO = "In-Reply-To"
86+
HEADER_REFERENCES = "References"
87+
8188
@abstractmethod
8289
def send_email(self, to: str, message: EmailForActionType) -> None: ...
8390

91+
@staticmethod
92+
def get_threading_headers(
93+
message: EmailForActionType, header_prefix: str = ""
94+
) -> dict[str, str]:
95+
"""Return non-None threading headers from the message."""
96+
candidates = {
97+
BaseEmailProviderService.HEADER_REPLY_TO: message.reply_to,
98+
BaseEmailProviderService.HEADER_MESSAGE_ID: message.message_id,
99+
BaseEmailProviderService.HEADER_IN_REPLY_TO: message.in_reply_to,
100+
BaseEmailProviderService.HEADER_REFERENCES: message.references,
101+
}
102+
return {f"{header_prefix}{k}": v for k, v in candidates.items() if v}
103+
104+
@staticmethod
105+
def build_mime(
106+
from_address: str, to: str, message: EmailForActionType
107+
) -> EmailMessage:
108+
"""Build a MIME EmailMessage with optional threading headers.
109+
110+
Reusable by any provider that sends raw MIME (e.g., SES, SMTP).
111+
"""
112+
msg = EmailMessage(policy=email_policy.SMTP)
113+
msg["From"] = from_address
114+
msg["To"] = to
115+
msg["Subject"] = message.subject
116+
117+
for header, value in BaseEmailProviderService.get_threading_headers(
118+
message
119+
).items():
120+
msg[header] = value
121+
122+
if message.body_text:
123+
# The RFC 2046 multipart/alternative spec says parts should be
124+
# ordered from simplest to richest. The email client picks the
125+
# richest part it can render successfully.
126+
msg.set_content(message.body_text)
127+
msg.add_alternative(message.body, subtype="html")
128+
else:
129+
msg.set_content(message.body, subtype="html")
130+
131+
return msg
132+
84133

85134
class BaseSMSProviderService(BaseMessageProviderService):
86135
"""Base class for SMS provider services."""

src/fides/api/service/messaging/messaging_providers/mailchimp_transactional_service.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,29 @@ def __init__(self, messaging_config: MessagingConfig):
2828
)
2929

3030
def send_email(self, to: str, message: EmailForActionType) -> None:
31+
msg_payload: dict = {
32+
"from_email": self.from_email,
33+
"subject": message.subject,
34+
"html": message.body,
35+
"to": [{"email": to.strip(), "type": "to"}],
36+
}
37+
38+
# Reply-to uses Mandrill's native field
39+
if message.reply_to:
40+
msg_payload["reply_to"] = message.reply_to
41+
42+
# Threading headers (exclude Reply-To — handled natively above)
43+
threading_headers = self.get_threading_headers(message)
44+
threading_headers.pop(BaseEmailProviderService.HEADER_REPLY_TO, None)
45+
if threading_headers:
46+
msg_payload["headers"] = threading_headers
47+
if message.body_text:
48+
msg_payload["text"] = message.body_text
49+
3150
data = json.dumps(
3251
{
3352
"key": self.api_key,
34-
"message": {
35-
"from_email": self.from_email,
36-
"subject": message.subject,
37-
"html": message.body,
38-
"to": [{"email": to.strip(), "type": "to"}],
39-
},
53+
"message": msg_payload,
4054
}
4155
)
4256

src/fides/api/service/messaging/messaging_providers/mailgun_service.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ def send_email(self, to: str, message: EmailForActionType) -> None:
5757
else:
5858
data["html"] = message.body
5959

60+
# Threading / envelope headers
61+
data.update(self.get_threading_headers(message, header_prefix="h:"))
62+
if message.body_text:
63+
data["text"] = message.body_text
64+
6065
response = requests.post(
6166
f"{self.base_url}/{self.api_version}/{self.domain}/messages",
6267
auth=("api", self.api_key),

src/fides/api/service/messaging/messaging_providers/twilio_email_service.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22

33
import sendgrid
44
from loguru import logger
5-
from sendgrid.helpers.mail import Content, Email, Mail, Personalization, TemplateId, To
5+
from sendgrid.helpers.mail import (
6+
Content,
7+
Email,
8+
Header,
9+
Mail,
10+
Personalization,
11+
ReplyTo,
12+
TemplateId,
13+
To,
14+
)
615

716
from fides.api.common_exceptions import MessageDispatchException
817
from fides.api.models.messaging import MessagingConfig
@@ -41,9 +50,21 @@ def send_email(self, to: str, message: EmailForActionType) -> None:
4150
from_email = Email(self.from_email)
4251
to_email = To(to.strip())
4352
mail = self._compose_mail(
44-
from_email, to_email, message.subject, message.body, template_id
53+
from_email,
54+
to_email,
55+
message.subject,
56+
message.body,
57+
template_id,
58+
body_text=message.body_text,
4559
)
4660

61+
# Threading / envelope headers
62+
for key, value in self.get_threading_headers(message).items():
63+
if key == BaseEmailProviderService.HEADER_REPLY_TO:
64+
mail.reply_to = ReplyTo(value)
65+
else:
66+
mail.header = Header(key, value)
67+
4768
response = sg.client.mail.send.post(request_body=mail.get())
4869
if response.status_code >= 400:
4970
logger.error(
@@ -77,15 +98,26 @@ def _compose_mail(
7798
subject: str,
7899
message_body: str,
79100
template_id: str | None = None,
101+
body_text: str | None = None,
80102
) -> Mail:
81-
"""Composes a SendGrid Mail object, using a template if one exists."""
103+
"""Composes a SendGrid Mail object, using a template if one exists.
104+
105+
When body_text is provided, builds multipart/alternative with text/plain
106+
before text/html (RFC 2046: last part is most preferred).
107+
"""
82108
if template_id:
83109
mail = Mail(from_email=from_email, subject=subject)
84110
mail.template_id = TemplateId(template_id)
85111
personalization = Personalization()
86112
personalization.dynamic_template_data = {"fides_email_body": message_body}
87113
personalization.add_email(to_email)
88114
mail.add_personalization(personalization)
115+
elif body_text:
116+
mail = Mail(from_email=from_email, subject=subject)
117+
mail.add_personalization(Personalization())
118+
mail.personalizations[0].add_email(to_email)
119+
mail.content = Content("text/plain", body_text)
120+
mail.content = Content("text/html", message_body)
89121
else:
90122
content = Content("text/html", message_body)
91123
mail = Mail(from_email, to_email, subject, content)

0 commit comments

Comments
 (0)