This is an automated email from the ASF dual-hosted git repository.

hussein-awala 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 c84db2fa3a2 Fix async SmtpHook connections failing when STARTTLS is 
enabled (#75529)
c84db2fa3a2 is described below

commit c84db2fa3a2a5825330ee2187eefeeacde289455
Author: Shivam Rastogi <[email protected]>
AuthorDate: Wed Oct 28 13:25:35 2026 -0700

    Fix async SmtpHook connections failing when STARTTLS is enabled (#75529)
    
    aiosmtplib already performs the STARTTLS upgrade inside connect() when the
    client is created with start_tls=True, which the hook does for STARTTLS
    connections. aget_conn() then requested a second upgrade, so every async
    connection with STARTTLS enabled failed with "SMTPException: Connection
    already using TLS", even with a valid certificate. This broke async
    SmtpNotifier delivery for the documented port 587 setups, while the sync
    path kept working.
    
    The declared aiosmtplib>=0.1.6 floor never matched the hook: 0.1.6 rejects
    the start_tls argument, and the async OAuth2 path needs
    SMTP.auth_xoauth2(), which first shipped in aiosmtplib 5.1.0.
---
 providers/smtp/README.rst                          |  2 +-
 providers/smtp/docs/index.rst                      |  2 +-
 providers/smtp/pyproject.toml                      |  2 +-
 .../smtp/src/airflow/providers/smtp/hooks/smtp.py  |  6 ++---
 providers/smtp/tests/unit/smtp/hooks/test_smtp.py  | 31 +++++++++++++++++-----
 uv.lock                                            |  2 +-
 6 files changed, 30 insertions(+), 15 deletions(-)

diff --git a/providers/smtp/README.rst b/providers/smtp/README.rst
index ea3e3b2e22b..84b5ff28707 100644
--- a/providers/smtp/README.rst
+++ b/providers/smtp/README.rst
@@ -55,7 +55,7 @@ PIP package                                 Version required
 ==========================================  ==================
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
-``aiosmtplib``                              ``>=0.1.6``
+``aiosmtplib``                              ``>=5.1.0``
 ==========================================  ==================
 
 The changelog for the provider package can be found in the
diff --git a/providers/smtp/docs/index.rst b/providers/smtp/docs/index.rst
index f9367cf7ba2..a2a292aafd6 100644
--- a/providers/smtp/docs/index.rst
+++ b/providers/smtp/docs/index.rst
@@ -90,7 +90,7 @@ PIP package                                 Version required
 ==========================================  ==================
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
-``aiosmtplib``                              ``>=0.1.6``
+``aiosmtplib``                              ``>=5.1.0``
 ==========================================  ==================
 
 Downloading official packages
diff --git a/providers/smtp/pyproject.toml b/providers/smtp/pyproject.toml
index f5e5cf35aa9..910093968a4 100644
--- a/providers/smtp/pyproject.toml
+++ b/providers/smtp/pyproject.toml
@@ -60,7 +60,7 @@ requires-python = ">=3.11"
 dependencies = [
     "apache-airflow>=2.11.0",
     "apache-airflow-providers-common-compat>=1.12.0",
-    "aiosmtplib>=0.1.6",
+    "aiosmtplib>=5.1.0",
 ]
 
 [dependency-groups]
diff --git a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py 
b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
index b8fbce81904..5914188b26c 100644
--- a/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
+++ b/providers/smtp/src/airflow/providers/smtp/hooks/smtp.py
@@ -177,10 +177,6 @@ class SmtpHook(BaseHook):
                     if attempt == self.smtp_retry_limit:
                         raise AirflowException("Unable to connect to smtp 
server")
                 else:
-                    if self.smtp_starttls:
-                        await 
async_client.starttls(tls_context=self._build_ssl_context())
-                        await async_client.ehlo()
-
                     # choose auth
                     if self.auth_type == "oauth2":
                         if not self._access_token:
@@ -226,6 +222,8 @@ class SmtpHook(BaseHook):
         if is_async:
             kwargs["hostname"] = self.host
             kwargs["use_tls"] = self.use_ssl
+            # aiosmtplib's connect() performs the STARTTLS upgrade with 
tls_context, so aget_conn
+            # must not call starttls() again: a second upgrade fails with 
"Connection already using TLS".
             kwargs["start_tls"] = self.smtp_starttls if not self.use_ssl else 
None
             kwargs["tls_context"] = self._build_ssl_context()
         else:
diff --git a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py 
b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
index cab96aeaed9..296a67ace8c 100644
--- a/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
+++ b/providers/smtp/tests/unit/smtp/hooks/test_smtp.py
@@ -755,9 +755,7 @@ class TestSmtpHookAsync:
         assert call_kwargs["use_tls"] == expected_ssl
         assert call_kwargs["start_tls"] == (None if expected_ssl else True)
         assert isinstance(call_kwargs["tls_context"], ssl.SSLContext)
-
-        if expected_ssl:
-            assert mock_smtp_client.starttls.await_count == 1
+        mock_smtp_client.starttls.assert_not_awaited()
 
         assert mock_smtp_client.auth_login.await_count == 1
         mock_smtp_client.auth_login.assert_awaited_once_with(SMTP_LOGIN, 
SMTP_PASSWORD)
@@ -828,14 +826,33 @@ class TestSmtpHookAsync:
 
         mock_smtp_client.sendmail.assert_not_awaited()
 
+    async def test_async_starttls_not_repeated_after_connect(
+        self, mock_smtp, mock_smtp_client, mock_get_connection
+    ):
+        """Test that aget_conn does not repeat the STARTTLS upgrade aiosmtplib 
already did in connect()."""
+        # Like aiosmtplib, reject STARTTLS on a connection that already uses 
TLS.
+        mock_smtp_client.starttls.side_effect = 
aiosmtplib.errors.SMTPException(
+            "Connection already using TLS"
+        )
+
+        async with SmtpHook(smtp_conn_id=CONN_ID_NONSSL) as hook:
+            await hook.asend_email_smtp(to=TO_EMAIL, subject=TEST_SUBJECT, 
html_content=TEST_BODY)
+
+        assert mock_smtp.call_args.kwargs["start_tls"] is True
+        mock_smtp_client.starttls.assert_not_awaited()
+        mock_smtp_client.sendmail.assert_awaited_once()
+
     async def test_async_ehlo_called_after_starttls(self, mock_smtp, 
mock_smtp_client, mock_get_connection):
-        """Test that ehlo() is called after starttls() in async path."""
+        """Test that ehlo() is called after connect(), which does the STARTTLS 
upgrade, and before login."""
+        manager = AsyncMock()
+        mock_smtp_client.connect = manager.connect
+        mock_smtp_client.ehlo = manager.ehlo
+        mock_smtp_client.auth_login = manager.auth_login
+
         async with SmtpHook(smtp_conn_id=CONN_ID_NONSSL):
             pass
 
-        # For non-SSL, starttls is called followed by ehlo
-        assert mock_smtp_client.starttls.await_count == 1
-        assert mock_smtp_client.ehlo.await_count >= 2  # once in 
_abuild_client + once after starttls
+        assert manager.mock_calls == [call.connect(), call.ehlo(), 
call.auth_login(SMTP_LOGIN, SMTP_PASSWORD)]
 
     async def test_async_oauth2_auth(
         self, mock_smtp, mock_smtp_client, mock_get_connection, 
create_connection_without_db
diff --git a/uv.lock b/uv.lock
index 754e3d378b0..37884bf29cf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -8225,7 +8225,7 @@ docs = [
 
 [package.metadata]
 requires-dist = [
-    { name = "aiosmtplib", specifier = ">=0.1.6" },
+    { name = "aiosmtplib", specifier = ">=5.1.0" },
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
 ]

Reply via email to