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
16 changes: 12 additions & 4 deletions superset/reports/notifications/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from dataclasses import dataclass
from datetime import datetime
from email.utils import make_msgid, parseaddr
from functools import cached_property
from typing import Any, Optional

import nh3
Expand Down Expand Up @@ -59,7 +60,7 @@
"ul",
}.union(TABLE_TAGS)

ALLOWED_TABLE_ATTRIBUTES = {tag: TABLE_ATTRIBUTES for tag in TABLE_TAGS}
ALLOWED_TABLE_ATTRIBUTES = dict.fromkeys(TABLE_TAGS, TABLE_ATTRIBUTES)
ALLOWED_ATTRIBUTES = {
"a": {"href", "title"},
"abbr": {"title"},
Expand All @@ -83,7 +84,13 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
"""

type = ReportRecipientType.EMAIL
now = datetime.now(timezone("UTC"))

@cached_property
def _send_time(self) -> datetime:
"""Captured once per notification instance so subject, CSV filename and
PDF filename share a consistent timestamp across the same send.
"""
return datetime.now(timezone("UTC"))

@property
def _name(self) -> str:
Expand Down Expand Up @@ -215,9 +222,10 @@ def _get_subject(self) -> str:
def _parse_name(self, name: str) -> str:
"""If user add a date format to the subject, parse it to the real date
This feature is hidden behind a feature flag `DATE_FORMAT_IN_EMAIL_SUBJECT`
by default it is disabled
by default it is disabled. Uses ``_send_time`` so subject and attachment
filenames stay aligned across the same send.
"""
return self.now.strftime(name)
return self._send_time.strftime(name)

def _get_call_to_action(self) -> str:
return __(current_app.config["EMAIL_REPORTS_CTA"])
Expand Down
100 changes: 100 additions & 0 deletions tests/unit_tests/reports/notifications/email_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from unittest.mock import patch

import pandas as pd
from pytz import timezone
Expand Down Expand Up @@ -100,3 +101,102 @@ def test_email_subject_with_datetime() -> None:
)._get_subject()
assert datetime_pattern not in subject
assert now.strftime(datetime_pattern) in subject


@with_feature_flags(DATE_FORMAT_IN_EMAIL_SUBJECT=True)
def test_email_subject_datetime_evaluated_per_send() -> None:
"""
Regression test for #35908: two notifications constructed at different
times must carry their own send timestamp, not a value frozen to the
time the notification class was first imported.
"""
from superset.reports.models import ReportRecipients, ReportRecipientType
from superset.reports.notifications.base import NotificationContent
from superset.reports.notifications.email import EmailNotification

def make_content() -> NotificationContent:
return NotificationContent(
name="report %Y-%m-%d %H:%M",
embedded_data=pd.DataFrame({"A": [1]}),
description="",
header_data={
"notification_format": "PNG",
"notification_type": "Alert",
"owners": [1],
"notification_source": None,
"chart_id": None,
"dashboard_id": None,
"slack_channels": None,
"execution_id": "test-execution-id",
},
)

first_send = datetime(2026, 1, 1, 10, 0, 0, tzinfo=timezone("UTC"))
second_send = datetime(2026, 1, 1, 11, 0, 0, tzinfo=timezone("UTC"))

with patch("superset.reports.notifications.email.datetime") as mock_datetime:
mock_datetime.now.return_value = first_send
first_subject = EmailNotification(
recipient=ReportRecipients(type=ReportRecipientType.EMAIL),
content=make_content(),
)._get_subject()

mock_datetime.now.return_value = second_send
second_subject = EmailNotification(
recipient=ReportRecipients(type=ReportRecipientType.EMAIL),
content=make_content(),
)._get_subject()

assert "2026-01-01 10:00" in first_subject
assert "2026-01-01 11:00" in second_subject
assert first_subject != second_subject


@with_feature_flags(DATE_FORMAT_IN_EMAIL_SUBJECT=True)
def test_email_subject_datetime_consistent_within_single_send() -> None:
"""
Regression test for the codeant-ai review on #40285: within a single
notification instance, `_name` is read multiple times (subject + CSV/PDF
attachment filenames). All reads must return the same timestamp so the
subject and attachment names don't disagree if execution crosses a
second/minute boundary.
"""
from superset.reports.models import ReportRecipients, ReportRecipientType
from superset.reports.notifications.base import NotificationContent
from superset.reports.notifications.email import EmailNotification

content = NotificationContent(
name="report %Y-%m-%d %H:%M:%S",
embedded_data=pd.DataFrame({"A": [1]}),
description="",
header_data={
"notification_format": "PNG",
"notification_type": "Alert",
"owners": [1],
"notification_source": None,
"chart_id": None,
"dashboard_id": None,
"slack_channels": None,
"execution_id": "test-execution-id",
},
)

notification = EmailNotification(
recipient=ReportRecipients(type=ReportRecipientType.EMAIL), content=content
)

construct_time = datetime(2026, 1, 1, 10, 0, 0, tzinfo=timezone("UTC"))
much_later = datetime(2026, 1, 1, 23, 59, 59, tzinfo=timezone("UTC"))

with patch("superset.reports.notifications.email.datetime") as mock_datetime:
mock_datetime.now.return_value = construct_time
first_read = notification._name

# Simulate execution crossing a boundary before the next _name read.
mock_datetime.now.return_value = much_later
second_read = notification._name

# Both reads must return the construct-time value — _send_time is cached
# on the instance so subject and attachment names stay aligned.
assert first_read == second_read
assert "2026-01-01 10:00:00" in first_read
Loading