diff --git a/zerver/lib/email_notifications.py b/zerver/lib/email_notifications.py index a75485dfb2..a955283581 100644 --- a/zerver/lib/email_notifications.py +++ b/zerver/lib/email_notifications.py @@ -16,6 +16,7 @@ import lxml.html from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_backends +from django.utils.timezone import get_current_timezone_name as timezone_get_current_timezone_name from django.utils.timezone import now as timezone_now from django.utils.translation import gettext as _ from django.utils.translation import override as override_language @@ -32,6 +33,7 @@ from zerver.lib.queue import queue_event_on_commit from zerver.lib.send_email import EMAIL_DATE_FORMAT, FromAddress, send_future_email from zerver.lib.soft_deactivation import soft_reactivate_if_personal_notification from zerver.lib.tex import change_katex_to_raw_latex +from zerver.lib.timestamp import format_datetime_to_string from zerver.lib.timezone import canonicalize_timezone from zerver.lib.topic import get_topic_display_name, get_topic_resolution_and_bare_name from zerver.lib.url_encoding import ( @@ -183,6 +185,25 @@ def fix_spoilers_in_text(content: str, language: str) -> str: return "\n".join(output) +def convert_time_to_local_timezone(fragment: lxml.html.HtmlElement, user: UserProfile) -> None: + user_tz = user.timezone or timezone_get_current_timezone_name() + time_elements = fragment.findall(".//time") + + for time_elem in time_elements: + datetime_str = time_elem.get("datetime") + if not datetime_str: + # We expect there to always be a datetime attribute. + continue # nocoverage + try: + dt_utc = timezone_now().strptime(datetime_str, "%Y-%m-%dT%H:%M:%S%z") + dt_local = dt_utc.astimezone(zoneinfo.ZoneInfo(canonicalize_timezone(user_tz))) + formatted_time = format_datetime_to_string(dt_local, user.twenty_four_hour_time) + time_elem.text = formatted_time + except Exception as e: + logger.warning("Failed to convert time element '%s': %s", datetime_str, e) + continue + + def add_quote_prefix_in_text(content: str) -> str: """ We add quote prefix ">" to each line of the message in plain text @@ -260,6 +281,7 @@ def build_message_list( fix_emojis(fragment, user.emojiset) fix_spoilers_in_html(fragment, user.default_language) change_katex_to_raw_latex(fragment) + convert_time_to_local_timezone(fragment, user) html = Markup(lxml.html.tostring(fragment, encoding="unicode")) if sender: diff --git a/zerver/tests/test_message_notification_emails.py b/zerver/tests/test_message_notification_emails.py index 097eb2b939..ba3e428336 100644 --- a/zerver/tests/test_message_notification_emails.py +++ b/zerver/tests/test_message_notification_emails.py @@ -1404,6 +1404,58 @@ class TestMessageNotificationEmails(ZulipTestCase): mail.outbox[0].alternatives[0][0], ) + def test_datetime_conversion_in_missed_message_content(self) -> None: + hamlet = self.example_user("hamlet") + + get_or_create_direct_message_group(id_list=[hamlet.id]) + + # Normal message with timestamp. + msg_id = self.send_personal_message( + hamlet, hamlet, "Meeting at ", read_by_sender=False + ) + + self.handle_missedmessage_emails( + hamlet.id, + {msg_id: MissedMessageData(trigger=NotificationTriggers.DIRECT_MESSAGE)}, + ) + + assert isinstance(mail.outbox[0], EmailMultiAlternatives) + self.assertEqual(mail.outbox[0].subject, "DMs with King Hamlet") + assert isinstance(mail.outbox[0].alternatives[0][0], str) + # Sender name is not appended for missed 1:1 direct messages + self.assertEqual( + "> Meeting at \n\n--\n\nReply", mail.outbox[0].body[:56] + ) + self.assertIn( + '

Meeting at

', + mail.outbox[0].alternatives[0][0], + ) + + # The timestamp is not formatted correctly. + msg_id = self.send_personal_message( + hamlet, hamlet, "Meeting at ", read_by_sender=False + ) + + with ( + mock.patch( + "zerver.lib.email_notifications.format_datetime_to_string", + side_effect=ValueError("Invalid datetime format"), + ), + self.assertLogs(level="WARNING") as m, + ): + self.handle_missedmessage_emails( + hamlet.id, + {msg_id: MissedMessageData(trigger=NotificationTriggers.DIRECT_MESSAGE)}, + ) + + self.assertEqual( + m.output, + [ + "WARNING:zerver.lib.email_notifications:Failed to convert time element " + "'2025-09-30T16:30:00Z': Invalid datetime format", + ], + ) + def test_multiple_missed_personal_messages(self) -> None: hamlet = self.example_user("hamlet") msg_id_1 = self.send_personal_message(