send_email: Set the Date header according to local enqueue time.

Email clients tend to sort emails by the "Date" header, which is not
when the email was received -- emails can be arbitrarily delayed
during relaying.  Messages without a Date header (as all Zulip
messages previously) have one inserted by the first mailserver they
encounter.  As there are now multiple email-sending queues, we would
like the view of the database, as presented by the emails that are
sent out, to be consistent based on the Date header, which may not be
the same as when the client gets the email in their inbox.

Insert a Date header of when the Zulip system inserted the data into
the local queue, as that encodes when the full information was pulled
from the database.  This also opens the door to multiple workers
servicing the email_senders queue, to limit backlogging during large
notifications, without having to worry about inconsistent delivery
order between those two workers.

Messages which are sent synchronously via `send_email()` get a Date
header of when we attempt to send the message; this is, in practice,
no different from Django's default behaviour of doing so, but makes
the behaviour slightly more consistent.
This commit is contained in:
Alex Vandiver
2025-03-06 16:38:55 +00:00
committed by Tim Abbott
parent 0e152a128f
commit 721fd26442
7 changed files with 24 additions and 3 deletions

View File

@@ -2,6 +2,7 @@
<h4>Envelope-From: {{ envelope_from }}</h4> <h4>Envelope-From: {{ envelope_from }}</h4>
{% endif %} {% endif %}
<h4>From: {{ from_email }}</h4> <h4>From: {{ from_email }}</h4>
<h4>Date: {{ date }}</h4>
{% if reply_to %} {% if reply_to %}
<h4>Reply to: <h4>Reply to:
{% for email in reply_to %} {% for email in reply_to %}

View File

@@ -30,6 +30,7 @@ from zerver.lib.exceptions import InvitationError
from zerver.lib.invites import notify_invites_changed from zerver.lib.invites import notify_invites_changed
from zerver.lib.queue import queue_event_on_commit from zerver.lib.queue import queue_event_on_commit
from zerver.lib.send_email import ( from zerver.lib.send_email import (
EMAIL_DATE_FORMAT,
FromAddress, FromAddress,
clear_scheduled_invitation_emails, clear_scheduled_invitation_emails,
maybe_remove_from_suppression_list, maybe_remove_from_suppression_list,
@@ -468,6 +469,7 @@ def do_send_user_invite_email(
"corporate_enabled": settings.CORPORATE_ENABLED, "corporate_enabled": settings.CORPORATE_ENABLED,
}, },
"realm_id": realm.id, "realm_id": realm.id,
"date": event_time.strftime(EMAIL_DATE_FORMAT),
} }
queue_event_on_commit("email_senders", event) queue_event_on_commit("email_senders", event)

View File

@@ -27,7 +27,7 @@ from zerver.lib.markdown.fenced_code import FENCE_RE
from zerver.lib.message import bulk_access_messages from zerver.lib.message import bulk_access_messages
from zerver.lib.notification_data import get_mentioned_user_group from zerver.lib.notification_data import get_mentioned_user_group
from zerver.lib.queue import queue_event_on_commit from zerver.lib.queue import queue_event_on_commit
from zerver.lib.send_email import FromAddress, send_future_email 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.soft_deactivation import soft_reactivate_if_personal_notification
from zerver.lib.tex import change_katex_to_raw_latex from zerver.lib.tex import change_katex_to_raw_latex
from zerver.lib.timezone import canonicalize_timezone from zerver.lib.timezone import canonicalize_timezone
@@ -586,6 +586,8 @@ def do_send_missedmessage_events_reply_in_zulip(
) )
from_address = FromAddress.NOREPLY from_address = FromAddress.NOREPLY
user_tz = user_profile.timezone or settings.TIME_ZONE
local_time = timezone_now().astimezone(zoneinfo.ZoneInfo(canonicalize_timezone(user_tz)))
email_dict = { email_dict = {
"template_prefix": "zerver/emails/missed_message", "template_prefix": "zerver/emails/missed_message",
"to_user_ids": [user_profile.id], "to_user_ids": [user_profile.id],
@@ -593,6 +595,7 @@ def do_send_missedmessage_events_reply_in_zulip(
"from_address": from_address, "from_address": from_address,
"reply_to_email": str(Address(display_name=reply_to_name, addr_spec=reply_to_address)), "reply_to_email": str(Address(display_name=reply_to_name, addr_spec=reply_to_address)),
"context": context, "context": context,
"date": local_time.strftime(EMAIL_DATE_FORMAT),
} }
queue_event_on_commit("email_senders", email_dict) queue_event_on_commit("email_senders", email_dict)

View File

@@ -43,6 +43,7 @@ from zproject.email_backends import EmailLogBackEnd, get_forward_address
if settings.ZILENCER_ENABLED: if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteZulipServer from zilencer.models import RemoteZulipServer
EMAIL_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
MAX_CONNECTION_TRIES = 3 MAX_CONNECTION_TRIES = 3
## Logging setup ## ## Logging setup ##
@@ -94,6 +95,7 @@ def build_email(
from_address: str | None = None, from_address: str | None = None,
reply_to_email: str | None = None, reply_to_email: str | None = None,
language: str | None = None, language: str | None = None,
date: str | None = None,
context: Mapping[str, Any] = {}, context: Mapping[str, Any] = {},
realm: Realm | None = None, realm: Realm | None = None,
) -> EmailMultiAlternatives: ) -> EmailMultiAlternatives:
@@ -124,6 +126,14 @@ def build_email(
# commonly-recognized. # commonly-recognized.
extra_headers = {"X-Auto-Response-Suppress": "All"} extra_headers = {"X-Auto-Response-Suppress": "All"}
if date is None:
# Messages enqueued via the `email_senders` queue provide a
# Date header of when they were enqueued; Django would also
# add a default-now header if we left this off, but doing so
# ourselves here explicitly makes it slightly more consistent.
date = timezone_now().strftime(EMAIL_DATE_FORMAT)
extra_headers["Date"] = date
if realm is not None: if realm is not None:
# formaddr is meant for formatting (display_name, email_address) pair for headers like "To", # formaddr is meant for formatting (display_name, email_address) pair for headers like "To",
# but we can use its utility for formatting the List-Id header, as it follows the same format, # but we can use its utility for formatting the List-Id header, as it follows the same format,
@@ -255,6 +265,7 @@ def send_email(
from_address: str | None = None, from_address: str | None = None,
reply_to_email: str | None = None, reply_to_email: str | None = None,
language: str | None = None, language: str | None = None,
date: str | None = None,
context: Mapping[str, Any] = {}, context: Mapping[str, Any] = {},
realm: Realm | None = None, realm: Realm | None = None,
connection: BaseEmailBackend | None = None, connection: BaseEmailBackend | None = None,
@@ -269,6 +280,7 @@ def send_email(
from_address=from_address, from_address=from_address,
reply_to_email=reply_to_email, reply_to_email=reply_to_email,
language=language, language=language,
date=date,
context=context, context=context,
realm=realm, realm=realm,
) )

View File

@@ -10,7 +10,7 @@ from django.utils.translation import gettext as _
from confirmation.models import one_click_unsubscribe_link from confirmation.models import one_click_unsubscribe_link
from zerver.lib.queue import queue_json_publish_rollback_unsafe from zerver.lib.queue import queue_json_publish_rollback_unsafe
from zerver.lib.send_email import FromAddress from zerver.lib.send_email import EMAIL_DATE_FORMAT, FromAddress
from zerver.lib.timezone import canonicalize_timezone from zerver.lib.timezone import canonicalize_timezone
from zerver.models import UserProfile from zerver.models import UserProfile
@@ -109,6 +109,7 @@ def email_on_new_login(sender: Any, user: UserProfile, request: Any, **kwargs: A
"from_name": FromAddress.security_email_from_name(user_profile=user), "from_name": FromAddress.security_email_from_name(user_profile=user),
"from_address": FromAddress.NOREPLY, "from_address": FromAddress.NOREPLY,
"context": context, "context": context,
"date": local_time.strftime(EMAIL_DATE_FORMAT),
} }
queue_json_publish_rollback_unsafe("email_senders", email_dict) queue_json_publish_rollback_unsafe("email_senders", email_dict)

View File

@@ -65,7 +65,7 @@ from zerver.lib.remote_server import (
) )
from zerver.lib.request import RequestNotes from zerver.lib.request import RequestNotes
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.send_email import FromAddress from zerver.lib.send_email import EMAIL_DATE_FORMAT, FromAddress
from zerver.lib.timestamp import timestamp_to_datetime from zerver.lib.timestamp import timestamp_to_datetime
from zerver.lib.typed_endpoint import ( from zerver.lib.typed_endpoint import (
ApnsAppId, ApnsAppId,
@@ -1192,6 +1192,7 @@ def update_remote_realm_data_for_server(
"template_prefix": "zerver/emails/internal_billing_notice", "template_prefix": "zerver/emails/internal_billing_notice",
"to_emails": [BILLING_SUPPORT_EMAIL], "to_emails": [BILLING_SUPPORT_EMAIL],
"from_address": FromAddress.tokenized_no_reply_address(), "from_address": FromAddress.tokenized_no_reply_address(),
"date": timezone_now().strftime(EMAIL_DATE_FORMAT),
} }
for context in new_locally_deleted_remote_realms_on_paid_plan_contexts: for context in new_locally_deleted_remote_realms_on_paid_plan_contexts:
email_dict["context"] = context email_dict["context"] = context

View File

@@ -49,6 +49,7 @@ class EmailLogBackEnd(EmailBackend):
"reply_to": email.reply_to, "reply_to": email.reply_to,
"recipients": email.to, "recipients": email.to,
"body": email.body, "body": email.body,
"date": email.extra_headers.get("Date", "?"),
"html_message": html_message, "html_message": html_message,
} }