snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
176
zproject/email_backends.py
Normal file
176
zproject/email_backends.py
Normal file
@@ -0,0 +1,176 @@
|
||||
# https://zulip.readthedocs.io/en/latest/subsystems/email.html#testing-in-a-real-email-client
|
||||
import configparser
|
||||
import logging
|
||||
import smtplib
|
||||
from collections.abc import Sequence
|
||||
from email.message import Message
|
||||
from typing import Any
|
||||
|
||||
import tenacity
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
from django.core.mail.message import EmailAlternative, EmailMessage
|
||||
from django.template import loader
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from typing_extensions import override
|
||||
|
||||
MAX_CONNECTION_TRIES = 3
|
||||
|
||||
# SMTPException is a subclass of OSError, so retry on connection-level
|
||||
# errors (ConnectionError, TimeoutError) and SMTPServerDisconnected
|
||||
# (dropped connection), but not on SMTP protocol errors like
|
||||
# SMTPAuthenticationError, SMTPRecipientsRefused, or SMTPDataError.
|
||||
smtp_connection_backoff = tenacity.retry(
|
||||
wait=tenacity.wait_exponential_jitter(),
|
||||
retry=tenacity.retry_if_exception(
|
||||
lambda exc: (
|
||||
isinstance(exc, OSError)
|
||||
and (
|
||||
not isinstance(exc, smtplib.SMTPException)
|
||||
or isinstance(exc, smtplib.SMTPServerDisconnected)
|
||||
)
|
||||
)
|
||||
),
|
||||
stop=tenacity.stop_after_attempt(MAX_CONNECTION_TRIES),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
def get_forward_address() -> str:
|
||||
config = configparser.ConfigParser()
|
||||
config.read(settings.FORWARD_ADDRESS_CONFIG_FILE)
|
||||
try:
|
||||
return config.get("DEV_EMAIL", "forward_address")
|
||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||
return ""
|
||||
|
||||
|
||||
def set_forward_address(forward_address: str) -> None:
|
||||
config = configparser.ConfigParser()
|
||||
config.read(settings.FORWARD_ADDRESS_CONFIG_FILE)
|
||||
|
||||
if not config.has_section("DEV_EMAIL"):
|
||||
config.add_section("DEV_EMAIL")
|
||||
config.set("DEV_EMAIL", "forward_address", forward_address)
|
||||
|
||||
with open(settings.FORWARD_ADDRESS_CONFIG_FILE, "w") as cfgfile:
|
||||
config.write(cfgfile)
|
||||
|
||||
|
||||
class EmailLogBackEnd(EmailBackend):
|
||||
@staticmethod
|
||||
def log_email(email: EmailMessage) -> None:
|
||||
"""Used in development to record sent emails in a nice HTML log"""
|
||||
html_message: bytes | EmailMessage | Message | str = "Missing HTML message"
|
||||
assert isinstance(email, EmailMultiAlternatives)
|
||||
if len(email.alternatives) > 0:
|
||||
html_message = email.alternatives[0][0]
|
||||
|
||||
context = {
|
||||
"subject": email.subject,
|
||||
"envelope_from": email.from_email,
|
||||
"from_email": email.extra_headers.get("From", email.from_email),
|
||||
"reply_to": email.reply_to,
|
||||
"recipients": email.to,
|
||||
"body": email.body,
|
||||
"date": email.extra_headers.get("Date", "?"),
|
||||
"html_message": html_message,
|
||||
}
|
||||
|
||||
new_email = loader.render_to_string("zerver/email.html", context)
|
||||
|
||||
# Read in the pre-existing log, so that we can add the new entry
|
||||
# at the top.
|
||||
try:
|
||||
with open(settings.EMAIL_CONTENT_LOG_PATH) as f:
|
||||
previous_emails = f.read()
|
||||
except FileNotFoundError:
|
||||
previous_emails = ""
|
||||
|
||||
with open(settings.EMAIL_CONTENT_LOG_PATH, "w+") as f:
|
||||
f.write(new_email + previous_emails)
|
||||
|
||||
@staticmethod
|
||||
def prepare_email_messages_for_forwarding(email_messages: Sequence[EmailMessage]) -> None:
|
||||
localhost_email_images_base_url = settings.ROOT_DOMAIN_URI + "/static/images/emails"
|
||||
czo_email_images_base_url = "https://chat.zulip.org/static/images/emails"
|
||||
|
||||
for email_message in email_messages:
|
||||
assert isinstance(email_message, EmailMultiAlternatives)
|
||||
# Here, we replace the image URLs used in development with
|
||||
# chat.zulip.org URLs, so that web email providers like Gmail
|
||||
# will be able to fetch the illustrations used in the emails.
|
||||
assert isinstance(email_message.alternatives[0], EmailAlternative)
|
||||
original_content = email_message.alternatives[0].content
|
||||
original_mimetype = email_message.alternatives[0].mimetype
|
||||
assert isinstance(original_content, str)
|
||||
email_message.alternatives[0] = EmailAlternative(
|
||||
content=original_content.replace(
|
||||
localhost_email_images_base_url, czo_email_images_base_url
|
||||
),
|
||||
mimetype=original_mimetype,
|
||||
)
|
||||
|
||||
email_message.to = [get_forward_address()]
|
||||
|
||||
# This wrapper function exists to allow tests easily to mock the
|
||||
# step of trying to send the emails. Previously, we had mocked
|
||||
# Django's connection.send_messages(), which caused unexplained
|
||||
# test failures when running test-backend at very high
|
||||
# concurrency.
|
||||
def _do_send_messages(self, email_messages: Sequence[EmailMessage]) -> int:
|
||||
return super().send_messages(email_messages) # nocoverage
|
||||
|
||||
@override
|
||||
def send_messages(self, email_messages: Sequence[EmailMessage]) -> int:
|
||||
num_sent = len(email_messages)
|
||||
if get_forward_address():
|
||||
self.prepare_email_messages_for_forwarding(email_messages)
|
||||
num_sent = self._do_send_messages(email_messages)
|
||||
|
||||
if settings.DEVELOPMENT_LOG_EMAILS:
|
||||
for email in email_messages:
|
||||
self.log_email(email)
|
||||
email_log_url = settings.ROOT_DOMAIN_URI + "/emails"
|
||||
logging.info("Emails sent in development are available at %s", email_log_url)
|
||||
return num_sent
|
||||
|
||||
|
||||
class PersistentSMTPEmailBackend(EmailBackend):
|
||||
@override
|
||||
def open(self, **kwargs: Any) -> bool:
|
||||
if super().open(**kwargs):
|
||||
self.opened_at = timezone_now()
|
||||
# Always return False so that Django's send_messages does not
|
||||
# auto-close the connection after sending.
|
||||
return False
|
||||
|
||||
@smtp_connection_backoff
|
||||
def ensure_connected(self) -> None:
|
||||
"""Open the connection if needed, and validate that it is
|
||||
still alive, reconnecting if necessary."""
|
||||
self.open()
|
||||
self._validate_or_reconnect()
|
||||
|
||||
def _validate_or_reconnect(self) -> None:
|
||||
"""Validate that the existing SMTP connection is still alive,
|
||||
reconnecting if necessary. Called with backoff protection,
|
||||
not from open()."""
|
||||
status = None
|
||||
time_elapsed = (timezone_now() - self.opened_at).total_seconds() / 60
|
||||
if (
|
||||
settings.EMAIL_MAX_CONNECTION_LIFETIME_IN_MINUTES is None
|
||||
or time_elapsed <= settings.EMAIL_MAX_CONNECTION_LIFETIME_IN_MINUTES
|
||||
):
|
||||
# No-op to ensure that we don't return a connection that has been
|
||||
# closed by the mail server.
|
||||
try:
|
||||
assert self.connection is not None
|
||||
status = self.connection.noop()[0]
|
||||
except Exception:
|
||||
pass
|
||||
if status is None or status != 250:
|
||||
# Close and connect again.
|
||||
super().close()
|
||||
self.open()
|
||||
Reference in New Issue
Block a user