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)

Reply via email to