send_custom_email: Split out the sending to remote servers.

This commit is contained in:
Alex Vandiver
2023-12-13 22:05:21 +00:00
committed by Tim Abbott
parent 62e6b10ecd
commit 791d66fe28
3 changed files with 93 additions and 68 deletions

View File

@@ -8,7 +8,7 @@ from email.headerregistry import Address
from email.parser import Parser from email.parser import Parser
from email.policy import default from email.policy import default
from email.utils import formataddr, parseaddr from email.utils import formataddr, parseaddr
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple, Union from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
import backoff import backoff
import css_inline import css_inline
@@ -32,6 +32,9 @@ from zerver.lib.logging_util import log_to_file
from zerver.models import EMAIL_TYPES, Realm, ScheduledEmail, UserProfile, get_user_profile_by_id from zerver.models import EMAIL_TYPES, Realm, ScheduledEmail, UserProfile, get_user_profile_by_id
from zproject.email_backends import EmailLogBackEnd, get_forward_address from zproject.email_backends import EmailLogBackEnd, get_forward_address
if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteZulipServer
MAX_CONNECTION_TRIES = 3 MAX_CONNECTION_TRIES = 3
## Logging setup ## ## Logging setup ##
@@ -507,26 +510,15 @@ def get_header(option: Optional[str], header: Optional[str], name: str) -> str:
return str(option or header) return str(option or header)
def send_custom_email( def custom_email_sender(
users: QuerySet[UserProfile], markdown_template_path: str,
*,
target_emails: Sequence[str] = [],
dry_run: bool, dry_run: bool,
options: Dict[str, str], subject: Optional[str] = None,
add_context: Optional[Callable[[Dict[str, object], UserProfile], None]] = None, from_name: Optional[str] = None,
) -> None: reply_to: Optional[str] = None,
""" **kwargs: Any,
Helper for `manage.py send_custom_email`. ) -> Callable[..., None]:
with open(markdown_template_path) as f:
Can be used directly with from a management shell with
send_custom_email(user_profile_list, dict(
markdown_template_path="/path/to/markdown/file.md",
subject="Email subject",
from_name="Sender Name")
)
"""
with open(options["markdown_template_path"]) as f:
text = f.read() text = f.read()
parsed_email_template = Parser(policy=default).parsestr(text) parsed_email_template = Parser(policy=default).parsestr(text)
email_template_hash = hashlib.sha256(text.encode()).hexdigest()[0:32] email_template_hash = hashlib.sha256(text.encode()).hexdigest()[0:32]
@@ -559,9 +551,45 @@ def send_custom_email(
f.write(base_template.read().replace("{{ rendered_input }}", rendered_input)) f.write(base_template.read().replace("{{ rendered_input }}", rendered_input))
with open(subject_path, "w") as f: with open(subject_path, "w") as f:
f.write(get_header(options.get("subject"), parsed_email_template.get("subject"), "subject")) f.write(get_header(subject, parsed_email_template.get("subject"), "subject"))
# Finally, we send the actual emails. def send_one_email(
context: Dict[str, Any], to_user_id: Optional[int] = None, to_email: Optional[str] = None
) -> None:
assert to_user_id is not None or to_email is not None
with suppress(EmailNotDeliveredError):
send_email(
email_id,
to_user_ids=[to_user_id] if to_user_id is not None else None,
to_emails=[to_email] if to_email is not None else None,
from_address=FromAddress.SUPPORT,
reply_to_email=reply_to,
from_name=get_header(from_name, parsed_email_template.get("from"), "from_name"),
context=context,
dry_run=dry_run,
)
return send_one_email
def send_custom_email(
users: QuerySet[UserProfile],
*,
dry_run: bool,
options: Dict[str, str],
add_context: Optional[Callable[[Dict[str, object], UserProfile], None]] = None,
) -> None:
"""
Helper for `manage.py send_custom_email`.
Can be used directly with from a management shell with
send_custom_email(user_profile_list, dict(
markdown_template_path="/path/to/markdown/file.md",
subject="Email subject",
from_name="Sender Name")
)
"""
email_sender = custom_email_sender(**options, dry_run=dry_run)
for user_profile in users.select_related("realm").order_by("id"): for user_profile in users.select_related("realm").order_by("id"):
context: Dict[str, object] = { context: Dict[str, object] = {
"realm": user_profile.realm, "realm": user_profile.realm,
@@ -571,35 +599,30 @@ def send_custom_email(
} }
if add_context is not None: if add_context is not None:
add_context(context, user_profile) add_context(context, user_profile)
with suppress(EmailNotDeliveredError): email_sender(
send_email( to_user_id=user_profile.id,
email_id, context=context,
to_user_ids=[user_profile.id], )
from_address=FromAddress.SUPPORT,
reply_to_email=options.get("reply_to"),
from_name=get_header(
options.get("from_name"), parsed_email_template.get("from"), "from_name"
),
context=context,
dry_run=dry_run,
)
if dry_run: if dry_run:
break break
# Now send emails to any recipients without a user account.
# This code path is intended for rare RemoteZulipServer emails. def send_custom_server_email(
for email_address in target_emails: remote_servers: QuerySet["RemoteZulipServer"],
send_email( *,
email_id, dry_run: bool,
to_emails=[email_address], options: Dict[str, str],
from_address=FromAddress.SUPPORT, add_context: Optional[Callable[[Dict[str, object], "RemoteZulipServer"], None]] = None,
reply_to_email=options.get("reply_to"), ) -> None:
from_name=get_header( email_sender = custom_email_sender(**options, dry_run=dry_run)
options.get("from_name"), parsed_email_template.get("from"), "from_name"
), for server in remote_servers:
context={"remote_server_email": True}, email_sender(
dry_run=dry_run, to_email=server.contact_email,
context={
"remote_server_email": True,
},
) )
if dry_run: if dry_run:

View File

@@ -1,5 +1,5 @@
from argparse import ArgumentParser from argparse import ArgumentParser
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, Optional
import orjson import orjson
from django.conf import settings from django.conf import settings
@@ -8,9 +8,12 @@ from typing_extensions import override
from confirmation.models import one_click_unsubscribe_link from confirmation.models import one_click_unsubscribe_link
from zerver.lib.management import ZulipBaseCommand from zerver.lib.management import ZulipBaseCommand
from zerver.lib.send_email import send_custom_email from zerver.lib.send_email import send_custom_email, send_custom_server_email
from zerver.models import Realm, UserProfile from zerver.models import Realm, UserProfile
if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteZulipServer
class Command(ZulipBaseCommand): class Command(ZulipBaseCommand):
help = """ help = """
@@ -89,10 +92,18 @@ class Command(ZulipBaseCommand):
def handle( def handle(
self, *args: Any, dry_run: bool = False, admins_only: bool = False, **options: str self, *args: Any, dry_run: bool = False, admins_only: bool = False, **options: str
) -> None: ) -> None:
target_emails: List[str] = []
users: QuerySet[UserProfile] = UserProfile.objects.none() users: QuerySet[UserProfile] = UserProfile.objects.none()
add_context: Optional[Callable[[Dict[str, object], UserProfile], None]] = None add_context: Optional[Callable[[Dict[str, object], UserProfile], None]] = None
if options["remote_servers"]:
servers = RemoteZulipServer.objects.filter(deactivated=False)
send_custom_server_email(servers, dry_run=dry_run, options=options)
if dry_run:
print("Would send the above email to:")
for server in servers:
print(f" {server.contact_email} ({server.hostname})")
return
if options["entire_server"]: if options["entire_server"]:
users = UserProfile.objects.filter( users = UserProfile.objects.filter(
is_active=True, is_bot=False, is_mirror_dummy=False, realm__deactivated=False is_active=True, is_bot=False, is_mirror_dummy=False, realm__deactivated=False
@@ -113,16 +124,7 @@ class Command(ZulipBaseCommand):
context["unsubscribe_link"] = one_click_unsubscribe_link(user, "marketing") context["unsubscribe_link"] = one_click_unsubscribe_link(user, "marketing")
add_context = add_marketing_unsubscribe add_context = add_marketing_unsubscribe
elif options["remote_servers"]:
from zilencer.models import RemoteZulipServer
target_emails = list(
set(
RemoteZulipServer.objects.filter(deactivated=False).values_list(
"contact_email", flat=True
)
)
)
elif options["all_sponsored_org_admins"]: elif options["all_sponsored_org_admins"]:
# Sends at most one copy to each email address, even if it # Sends at most one copy to each email address, even if it
# is an administrator in several organizations. # is an administrator in several organizations.
@@ -162,7 +164,6 @@ class Command(ZulipBaseCommand):
) )
send_custom_email( send_custom_email(
users, users,
target_emails=target_emails,
dry_run=dry_run, dry_run=dry_run,
options=options, options=options,
add_context=add_context, add_context=add_context,
@@ -172,5 +173,3 @@ class Command(ZulipBaseCommand):
print("Would send the above email to:") print("Would send the above email to:")
for user in users: for user in users:
print(f" {user.delivery_email} ({user.realm.string_id})") print(f" {user.delivery_email} ({user.realm.string_id})")
for email in target_emails:
print(f" {email}")

View File

@@ -16,9 +16,14 @@ from zerver.lib.email_notifications import (
get_onboarding_email_schedule, get_onboarding_email_schedule,
send_account_registered_email, send_account_registered_email,
) )
from zerver.lib.send_email import deliver_scheduled_emails, send_custom_email from zerver.lib.send_email import (
deliver_scheduled_emails,
send_custom_email,
send_custom_server_email,
)
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.models import Realm, ScheduledEmail, UserProfile, get_realm from zerver.models import Realm, ScheduledEmail, UserProfile, get_realm
from zilencer.models import RemoteZulipServer
class TestCustomEmails(ZulipTestCase): class TestCustomEmails(ZulipTestCase):
@@ -60,11 +65,9 @@ class TestCustomEmails(ZulipTestCase):
email_subject = "subject_test" email_subject = "subject_test"
reply_to = "reply_to_test" reply_to = "reply_to_test"
from_name = "from_name_test" from_name = "from_name_test"
contact_email = "zulip-admin@example.com"
markdown_template_path = "templates/corporate/policies/index.md" markdown_template_path = "templates/corporate/policies/index.md"
send_custom_email( send_custom_server_email(
UserProfile.objects.none(), remote_servers=RemoteZulipServer.objects.all(),
target_emails=[contact_email],
dry_run=False, dry_run=False,
options={ options={
"markdown_template_path": markdown_template_path, "markdown_template_path": markdown_template_path,
@@ -76,7 +79,7 @@ class TestCustomEmails(ZulipTestCase):
self.assert_length(mail.outbox, 1) self.assert_length(mail.outbox, 1)
msg = mail.outbox[0] msg = mail.outbox[0]
self.assertEqual(msg.subject, email_subject) self.assertEqual(msg.subject, email_subject)
self.assertEqual(msg.to, [contact_email]) self.assertEqual(msg.to, ["remotezulipserver@zulip.com"])
self.assert_length(msg.reply_to, 1) self.assert_length(msg.reply_to, 1)
self.assertEqual(msg.reply_to[0], reply_to) self.assertEqual(msg.reply_to[0], reply_to)
self.assertNotIn("{% block content %}", msg.body) self.assertNotIn("{% block content %}", msg.body)