This is an automated email from the ASF dual-hosted git repository.
vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a22fcb9a67b Stop importing airflow.utils.email in the Amazon SES hook
(#75711)
a22fcb9a67b is described below
commit a22fcb9a67b7fe5c651dfc34e07688c35fb135f4
Author: Shivam Rastogi <[email protected]>
AuthorDate: Tue Oct 27 07:06:43 2026 -0700
Stop importing airflow.utils.email in the Amazon SES hook (#75711)
---
.../src/airflow/providers/amazon/aws/hooks/ses.py | 80 +++++++++++++++++++++-
.../amazon/tests/unit/amazon/aws/hooks/test_ses.py | 66 +++++++++++++++++-
2 files changed, 142 insertions(+), 4 deletions(-)
diff --git a/providers/amazon/src/airflow/providers/amazon/aws/hooks/ses.py
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/ses.py
index d418e13749e..0c47113479f 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/ses.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/ses.py
@@ -18,11 +18,85 @@
from __future__ import annotations
+import os
+import re
from collections.abc import Iterable
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.utils import formatdate
from typing import Any
from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook
-from airflow.utils.email import build_mime_message
+
+
+def _get_email_address_list(addresses: str | Iterable[str]) -> list[str]:
+ """
+ Return a list of email addresses; a string may separate them with ``,`` or
``;``.
+
+ Copied from
``airflow.providers.smtp.utils.emailer.get_email_address_list``.
+ """
+ if isinstance(addresses, str):
+ return re.split(r"\s*[,;]\s*", addresses)
+ if isinstance(addresses, Iterable):
+ if not all(isinstance(item, str) for item in addresses):
+ raise TypeError("The items in your iterable must be strings.")
+ return list(addresses)
+ raise TypeError(f"Unexpected argument type: Received
'{type(addresses).__name__}'.")
+
+
+def _build_mime_message(
+ mail_from: str | None,
+ to: str | Iterable[str],
+ subject: str,
+ html_content: str,
+ files: list[str] | None = None,
+ cc: str | Iterable[str] | None = None,
+ bcc: str | Iterable[str] | None = None,
+ mime_subtype: str = "mixed",
+ mime_charset: str = "utf-8",
+ custom_headers: dict[str, Any] | None = None,
+) -> tuple[MIMEMultipart, list[str]]:
+ """
+ Build the MIME message and return it with the full list of recipients,
including CC and BCC.
+
+ Copied from ``airflow.providers.smtp.utils.emailer.build_mime_message``.
+ """
+ to = _get_email_address_list(to)
+
+ msg = MIMEMultipart(mime_subtype)
+ msg["Subject"] = subject
+ if mail_from:
+ msg["From"] = mail_from
+ msg["To"] = ", ".join(to)
+ recipients = to
+ if cc:
+ cc = _get_email_address_list(cc)
+ msg["CC"] = ", ".join(cc)
+ recipients += cc
+
+ if bcc:
+ # don't add bcc in header
+ bcc = _get_email_address_list(bcc)
+ recipients += bcc
+
+ msg["Date"] = formatdate(localtime=True)
+ mime_text = MIMEText(html_content, "html", mime_charset)
+ msg.attach(mime_text)
+
+ for fname in files or []:
+ basename = os.path.basename(fname)
+ with open(fname, "rb") as file:
+ part = MIMEApplication(file.read(), Name=basename)
+ part.add_header("Content-Disposition", "attachment",
filename=basename)
+ part["Content-ID"] = f"<{basename}>"
+ msg.attach(part)
+
+ if custom_headers:
+ for header_key, header_value in custom_headers.items():
+ msg[header_key] = header_value
+
+ return msg, recipients
class SesHook(AwsBaseHook):
@@ -96,7 +170,7 @@ class SesHook(AwsBaseHook):
"""
custom_headers = self._build_headers(custom_headers, reply_to,
return_path)
- message, recipients = build_mime_message(
+ message, recipients = _build_mime_message(
mail_from=mail_from,
to=to,
subject=subject,
@@ -153,7 +227,7 @@ class SesHook(AwsBaseHook):
"""
custom_headers = self._build_headers(custom_headers, reply_to,
return_path)
- message, recipients = build_mime_message(
+ message, recipients = _build_mime_message(
mail_from=mail_from,
to=to,
subject=subject,
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_ses.py
b/providers/amazon/tests/unit/amazon/aws/hooks/test_ses.py
index bd56b679e55..1907f25b692 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_ses.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_ses.py
@@ -16,13 +16,16 @@
# under the License.
from __future__ import annotations
+import os
+import subprocess
+import sys
from unittest import mock
import boto3
import pytest
from moto import mock_aws
-from airflow.providers.amazon.aws.hooks.ses import SesHook
+from airflow.providers.amazon.aws.hooks.ses import SesHook, _build_mime_message
boto3.setup_default_session()
@@ -135,3 +138,64 @@ class TestAsyncSesHook:
assert response["MessageId"] == "test_message_id"
mock_async_client.send_raw_email.assert_called_once()
+
+
+class TestBuildMimeMessage:
+ def test_message_and_recipients(self, tmp_path):
+ attachment = tmp_path / "report.txt"
+ attachment.write_bytes(b"data")
+
+ msg, recipients = _build_mime_message(
+ mail_from=TEST_FROM_ADDRESS,
+ to="[email protected]; [email protected]",
+ subject=TEST_SUBJECT,
+ html_content=TEST_HTML_CONTENT,
+ files=[os.fspath(attachment)],
+ cc=["[email protected]"],
+ bcc="[email protected]",
+ custom_headers={"Reply-To": TEST_REPLY_TO},
+ )
+
+ assert recipients == ["[email protected]", "[email protected]",
"[email protected]", "[email protected]"]
+ assert msg["From"] == TEST_FROM_ADDRESS
+ assert msg["To"] == "[email protected], [email protected]"
+ assert msg["CC"] == "[email protected]"
+ assert "Bcc" not in msg
+ assert msg["Subject"] == TEST_SUBJECT
+ assert msg["Reply-To"] == TEST_REPLY_TO
+ assert "Date" in msg
+ html_part, attachment_part = msg.get_payload()
+ assert html_part.get_payload(decode=True) == TEST_HTML_CONTENT.encode()
+ assert attachment_part.get_filename() == "report.txt"
+ assert attachment_part["Content-ID"] == "<report.txt>"
+ assert attachment_part.get_payload(decode=True) == b"data"
+
+ def test_without_sender_omits_from_header(self):
+ msg, _ = _build_mime_message(
+ mail_from=None, to="[email protected]", subject=TEST_SUBJECT,
html_content=TEST_HTML_CONTENT
+ )
+
+ assert "From" not in msg
+
+ @pytest.mark.parametrize(
+ ("to", "match"),
+ [
+ pytest.param(1, "Unexpected argument type: Received 'int'",
id="not_iterable"),
+ pytest.param(["[email protected]", 2], "The items in your iterable
must be strings", id="not_string"),
+ ],
+ )
+ def test_rejects_non_string_addresses(self, to, match):
+ with pytest.raises(TypeError, match=match):
+ _build_mime_message(
+ mail_from=TEST_FROM_ADDRESS, to=to, subject=TEST_SUBJECT,
html_content=TEST_HTML_CONTENT
+ )
+
+
+def test_hook_imports_without_core_email_utils():
+ """``airflow.utils.email`` is moving to the SMTP provider, so the hook
must not import it."""
+ script = (
+ "import sys\n"
+ "sys.modules['airflow.utils.email'] = None\n"
+ "import airflow.providers.amazon.aws.hooks.ses\n"
+ )
+ subprocess.run([sys.executable, "-c", script], check=True)