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.policy import default
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 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 zproject.email_backends import EmailLogBackEnd, get_forward_address
if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteZulipServer
MAX_CONNECTION_TRIES = 3
## Logging setup ##
@@ -507,26 +510,15 @@ def get_header(option: Optional[str], header: Optional[str], name: str) -> str:
return str(option or header)
def send_custom_email(
users: QuerySet[UserProfile],
*,
target_emails: Sequence[str] = [],
def custom_email_sender(
markdown_template_path: str,
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")
)
"""
with open(options["markdown_template_path"]) as f:
subject: Optional[str] = None,
from_name: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs: Any,
) -> Callable[..., None]:
with open(markdown_template_path) as f:
text = f.read()
parsed_email_template = Parser(policy=default).parsestr(text)
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))
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"):
context: Dict[str, object] = {
"realm": user_profile.realm,
@@ -571,35 +599,30 @@ def send_custom_email(
}
if add_context is not None:
add_context(context, user_profile)
with suppress(EmailNotDeliveredError):
send_email(
email_id,
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,
)
email_sender(
to_user_id=user_profile.id,
context=context,
)
if dry_run:
break
# Now send emails to any recipients without a user account.
# This code path is intended for rare RemoteZulipServer emails.
for email_address in target_emails:
send_email(
email_id,
to_emails=[email_address],
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={"remote_server_email": True},
dry_run=dry_run,
def send_custom_server_email(
remote_servers: QuerySet["RemoteZulipServer"],
*,
dry_run: bool,
options: Dict[str, str],
add_context: Optional[Callable[[Dict[str, object], "RemoteZulipServer"], None]] = None,
) -> None:
email_sender = custom_email_sender(**options, dry_run=dry_run)
for server in remote_servers:
email_sender(
to_email=server.contact_email,
context={
"remote_server_email": True,
},
)
if dry_run:

View File

@@ -1,5 +1,5 @@
from argparse import ArgumentParser
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, Optional
import orjson
from django.conf import settings
@@ -8,9 +8,12 @@ from typing_extensions import override
from confirmation.models import one_click_unsubscribe_link
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
if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteZulipServer
class Command(ZulipBaseCommand):
help = """
@@ -89,10 +92,18 @@ class Command(ZulipBaseCommand):
def handle(
self, *args: Any, dry_run: bool = False, admins_only: bool = False, **options: str
) -> None:
target_emails: List[str] = []
users: QuerySet[UserProfile] = UserProfile.objects.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"]:
users = UserProfile.objects.filter(
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")
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"]:
# Sends at most one copy to each email address, even if it
# is an administrator in several organizations.
@@ -162,7 +164,6 @@ class Command(ZulipBaseCommand):
)
send_custom_email(
users,
target_emails=target_emails,
dry_run=dry_run,
options=options,
add_context=add_context,
@@ -172,5 +173,3 @@ class Command(ZulipBaseCommand):
print("Would send the above email to:")
for user in users:
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,
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.models import Realm, ScheduledEmail, UserProfile, get_realm
from zilencer.models import RemoteZulipServer
class TestCustomEmails(ZulipTestCase):
@@ -60,11 +65,9 @@ class TestCustomEmails(ZulipTestCase):
email_subject = "subject_test"
reply_to = "reply_to_test"
from_name = "from_name_test"
contact_email = "zulip-admin@example.com"
markdown_template_path = "templates/corporate/policies/index.md"
send_custom_email(
UserProfile.objects.none(),
target_emails=[contact_email],
send_custom_server_email(
remote_servers=RemoteZulipServer.objects.all(),
dry_run=False,
options={
"markdown_template_path": markdown_template_path,
@@ -76,7 +79,7 @@ class TestCustomEmails(ZulipTestCase):
self.assert_length(mail.outbox, 1)
msg = mail.outbox[0]
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.assertEqual(msg.reply_to[0], reply_to)
self.assertNotIn("{% block content %}", msg.body)