mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	Changed the requirements for UserProfile in order to allow use of the formataddr function in send_mail.py. Converted send_email to use formataddr in conjunction with the commit that strengthened requirements for full_name, such that they can now be used in the to field of emails. Fixes #4676.
		
			
				
	
	
		
			203 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			203 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
from django.conf import settings
 | 
						|
from django.core.mail import EmailMultiAlternatives
 | 
						|
from django.template import loader
 | 
						|
from django.utils.timezone import now as timezone_now
 | 
						|
from django.utils.translation import override as override_language
 | 
						|
from django.template.exceptions import TemplateDoesNotExist
 | 
						|
from zerver.models import ScheduledEmail, get_user_profile_by_id, \
 | 
						|
    EMAIL_TYPES, Realm
 | 
						|
 | 
						|
import datetime
 | 
						|
from email.utils import parseaddr, formataddr
 | 
						|
import logging
 | 
						|
import ujson
 | 
						|
 | 
						|
import os
 | 
						|
from typing import Any, Dict, List, Mapping, Optional, Tuple
 | 
						|
 | 
						|
from zerver.lib.logging_util import log_to_file
 | 
						|
from confirmation.models import generate_key
 | 
						|
 | 
						|
## Logging setup ##
 | 
						|
 | 
						|
logger = logging.getLogger('zulip.send_email')
 | 
						|
log_to_file(logger, settings.EMAIL_LOG_PATH)
 | 
						|
 | 
						|
class FromAddress:
 | 
						|
    SUPPORT = parseaddr(settings.ZULIP_ADMINISTRATOR)[1]
 | 
						|
    NOREPLY = parseaddr(settings.NOREPLY_EMAIL_ADDRESS)[1]
 | 
						|
 | 
						|
    # Generates an unpredictable noreply address.
 | 
						|
    @staticmethod
 | 
						|
    def tokenized_no_reply_address() -> str:
 | 
						|
        if settings.ADD_TOKENS_TO_NOREPLY_ADDRESS:
 | 
						|
            return parseaddr(settings.TOKENIZED_NOREPLY_EMAIL_ADDRESS)[1].format(token=generate_key())
 | 
						|
        return FromAddress.NOREPLY
 | 
						|
 | 
						|
def build_email(template_prefix: str, to_user_ids: Optional[List[int]]=None,
 | 
						|
                to_emails: Optional[List[str]]=None, from_name: Optional[str]=None,
 | 
						|
                from_address: Optional[str]=None, reply_to_email: Optional[str]=None,
 | 
						|
                language: Optional[str]=None, context: Optional[Dict[str, Any]]=None
 | 
						|
                ) -> EmailMultiAlternatives:
 | 
						|
    # Callers should pass exactly one of to_user_id and to_email.
 | 
						|
    assert (to_user_ids is None) ^ (to_emails is None)
 | 
						|
    if to_user_ids is not None:
 | 
						|
        to_users = [get_user_profile_by_id(to_user_id) for to_user_id in to_user_ids]
 | 
						|
        to_emails = [formataddr((to_user.full_name, to_user.delivery_email)) for to_user in to_users]
 | 
						|
 | 
						|
    if context is None:
 | 
						|
        context = {}
 | 
						|
 | 
						|
    context.update({
 | 
						|
        'support_email': FromAddress.SUPPORT,
 | 
						|
        'email_images_base_uri': settings.ROOT_DOMAIN_URI + '/static/images/emails',
 | 
						|
        'physical_address': settings.PHYSICAL_ADDRESS,
 | 
						|
    })
 | 
						|
 | 
						|
    def render_templates() -> Tuple[str, str, str]:
 | 
						|
        email_subject = loader.render_to_string(template_prefix + '.subject.txt',
 | 
						|
                                                context=context,
 | 
						|
                                                using='Jinja2_plaintext').strip().replace('\n', '')
 | 
						|
        message = loader.render_to_string(template_prefix + '.txt',
 | 
						|
                                          context=context, using='Jinja2_plaintext')
 | 
						|
 | 
						|
        try:
 | 
						|
            html_message = loader.render_to_string(template_prefix + '.html', context)
 | 
						|
        except TemplateDoesNotExist:
 | 
						|
            emails_dir = os.path.dirname(template_prefix)
 | 
						|
            template = os.path.basename(template_prefix)
 | 
						|
            compiled_template_prefix = os.path.join(emails_dir, "compiled", template)
 | 
						|
            html_message = loader.render_to_string(compiled_template_prefix + '.html', context)
 | 
						|
        return (html_message, message, email_subject)
 | 
						|
 | 
						|
    if not language and to_user_ids is not None:
 | 
						|
        language = to_users[0].default_language
 | 
						|
    if language:
 | 
						|
        with override_language(language):
 | 
						|
            # Make sure that we render the email using the target's native language
 | 
						|
            (html_message, message, email_subject) = render_templates()
 | 
						|
    else:
 | 
						|
        (html_message, message, email_subject) = render_templates()
 | 
						|
        logger.warning("Missing language for email template '{}'".format(template_prefix))
 | 
						|
 | 
						|
    if from_name is None:
 | 
						|
        from_name = "Zulip"
 | 
						|
    if from_address is None:
 | 
						|
        from_address = FromAddress.NOREPLY
 | 
						|
    from_email = formataddr((from_name, from_address))
 | 
						|
    reply_to = None
 | 
						|
    if reply_to_email is not None:
 | 
						|
        reply_to = [reply_to_email]
 | 
						|
    # Remove the from_name in the reply-to for noreply emails, so that users
 | 
						|
    # see "noreply@..." rather than "Zulip" or whatever the from_name is
 | 
						|
    # when they reply in their email client.
 | 
						|
    elif from_address == FromAddress.NOREPLY:
 | 
						|
        reply_to = [FromAddress.NOREPLY]
 | 
						|
 | 
						|
    mail = EmailMultiAlternatives(email_subject, message, from_email, to_emails, reply_to=reply_to)
 | 
						|
    if html_message is not None:
 | 
						|
        mail.attach_alternative(html_message, 'text/html')
 | 
						|
    return mail
 | 
						|
 | 
						|
class EmailNotDeliveredException(Exception):
 | 
						|
    pass
 | 
						|
 | 
						|
# When changing the arguments to this function, you may need to write a
 | 
						|
# migration to change or remove any emails in ScheduledEmail.
 | 
						|
def send_email(template_prefix: str, to_user_ids: Optional[List[int]]=None,
 | 
						|
               to_emails: Optional[List[str]]=None, from_name: Optional[str]=None,
 | 
						|
               from_address: Optional[str]=None, reply_to_email: Optional[str]=None,
 | 
						|
               language: Optional[str]=None, context: Dict[str, Any]={}) -> None:
 | 
						|
    mail = build_email(template_prefix, to_user_ids=to_user_ids, to_emails=to_emails,
 | 
						|
                       from_name=from_name, from_address=from_address,
 | 
						|
                       reply_to_email=reply_to_email, language=language, context=context)
 | 
						|
    template = template_prefix.split("/")[-1]
 | 
						|
    logger.info("Sending %s email to %s" % (template, mail.to))
 | 
						|
 | 
						|
    if mail.send() == 0:
 | 
						|
        logger.error("Error sending %s email to %s" % (template, mail.to))
 | 
						|
        raise EmailNotDeliveredException
 | 
						|
 | 
						|
def send_email_from_dict(email_dict: Mapping[str, Any]) -> None:
 | 
						|
    send_email(**dict(email_dict))
 | 
						|
 | 
						|
def send_future_email(template_prefix: str, realm: Realm, to_user_ids: Optional[List[int]]=None,
 | 
						|
                      to_emails: Optional[List[str]]=None, from_name: Optional[str]=None,
 | 
						|
                      from_address: Optional[str]=None, language: Optional[str]=None,
 | 
						|
                      context: Dict[str, Any]={}, delay: datetime.timedelta=datetime.timedelta(0)) -> None:
 | 
						|
    template_name = template_prefix.split('/')[-1]
 | 
						|
    email_fields = {'template_prefix': template_prefix, 'from_name': from_name, 'from_address': from_address,
 | 
						|
                    'language': language, 'context': context}
 | 
						|
 | 
						|
    if settings.DEVELOPMENT_LOG_EMAILS:
 | 
						|
        send_email(template_prefix, to_user_ids=to_user_ids, to_emails=to_emails, from_name=from_name,
 | 
						|
                   from_address=from_address, language=language, context=context)
 | 
						|
        # For logging the email
 | 
						|
 | 
						|
    assert (to_user_ids is None) ^ (to_emails is None)
 | 
						|
    email = ScheduledEmail.objects.create(
 | 
						|
        type=EMAIL_TYPES[template_name],
 | 
						|
        scheduled_timestamp=timezone_now() + delay,
 | 
						|
        realm=realm,
 | 
						|
        data=ujson.dumps(email_fields))
 | 
						|
 | 
						|
    # We store the recipients in the ScheduledEmail object itself,
 | 
						|
    # rather than the JSON data object, so that we can find and clear
 | 
						|
    # them using clear_scheduled_emails.
 | 
						|
    try:
 | 
						|
        if to_user_ids is not None:
 | 
						|
            email.users.add(*to_user_ids)
 | 
						|
        else:
 | 
						|
            assert to_emails is not None
 | 
						|
            assert(len(to_emails) == 1)
 | 
						|
            email.address = parseaddr(to_emails[0])[1]
 | 
						|
        email.save()
 | 
						|
    except Exception as e:
 | 
						|
        email.delete()
 | 
						|
        raise e
 | 
						|
 | 
						|
def send_email_to_admins(template_prefix: str, realm: Realm, from_name: Optional[str]=None,
 | 
						|
                         from_address: Optional[str]=None, context: Dict[str, Any]={}) -> None:
 | 
						|
    admins = realm.get_human_admin_users()
 | 
						|
    admin_user_ids = [admin.id for admin in admins]
 | 
						|
    send_email(template_prefix, to_user_ids=admin_user_ids, from_name=from_name,
 | 
						|
               from_address=from_address, context=context)
 | 
						|
 | 
						|
def clear_scheduled_invitation_emails(email: str) -> None:
 | 
						|
    """Unlike most scheduled emails, invitation emails don't have an
 | 
						|
    existing user object to key off of, so we filter by address here."""
 | 
						|
    items = ScheduledEmail.objects.filter(address__iexact=email,
 | 
						|
                                          type=ScheduledEmail.INVITATION_REMINDER)
 | 
						|
    items.delete()
 | 
						|
 | 
						|
def clear_scheduled_emails(user_ids: List[int], email_type: Optional[int]=None) -> None:
 | 
						|
    items = ScheduledEmail.objects.filter(users__in=user_ids).distinct()
 | 
						|
    if email_type is not None:
 | 
						|
        items = items.filter(type=email_type)
 | 
						|
    for item in items:
 | 
						|
        item.users.remove(*user_ids)
 | 
						|
        if item.users.all().count() == 0:
 | 
						|
            item.delete()
 | 
						|
 | 
						|
def handle_send_email_format_changes(job: Dict[str, Any]) -> None:
 | 
						|
    # Reformat any jobs that used the old to_email
 | 
						|
    # and to_user_ids argument formats.
 | 
						|
    if 'to_email' in job:
 | 
						|
        if job['to_email'] is not None:
 | 
						|
            job['to_emails'] = [job['to_email']]
 | 
						|
        del job['to_email']
 | 
						|
    if 'to_user_id' in job:
 | 
						|
        if job['to_user_id'] is not None:
 | 
						|
            job['to_user_ids'] = [job['to_user_id']]
 | 
						|
        del job['to_user_id']
 | 
						|
 | 
						|
def deliver_email(email: ScheduledEmail) -> None:
 | 
						|
    data = ujson.loads(email.data)
 | 
						|
    if email.users.exists():
 | 
						|
        data['to_user_ids'] = [user.id for user in email.users.all()]
 | 
						|
    if email.address is not None:
 | 
						|
        data['to_emails'] = [email.address]
 | 
						|
    handle_send_email_format_changes(data)
 | 
						|
    send_email(**data)
 | 
						|
    email.delete()
 |