-
Notifications
You must be signed in to change notification settings - Fork 90
ENG-3301: Add threading header support to email providers (Story 2) #8122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ddbff34
ce9bc14
57727f6
5cd7e8e
aa85c56
b92ef93
9a67b75
8433b68
f07ce63
7122b08
1dd76b2
da7dac8
00dc2a9
a026b88
4691b97
d6c47e1
bb052c2
cfe132a
1952a2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: [] |
| 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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.""" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we not use 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — extracted |
||
|
|
||
| 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, | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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( | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this actually append both contents?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes — the If
|
||
| else: | ||
| content = Content("text/html", message_body) | ||
| mail = Mail(from_email, to_email, subject, content) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.