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
177 lines
6.9 KiB
Python
177 lines
6.9 KiB
Python
# 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()
|