= ({
)}
+
+
+ updateAlertState('include_cta', e.target.checked)
+ }
+ >
+ {t('Include a link back to Superset')}
+
+
+
>
),
},
diff --git a/superset-frontend/src/features/alerts/types.ts b/superset-frontend/src/features/alerts/types.ts
index f49890e075c..a9d854beb3a 100644
--- a/superset-frontend/src/features/alerts/types.ts
+++ b/superset-frontend/src/features/alerts/types.ts
@@ -135,6 +135,7 @@ export type AlertObject = {
force_screenshot: boolean;
grace_period?: number;
id: number;
+ include_cta?: boolean;
last_eval_dttm?: number;
last_state?:
| 'Success'
diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py
index 7efe14820e9..81789f2a7c9 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -1243,6 +1243,8 @@ class BaseReportState:
error_text = None
header_data = self._get_log_data()
url = self._get_url(user_friendly=True)
+ # NULL (rows predating the include_cta column) is treated as True
+ include_cta = self._report_schedule.include_cta is not False
if (
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
@@ -1278,6 +1280,7 @@ class BaseReportState:
text=error_text,
header_data=header_data,
url=url,
+ include_cta=include_cta,
)
if (
@@ -1310,6 +1313,7 @@ class BaseReportState:
xlsx=xlsx_data,
embedded_data=embedded_data,
header_data=header_data,
+ include_cta=include_cta,
)
def _send(
@@ -1414,7 +1418,12 @@ class BaseReportState:
self._execution_id,
)
notification_content = NotificationContent(
- name=sanitize_title(name), text=message, header_data=header_data, url=url
+ name=sanitize_title(name),
+ text=message,
+ header_data=header_data,
+ url=url,
+ # NULL (rows predating the include_cta column) is treated as True
+ include_cta=self._report_schedule.include_cta is not False,
)
# filter recipients to recipients who are also editors
diff --git a/superset/migrations/versions/2026-07-28_10-00_2d6ad72e4af6_add_include_cta_to_report_schedule.py b/superset/migrations/versions/2026-07-28_10-00_2d6ad72e4af6_add_include_cta_to_report_schedule.py
new file mode 100644
index 00000000000..34f2bc512f3
--- /dev/null
+++ b/superset/migrations/versions/2026-07-28_10-00_2d6ad72e4af6_add_include_cta_to_report_schedule.py
@@ -0,0 +1,54 @@
+# 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.
+"""add include_cta to report_schedule
+
+Adds a nullable ``include_cta`` column to the ``report_schedule`` table. It controls
+whether the call-to-action link back to Superset (e.g. "Explore in Superset") is
+included in the notifications delivered for that schedule. The column defaults to
+true and NULL is treated as true, so existing schedules keep the link.
+
+Revision ID: 2d6ad72e4af6
+Revises: 1a27941d5352
+Create Date: 2026-07-28 10:00:00.000000
+
+"""
+
+import sqlalchemy as sa
+
+from superset.migrations.shared.utils import add_columns, drop_columns
+
+# revision identifiers, used by Alembic.
+revision = "2d6ad72e4af6"
+down_revision = "b8d2f4a6c901"
+
+
+def upgrade() -> None:
+ """Add the nullable ``include_cta`` column to ``report_schedule``."""
+ add_columns(
+ "report_schedule",
+ sa.Column(
+ "include_cta",
+ sa.Boolean(),
+ nullable=True,
+ server_default=sa.true(),
+ ),
+ )
+
+
+def downgrade() -> None:
+ """Drop the ``include_cta`` column from ``report_schedule``."""
+ drop_columns("report_schedule", "include_cta")
diff --git a/superset/reports/api.py b/superset/reports/api.py
index 4eb28964d35..61178e87aae 100644
--- a/superset/reports/api.py
+++ b/superset/reports/api.py
@@ -127,6 +127,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"extra",
"force_screenshot",
"grace_period",
+ "include_cta",
"last_eval_dttm",
"last_state",
"last_value",
@@ -204,6 +205,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"extra",
"force_screenshot",
"grace_period",
+ "include_cta",
"log_retention",
"name",
"recipients",
diff --git a/superset/reports/models.py b/superset/reports/models.py
index 0e67e254664..1510ea63782 100644
--- a/superset/reports/models.py
+++ b/superset/reports/models.py
@@ -188,6 +188,10 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model):
email_subject = Column(String(255))
+ # (Alerts/Reports) Include the call-to-action link back to Superset in
+ # notifications? NULL is treated as True.
+ include_cta = Column(Boolean, default=True, nullable=True)
+
def __repr__(self) -> str:
return str(self.name)
diff --git a/superset/reports/notifications/base.py b/superset/reports/notifications/base.py
index 4a99c0ef33a..5dc2d44c09a 100644
--- a/superset/reports/notifications/base.py
+++ b/superset/reports/notifications/base.py
@@ -38,6 +38,7 @@ class NotificationContent:
# Populated only when this is a per-retry or final-failure notification
retry_attempt: Optional[int] = None
retry_max_attempts: Optional[int] = None
+ include_cta: bool = True # include the call-to-action link back to Superset
class BaseNotification: # pylint: disable=too-few-public-methods
diff --git a/superset/reports/notifications/email.py b/superset/reports/notifications/email.py
index e8515848851..5f1687e872f 100644
--- a/superset/reports/notifications/email.py
+++ b/superset/reports/notifications/email.py
@@ -148,22 +148,29 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
return parseaddr(current_app.config["SMTP_MAIL_FROM"])[1].split("@")[1]
def _error_template(self, text: str) -> str:
- call_to_action = self._get_call_to_action()
# The error text is derived from exception messages that can embed
# data-controlled content (e.g. crafted table/column names in a DB
# error). Strip all HTML before interpolating it into the email body,
# matching the sanitization applied to the normal content path.
# pylint: disable=no-member
safe_text = nh3.clean(text, tags=set(), attributes={})
+ if self._content.include_cta:
+ return __(
+ """
+ Your report/alert was unable to be generated because of the following error: %(text)s
+ Please check your dashboard/chart for errors.
+ %(call_to_action)s
+ """, # noqa: E501
+ text=safe_text,
+ url=self._content.url,
+ call_to_action=self._get_call_to_action(),
+ )
return __(
"""
Your report/alert was unable to be generated because of the following error: %(text)s
Please check your dashboard/chart for errors.
- %(call_to_action)s
""", # noqa: E501
text=safe_text,
- url=self._content.url,
- call_to_action=call_to_action,
)
def _retry_error_template(self, text: str) -> str:
@@ -173,7 +180,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
retries_remaining = (max_attempts or 0) - (attempt or 0)
# pylint: disable=no-member
safe_text = nh3.clean(text, tags=set(), attributes={})
- call_to_action = self._get_call_to_action()
+ cta_tag = self._render_call_to_action_paragraph()
return textwrap.dedent(
f"""
@@ -199,7 +206,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
Retry attempt: {attempt} of {max_attempts}
Retries remaining: {retries_remaining}
Error details: {safe_text}
- {call_to_action}
+ {cta_tag}