From dbbc5b38dce7a0c24ec78cf41dee86d02a06e62c Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Tue, 12 May 2026 03:49:35 -0700 Subject: [PATCH 1/2] Honor SMTP connection host/port and extra in send_mime_email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Connection passed to ``airflow.utils.email.send_mime_email`` was only read for ``login`` and ``password``. ``host``, ``port``, ``starttls``, ``ssl``, ``timeout`` and ``retry_limit`` were always taken from the ``[smtp]`` config, so a Connection configured in the UI or via secrets had no effect on those fields. This is surprising in a deployment that pins the SMTP connection through Airflow's connection store (and is what the SMTP provider's hook does for its own ``send_email`` path). The Connection now overrides the corresponding config values when set: * ``host`` / ``port`` from the Connection take precedence over ``smtp_host`` / ``smtp_port``. * The Connection ``extra`` JSON may include ``disable_tls``, ``disable_ssl``, ``timeout`` and ``retry_limit`` keys — matching the schema the SMTP provider already declares for its connection — and these override the config when present. When the Connection does not set a field (or the connection lookup raises ``AirflowException``), the existing ``[smtp]`` config values are still used, so deployments that configure SMTP entirely through ``airflow.cfg`` keep working unchanged. Closes: #34554 --- airflow-core/newsfragments/34554.bugfix.rst | 1 + airflow-core/src/airflow/utils/email.py | 21 ++++ airflow-core/tests/unit/utils/test_email.py | 110 ++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 airflow-core/newsfragments/34554.bugfix.rst diff --git a/airflow-core/newsfragments/34554.bugfix.rst b/airflow-core/newsfragments/34554.bugfix.rst new file mode 100644 index 0000000000000..32b9d709e5358 --- /dev/null +++ b/airflow-core/newsfragments/34554.bugfix.rst @@ -0,0 +1 @@ +Honor SMTP connection ``host``, ``port`` and ``extra`` values in ``send_mime_email``. Previously only ``login`` / ``password`` were read from the connection while host, port, TLS/SSL, timeout and retry settings always came from the ``[smtp]`` config. The connection fields now take precedence when set, matching the SMTP provider's connection schema (``disable_tls``, ``disable_ssl``, ``timeout``, ``retry_limit``). diff --git a/airflow-core/src/airflow/utils/email.py b/airflow-core/src/airflow/utils/email.py index 7376e14baf860..212f19f71da5d 100644 --- a/airflow-core/src/airflow/utils/email.py +++ b/airflow-core/src/airflow/utils/email.py @@ -229,6 +229,14 @@ def send_mime_email( """ Send a MIME email. + Connection fields take precedence over the ``[smtp]`` configuration when set: + + * ``host`` / ``port`` on the connection override ``smtp_host`` / ``smtp_port``. + * ``login`` / ``password`` on the connection set the SMTP credentials. + * The connection ``extra`` JSON may contain ``disable_tls``, ``disable_ssl``, + ``timeout``, and ``retry_limit`` keys (matching the SMTP provider's + connection schema) that override the corresponding config values. + :param e_from: The email address of the sender. :param e_to: The email address or a list of email addresses of the recipient(s). :param mime_msg: The MIME message to send. @@ -251,6 +259,19 @@ def send_mime_email( airflow_conn = Connection.get_connection_from_secrets(conn_id) smtp_user = airflow_conn.login smtp_password = airflow_conn.password + if airflow_conn.host: + smtp_host = airflow_conn.host + if airflow_conn.port is not None: + smtp_port = airflow_conn.port + extra = airflow_conn.extra_dejson + if "disable_tls" in extra: + smtp_starttls = not bool(extra["disable_tls"]) + if "disable_ssl" in extra: + smtp_ssl = not bool(extra["disable_ssl"]) + if "timeout" in extra: + smtp_timeout = int(extra["timeout"]) + if "retry_limit" in extra: + smtp_retry_limit = int(extra["retry_limit"]) except AirflowException: pass if smtp_user is None or smtp_password is None: diff --git a/airflow-core/tests/unit/utils/test_email.py b/airflow-core/tests/unit/utils/test_email.py index c45e83d6e7714..2390d662d942e 100644 --- a/airflow-core/tests/unit/utils/test_email.py +++ b/airflow-core/tests/unit/utils/test_email.py @@ -238,6 +238,116 @@ def test_send_mime_conn_id(self, mock_smtp, monkeypatch): mock_smtp.return_value.sendmail.assert_called_once_with("from", "to", msg.as_string()) assert mock_smtp.return_value.quit.called + @mock.patch("smtplib.SMTP") + def test_send_mime_conn_host_port_override_config(self, mock_smtp, monkeypatch): + """Connection host/port should take precedence over [smtp] config.""" + monkeypatch.setenv( + "AIRFLOW_CONN_SMTP_TEST_CONN", + json.dumps( + { + "conn_type": "smtp", + "host": "conn.example.com", + "port": 2525, + "login": "test-user", + "password": "test-p@$$word", + } + ), + ) + msg = MIMEMultipart() + email.send_mime_email("from", "to", msg, dryrun=False, conn_id="smtp_test_conn") + mock_smtp.assert_called_once_with( + host="conn.example.com", + port=2525, + timeout=conf.getint("smtp", "SMTP_TIMEOUT"), + ) + + @mock.patch("smtplib.SMTP") + def test_send_mime_conn_timeout_retry_from_extra(self, mock_smtp, monkeypatch): + """``timeout`` and ``retry_limit`` from connection extras should override [smtp] config.""" + mock_smtp.side_effect = SMTPServerDisconnected() + monkeypatch.setenv( + "AIRFLOW_CONN_SMTP_TEST_CONN", + json.dumps( + { + "conn_type": "smtp", + "host": "conn.example.com", + "port": 2525, + "login": "test-user", + "password": "test-p@$$word", + "extra": json.dumps({"timeout": 7, "retry_limit": 3}), + } + ), + ) + msg = MIMEMultipart() + with pytest.raises(SMTPServerDisconnected): + email.send_mime_email("from", "to", msg, dryrun=False, conn_id="smtp_test_conn") + mock_smtp.assert_any_call(host="conn.example.com", port=2525, timeout=7) + assert mock_smtp.call_count == 3 + + @mock.patch("smtplib.SMTP_SSL") + @mock.patch("smtplib.SMTP") + def test_send_mime_conn_disable_tls_in_extra(self, mock_smtp, mock_smtp_ssl, monkeypatch): + """``disable_tls`` in connection extras should suppress starttls even when config enables it.""" + monkeypatch.setenv( + "AIRFLOW_CONN_SMTP_TEST_CONN", + json.dumps( + { + "conn_type": "smtp", + "host": "conn.example.com", + "port": 2525, + "login": "test-user", + "password": "test-p@$$word", + "extra": json.dumps({"disable_tls": True}), + } + ), + ) + msg = MIMEMultipart() + email.send_mime_email("from", "to", msg, dryrun=False, conn_id="smtp_test_conn") + assert not mock_smtp.return_value.starttls.called + assert not mock_smtp_ssl.called + + @mock.patch("smtplib.SMTP_SSL") + @mock.patch("smtplib.SMTP") + def test_send_mime_conn_disable_ssl_false_routes_to_smtp_ssl(self, mock_smtp, mock_smtp_ssl, monkeypatch): + """``disable_ssl=false`` in connection extras should enable SMTP_SSL even when config disables it.""" + mock_smtp_ssl.return_value = mock.Mock() + monkeypatch.setenv( + "AIRFLOW_CONN_SMTP_TEST_CONN", + json.dumps( + { + "conn_type": "smtp", + "host": "conn.example.com", + "port": 2525, + "login": "test-user", + "password": "test-p@$$word", + "extra": json.dumps({"disable_ssl": False, "disable_tls": True}), + } + ), + ) + msg = MIMEMultipart() + with conf_vars({("smtp", "smtp_ssl"): "False"}): + email.send_mime_email("from", "to", msg, dryrun=False, conn_id="smtp_test_conn") + assert not mock_smtp.called + mock_smtp_ssl.assert_called_once() + kwargs = mock_smtp_ssl.call_args.kwargs + assert kwargs["host"] == "conn.example.com" + assert kwargs["port"] == 2525 + + @mock.patch("smtplib.SMTP") + def test_send_mime_config_used_when_conn_lacks_host(self, mock_smtp, monkeypatch): + """[smtp] config should still be the fallback when the connection has no host.""" + monkeypatch.setenv( + "AIRFLOW_CONN_SMTP_TEST_CONN", + json.dumps({"conn_type": "smtp", "login": "test-user", "password": "test-p@$$word"}), + ) + msg = MIMEMultipart() + email.send_mime_email("from", "to", msg, dryrun=False, conn_id="smtp_test_conn") + mock_smtp.assert_called_once_with( + host=conf.get("smtp", "SMTP_HOST"), + port=conf.getint("smtp", "SMTP_PORT"), + timeout=conf.getint("smtp", "SMTP_TIMEOUT"), + ) + @mock.patch("smtplib.SMTP_SSL") @mock.patch("smtplib.SMTP") def test_send_mime_ssl_none_context(self, mock_smtp, mock_smtp_ssl): From 71598ec38ade1f453f288a11ae7f5a9794ac7c2f Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Tue, 12 May 2026 07:16:35 -0700 Subject: [PATCH 2/2] Rename newsfragment to PR number Signed-off-by: 1fanwang <1fannnw@gmail.com> --- airflow-core/newsfragments/{34554.bugfix.rst => 66764.bugfix.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename airflow-core/newsfragments/{34554.bugfix.rst => 66764.bugfix.rst} (100%) diff --git a/airflow-core/newsfragments/34554.bugfix.rst b/airflow-core/newsfragments/66764.bugfix.rst similarity index 100% rename from airflow-core/newsfragments/34554.bugfix.rst rename to airflow-core/newsfragments/66764.bugfix.rst