mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	send_custom_email: Split out the sending to remote servers.
This commit is contained in:
		
				
					committed by
					
						
						Tim Abbott
					
				
			
			
				
	
			
			
			
						parent
						
							62e6b10ecd
						
					
				
				
					commit
					791d66fe28
				
			@@ -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"
 | 
			
		||||
                ),
 | 
			
		||||
        email_sender(
 | 
			
		||||
            to_user_id=user_profile.id,
 | 
			
		||||
            context=context,
 | 
			
		||||
                dry_run=dry_run,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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}")
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user