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" },
]