Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions airflow-core/newsfragments/66764.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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``).
21 changes: 21 additions & 0 deletions airflow-core/src/airflow/utils/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions airflow-core/tests/unit/utils/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading