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.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:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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}")
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user