This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-3-test by this push:
new 1ebf5c9a424 [v3-3-test] Restore pluggable email backend for task
failure and retry alerts (#69877) (#70129)
1ebf5c9a424 is described below
commit 1ebf5c9a424b678d9b6dd273d3af7a999057274c
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Jul 21 16:50:44 2026 +0200
[v3-3-test] Restore pluggable email backend for task failure and retry
alerts (#69877) (#70129)
Since #57354, task email_on_failure / email_on_retry alerts were routed
unconditionally through SmtpNotifier, silently ignoring the
[email] email_backend configuration. Custom backends (SES, SendGrid,
org-internal) stopped delivering failure/retry emails even though the
deprecated email_on_* parameters still worked.
This restores the old behaviour using the existing [email] email_backend
option -- no new configuration is introduced:
- A non-default [email] email_backend is transparently wrapped in a new
LegacyEmailBackendNotifier, so existing SES / SendGrid / custom backends
keep delivering alerts unchanged. The backend is resolved from config at
notify time, so the Task SDK keeps no static dependency on
airflow.utils.email.
- Otherwise the default SmtpNotifier is used, exactly as before.
The notifier lives in the Task SDK
(airflow.sdk.execution_time.email_backend)
next to its only caller, so no extra provider needs to be installed for a
custom email backend to keep working.
Both failure-email entry points (the worker task-runner path and the
DAG-processor callback path) funnel through the same function, so the
selected backend is used consistently regardless of how the task failed.
The deprecated email_on_* parameters are not un-deprecated; this only keeps
their existing behaviour pluggable until removal in Airflow 4.
(cherry picked from commit f7dec02b2a9d88e886d0d3b2fd1919edbf2ff8af)
Co-authored-by: Jarek Potiuk <[email protected]>
---
airflow-core/newsfragments/69877.bugfix.rst | 1 +
.../tests/unit/dag_processing/test_processor.py | 86 +++++++++++++++++++-
.../airflow/sdk/execution_time/email_backend.py | 92 +++++++++++++++++++++
.../src/airflow/sdk/execution_time/task_runner.py | 48 ++++++++---
.../task_sdk/execution_time/test_email_backend.py | 86 ++++++++++++++++++++
.../task_sdk/execution_time/test_task_runner.py | 95 ++++++++++++++++++++++
6 files changed, 394 insertions(+), 14 deletions(-)
diff --git a/airflow-core/newsfragments/69877.bugfix.rst
b/airflow-core/newsfragments/69877.bugfix.rst
new file mode 100644
index 00000000000..7eef9afc990
--- /dev/null
+++ b/airflow-core/newsfragments/69877.bugfix.rst
@@ -0,0 +1 @@
+Restore delivery of ``email_on_failure`` and ``email_on_retry`` task alerts
through a custom ``[email] email_backend``. These alerts were routed
unconditionally through ``SmtpNotifier``, so deployments using an Amazon SES,
SendGrid or org-internal backend stopped receiving them. The default remains
``SmtpNotifier``. Note that a configured ``email_backend`` which cannot be
imported now fails with a logged error instead of silently falling back to SMTP
-- check that ``[email] email_backend [...]
diff --git a/airflow-core/tests/unit/dag_processing/test_processor.py
b/airflow-core/tests/unit/dag_processing/test_processor.py
index 599c36d47f6..2da26aab124 100644
--- a/airflow-core/tests/unit/dag_processing/test_processor.py
+++ b/airflow-core/tests/unit/dag_processing/test_processor.py
@@ -24,9 +24,9 @@ import sys
import textwrap
import typing
import uuid
-from collections.abc import Callable
+from collections.abc import Callable, Iterable
from socket import socketpair
-from typing import TYPE_CHECKING, BinaryIO
+from typing import TYPE_CHECKING, Any, BinaryIO
from unittest.mock import MagicMock, patch
import pytest
@@ -1571,6 +1571,24 @@ class TestExecuteTaskCallbacks:
_execute_task_callbacks(dagbag, request, log)
+def _recording_email_backend(
+ to: list[str] | Iterable[str],
+ subject: str,
+ html_content: str,
+ files: list[str] | None = None,
+ dryrun: bool = False,
+ cc: str | Iterable[str] | None = None,
+ bcc: str | Iterable[str] | None = None,
+ mime_subtype: str = "mixed",
+ mime_charset: str = "utf-8",
+ conn_id: str | None = None,
+ custom_headers: dict[str, Any] | None = None,
+ **kwargs,
+) -> None:
+ """Legacy ``[email] email_backend`` stub, patched with a spec'd mock in
tests."""
+ raise AssertionError("should be patched in the test")
+
+
class TestExecuteEmailCallbacks:
"""Test the email callback execution functionality."""
@@ -1923,6 +1941,70 @@ class TestExecuteEmailCallbacks:
call_kwargs = mock_dagbag_class.call_args.kwargs
assert call_kwargs["bundle_name"] == "test_bundle"
+ def test_execute_email_callbacks_uses_custom_email_backend(self):
+ """The Dag-processor path honours a custom ``[email] email_backend``,
like the worker path."""
+ backend = MagicMock(spec=_recording_email_backend)
+ dagbag = MagicMock(spec=DagBag)
+ with DAG(dag_id="test_dag") as dag:
+ BaseOperator(task_id="test_task", email=["[email protected]"])
+ dagbag.dags = {"test_dag": dag}
+
+ current_time = timezone.utcnow()
+ request = EmailRequest(
+ filepath="/path/to/dag.py",
+ bundle_name="test_bundle",
+ bundle_version="1.0.0",
+ ti=TIDataModel(
+ id=str(uuid.uuid4()),
+ task_id="test_task",
+ dag_id="test_dag",
+ run_id="test_run",
+ logical_date="2023-01-01T00:00:00Z",
+ try_number=1,
+ attempt_number=1,
+ state="failed",
+ dag_version_id=str(uuid.uuid4()),
+ ),
+ context_from_server=TIRunContext(
+ dag_run=DRDataModel(
+ dag_id="test_dag",
+ run_id="test_run",
+ logical_date="2023-01-01T00:00:00Z",
+ data_interval_start=current_time,
+ data_interval_end=current_time,
+ run_after=current_time,
+ start_date=current_time,
+ end_date=None,
+ run_type="manual",
+ state="running",
+ consumed_asset_events=[],
+ partition_key=None,
+ ),
+ max_tries=2,
+ ),
+ email_type="failure",
+ msg="Task failed",
+ )
+
+ conf_overrides = {
+ ("email", "email_backend"): f"{__name__}._recording_email_backend",
+ ("email", "email_conn_id"): "my_smtp",
+ ("email", "from_email"): "from@airflow",
+ }
+ with conf_vars(conf_overrides):
+ with patch(f"{__name__}._recording_email_backend", backend):
+ with patch(
+ "airflow.providers.smtp.notifications.smtp.SmtpNotifier",
autospec=True
+ ) as mock_smtp_notifier:
+ _execute_email_callbacks(dagbag, request,
MagicMock(spec=FilteringBoundLogger))
+
+ mock_smtp_notifier.assert_not_called()
+ backend.assert_called_once()
+ args, kwargs = backend.call_args
+ assert args[0] == ["[email protected]"]
+ assert kwargs["conn_id"] == "my_smtp"
+ assert kwargs["from_email"] == "from@airflow"
+
class TestDagProcessingMessageTypes:
def test_message_types_in_dag_processor(self):
diff --git a/task-sdk/src/airflow/sdk/execution_time/email_backend.py
b/task-sdk/src/airflow/sdk/execution_time/email_backend.py
new file mode 100644
index 00000000000..d307572e39d
--- /dev/null
+++ b/task-sdk/src/airflow/sdk/execution_time/email_backend.py
@@ -0,0 +1,92 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Protocol
+
+from airflow.sdk.bases.notifier import BaseNotifier
+from airflow.sdk.configuration import conf
+from airflow.sdk.exceptions import AirflowConfigException
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+ from airflow.sdk.definitions.context import Context
+
+_DEFAULT_EMAIL_BACKEND = "airflow.utils.email.send_email_smtp"
+
+
+class _ErrorEmailNotifier(Protocol):
+ """
+ Constructor contract shared by the notifiers used for failure and retry
alerts.
+
+ ``BaseNotifier`` itself does not describe this -- its ``__init__`` takes
only ``context`` --
+ so the shared shape is spelled out here to keep the call site type-checked.
+ """
+
+ def __call__(
+ self,
+ to: str | Iterable[str],
+ from_email: str | None = ...,
+ subject: str | None = ...,
+ html_content: str | None = ...,
+ ) -> BaseNotifier: ...
+
+
+class _LegacyEmailBackendNotifier(BaseNotifier):
+ """
+ Adapter that exposes a legacy ``[email] email_backend`` callable as a
notifier.
+
+ Before failure and retry alerts were routed through ``BaseNotifier``
subclasses, deployments
+ configured them through ``[email] email_backend`` -- a callable with the
+ ``airflow.utils.email.send_email`` signature, such as the Amazon SES or
SendGrid senders.
+ This adapter renders the standard email fields like any notifier, then
loads and calls the
+ configured backend, so existing ``email_backend`` setups keep working
unchanged.
+
+ The backend is resolved from config at notify time rather than imported
statically, keeping
+ the Task SDK free of a hard dependency on ``airflow.utils.email`` (which
lives in
+ ``airflow-core``).
+ """
+
+ template_fields = ("to", "from_email", "subject", "html_content")
+
+ def __init__(
+ self,
+ to: str | Iterable[str],
+ from_email: str | None = None,
+ subject: str | None = None,
+ html_content: str | None = None,
+ **kwargs: Any,
+ ) -> None:
+ super().__init__()
+ self.to = to
+ self.from_email = from_email
+ self.subject = subject
+ self.html_content = html_content
+
+ def notify(self, context: Context) -> None:
+ backend = conf.getimport("email", "email_backend",
fallback=_DEFAULT_EMAIL_BACKEND)
+ if backend is None:
+ raise AirflowConfigException("`[email] email_backend` is not
configured")
+ backend(
+ self.to,
+ self.subject,
+ self.html_content,
+ conn_id=conf.get("email", "email_conn_id", fallback=None),
+ from_email=self.from_email,
+ )
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 7a77ed9ad23..3200edf0db4 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -136,6 +136,11 @@ from airflow.sdk.execution_time.context import (
get_previous_dagrun_success,
set_current_context,
)
+from airflow.sdk.execution_time.email_backend import (
+ _DEFAULT_EMAIL_BACKEND,
+ _ErrorEmailNotifier,
+ _LegacyEmailBackendNotifier,
+)
from airflow.sdk.execution_time.sentry import Sentry
from airflow.sdk.execution_time.xcom import XCom
from airflow.sdk.listener import get_listener_manager
@@ -1997,20 +2002,39 @@ def _send_error_email_notification(
error: BaseException | str | None,
log: Logger,
) -> None:
- """Send email notification for task errors using SmtpNotifier."""
- try:
- from airflow.providers.smtp.notifications.smtp import SmtpNotifier
- except ImportError:
- log.error(
- "Failed to send task failure or retry email notification: "
- "`apache-airflow-providers-smtp` is not installed. "
- "Install this provider to enable email notifications."
- )
- return
+ """
+ Send email notification for task errors through the configured email
backend.
+
+ A non-default ``[email] email_backend`` (an SES, SendGrid or org-internal
callable with the
+ ``airflow.utils.email.send_email`` signature) is wrapped in
+
:class:`~airflow.sdk.execution_time.email_backend._LegacyEmailBackendNotifier`;
otherwise the
+ default :class:`~airflow.providers.smtp.notifications.smtp.SmtpNotifier`
is used.
+ Both the worker task-runner path (:func:`finalize`) and the DAG-processor
callback path
+ (``_execute_email_callbacks``) funnel through this function, so the
resolved backend is used
+ consistently regardless of how the task failed.
+ """
if not task.email:
return
+ email_backend = conf.get("email", "email_backend",
fallback=_DEFAULT_EMAIL_BACKEND)
+ notifier_description = "SmtpNotifier"
+
+ if email_backend and email_backend != _DEFAULT_EMAIL_BACKEND:
+ notifier_class: _ErrorEmailNotifier = _LegacyEmailBackendNotifier
+ notifier_description = f"configured email_backend {email_backend!r}"
+ else:
+ try:
+ from airflow.providers.smtp.notifications.smtp import SmtpNotifier
+ except ImportError:
+ log.error(
+ "Failed to send task failure or retry email notification: "
+ "`apache-airflow-providers-smtp` is not installed. "
+ "Install this provider to enable email notifications."
+ )
+ return
+ notifier_class = SmtpNotifier
+
subject_template_file = conf.get("email", "subject_template",
fallback=None)
# Read the template file if configured
@@ -2053,7 +2077,7 @@ def _send_error_email_notification(
return
try:
- notifier = SmtpNotifier(
+ notifier = notifier_class(
to=to_emails,
subject=subject,
html_content=html_content,
@@ -2061,7 +2085,7 @@ def _send_error_email_notification(
)
notifier(email_context)
except Exception:
- log.exception("Failed to send email notification")
+ log.exception("Failed to send email notification via %s",
notifier_description)
@detail_span("task.execute")
diff --git a/task-sdk/tests/task_sdk/execution_time/test_email_backend.py
b/task-sdk/tests/task_sdk/execution_time/test_email_backend.py
new file mode 100644
index 00000000000..5940d0568c2
--- /dev/null
+++ b/task-sdk/tests/task_sdk/execution_time/test_email_backend.py
@@ -0,0 +1,86 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from collections.abc import Iterable
+from typing import Any
+from unittest import mock
+
+import pytest
+
+from airflow.sdk.configuration import conf
+from airflow.sdk.exceptions import AirflowConfigException
+from airflow.sdk.execution_time.email_backend import
_LegacyEmailBackendNotifier
+
+
+def _legacy_email_backend(
+ to: list[str] | Iterable[str],
+ subject: str,
+ html_content: str,
+ files: list[str] | None = None,
+ dryrun: bool = False,
+ cc: str | Iterable[str] | None = None,
+ bcc: str | Iterable[str] | None = None,
+ mime_subtype: str = "mixed",
+ mime_charset: str = "utf-8",
+ conn_id: str | None = None,
+ custom_headers: dict[str, Any] | None = None,
+ **kwargs,
+) -> None:
+ """
+ Spec for an ``[email] email_backend`` callable.
+
+ Mirrors the ``airflow.utils.email.send_email`` signature so the autospec
enforces the
+ calling convention the notifier has to honour. Duplicated here rather than
imported
+ because the Task SDK must not depend on ``airflow-core``.
+ """
+ raise AssertionError("spec only; never called")
+
+
+class TestLegacyEmailBackendNotifier:
+ def test_notify_calls_configured_backend(self):
+ """notify() loads the legacy backend from config and calls it with the
standard fields."""
+ backend = mock.create_autospec(_legacy_email_backend)
+ notifier = _LegacyEmailBackendNotifier(
+ to=["[email protected]"],
+ from_email="[email protected]",
+ subject="Subject",
+ html_content="<p>body</p>",
+ )
+ with (
+ mock.patch.object(conf, "getimport", autospec=True,
return_value=backend) as getimport,
+ mock.patch.object(conf, "get", autospec=True,
return_value="my_conn"),
+ ):
+ notifier.notify(context={})
+
+ getimport.assert_called_once_with(
+ "email", "email_backend",
fallback="airflow.utils.email.send_email_smtp"
+ )
+ backend.assert_called_once_with(
+ ["[email protected]"],
+ "Subject",
+ "<p>body</p>",
+ conn_id="my_conn",
+ from_email="[email protected]",
+ )
+
+ def test_notify_raises_when_backend_unresolvable(self):
+ """An empty/unloadable backend raises rather than silently doing
nothing."""
+ notifier = _LegacyEmailBackendNotifier(to="[email protected]")
+ with mock.patch.object(conf, "getimport", autospec=True,
return_value=None):
+ with pytest.raises(AirflowConfigException):
+ notifier.notify(context={})
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index 1aad1a02d5e..9714a49e872 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -3766,6 +3766,30 @@ class TestXComAfterTaskExecution:
)
+def _recording_email_backend(
+ to: list[str] | Iterable[str],
+ subject: str,
+ html_content: str,
+ files: list[str] | None = None,
+ dryrun: bool = False,
+ cc: str | Iterable[str] | None = None,
+ bcc: str | Iterable[str] | None = None,
+ mime_subtype: str = "mixed",
+ mime_charset: str = "utf-8",
+ conn_id: str | None = None,
+ custom_headers: dict[str, Any] | None = None,
+ **kwargs,
+) -> None:
+ """
+ Legacy ``[email] email_backend`` stub, patched with an autospecced mock in
tests.
+
+ Mirrors the ``airflow.utils.email.send_email`` signature so the autospec
enforces the
+ calling convention ``_LegacyEmailBackendNotifier`` has to honour.
Duplicated here rather
+ than imported because the Task SDK must not depend on ``airflow-core``.
+ """
+ raise AssertionError("should be patched in the test")
+
+
class TestEmailNotifications:
FROM = "from@airflow"
@@ -3932,6 +3956,77 @@ class TestEmailNotifications:
)
assert kwargs["from_email"] == self.FROM
+ def test_custom_email_backend_is_used(self, create_runtime_ti,
mock_supervisor_comms):
+ """A custom ``[email] email_backend`` is wrapped and invoked with
rendered fields."""
+ backend = mock.create_autospec(_recording_email_backend)
+
+ class FailingOperator(BaseOperator):
+ def execute(self, context):
+ raise AirflowFailException("Task failed on purpose")
+
+ task = FailingOperator(
+ task_id="legacy_backend_task",
+ email=["[email protected]"],
+ email_on_failure=True,
+ )
+
+ runtime_ti = create_runtime_ti(task=task)
+ context = runtime_ti.get_template_context()
+ log = mock.MagicMock()
+
+ with conf_vars(
+ {
+ ("email", "email_backend"):
f"{__name__}._recording_email_backend",
+ ("email", "email_conn_id"): "my_smtp",
+ ("email", "from_email"): self.FROM,
+ }
+ ):
+ with mock.patch(f"{__name__}._recording_email_backend", backend):
+ with mock.patch(
+ "airflow.providers.smtp.notifications.smtp.SmtpNotifier",
autospec=True
+ ) as mock_smtp_notifier:
+ state, _, error = run(runtime_ti, context, log)
+ finalize(runtime_ti, state, context, log, error)
+
+ # The default SMTP notifier must not be used when a custom backend is
configured.
+ mock_smtp_notifier.assert_not_called()
+ backend.assert_called_once()
+ args, kwargs = backend.call_args
+ # send_email(to, subject, html_content, conn_id=..., from_email=...)
+ assert args[0] == ["[email protected]"]
+ assert kwargs["conn_id"] == "my_smtp"
+ assert kwargs["from_email"] == self.FROM
+
+ def test_unresolvable_email_backend_is_logged(self, create_runtime_ti,
mock_supervisor_comms):
+ """An unimportable custom backend is logged, and does not silently
fall back to SMTP."""
+
+ class FailingOperator(BaseOperator):
+ def execute(self, context):
+ raise AirflowFailException("Task failed on purpose")
+
+ task = FailingOperator(
+ task_id="bad_backend_task",
+ email=["[email protected]"],
+ email_on_failure=True,
+ )
+
+ runtime_ti = create_runtime_ti(task=task)
+ context = runtime_ti.get_template_context()
+ log = mock.MagicMock()
+
+ with conf_vars({("email", "email_backend"): "airflow.does.not.Exist"}):
+ # SmtpNotifier is patched to succeed, so a logged exception can
only come from
+ # resolving the custom backend -- without that, this would pass
pre-fix too.
+ with mock.patch(
+ "airflow.providers.smtp.notifications.smtp.SmtpNotifier",
autospec=True
+ ) as mock_smtp_notifier:
+ state, _, error = run(runtime_ti, context, log)
+ # Must not raise even though the backend cannot be loaded.
+ finalize(runtime_ti, state, context, log, error)
+
+ mock_smtp_notifier.assert_not_called()
+ log.exception.assert_called()
+
@pytest.mark.enable_redact
def test_rendered_templates_mask_secrets(self, create_runtime_ti,
mock_supervisor_comms):
"""Test that secrets registered with mask_secret() are redacted in
rendered template fields."""