Files
zulip/zerver/lib/send_email.py
Joshua Pan 533eccd655 models: Create delivery_email field in userprofile.
This commit creates a new field called delivery_email. For now, it is
exactly the same as email upon user profile creation and should stay
that way even when email is changed, and is used only for sending
outgoing email from Zulip.

The purpose of this field is to support an upcoming option where the
existing `email` field in Zulip becomes effectively the user's
"display email" address, as part of making it possible for users
actual email addresses (that can receive email, stored in the
delivery_email field) to not be available to other non-administrator
users in the organization.

Because the `email` field is used in numerous places in display code,
in the API, and in database queries, the shortest path to implementing
this "private email" feature is to keep "email" as-is in those parts
of the codebase, and just set the existing "email" ("display email")
model field to be something generated like
"username@zulip.example.com" for display purposes.

Eventually, we'll want to do further refactoring, either in the form
of having both `display_email` and `delivery_email` as fields, or
renaming "email" to "username".
2018-07-12 12:30:20 +05:30

137 lines
6.0 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.template.exceptions import TemplateDoesNotExist
from zerver.models import UserProfile, 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, Iterable, List, Mapping, Optional
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_id: Optional[int]=None,
to_email: Optional[str]=None, from_name: Optional[str]=None,
from_address: Optional[str]=None, reply_to_email: 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_id is None) ^ (to_email is None)
if to_user_id is not None:
to_user = get_user_profile_by_id(to_user_id)
# Change to formataddr((to_user.full_name, to_user.email)) once
# https://github.com/zulip/zulip/issues/4676 is resolved
to_email = to_user.delivery_email
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,
})
subject = loader.render_to_string(template_prefix + '.subject',
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)
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(subject, message, from_email, [to_email], 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_id: Optional[int]=None, to_email: Optional[str]=None,
from_name: Optional[str]=None, from_address: Optional[str]=None,
reply_to_email: Optional[str]=None, context: Dict[str, Any]={}) -> None:
mail = build_email(template_prefix, to_user_id=to_user_id, to_email=to_email, from_name=from_name,
from_address=from_address, reply_to_email=reply_to_email, 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_id: Optional[int]=None,
to_email: Optional[str]=None, from_name: Optional[str]=None,
from_address: 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, 'to_user_id': to_user_id, 'to_email': to_email,
'from_name': from_name, 'from_address': from_address, 'context': context}
if settings.DEVELOPMENT and not settings.TEST_SUITE:
send_email(template_prefix, to_user_id=to_user_id, to_email=to_email, from_name=from_name,
from_address=from_address, context=context)
# For logging the email
assert (to_user_id is None) ^ (to_email is None)
if to_user_id is not None:
# The realm is redundant if we have a to_user_id; this assert just
# expresses that fact
assert(UserProfile.objects.filter(id=to_user_id, realm=realm).exists())
to_field = {'user_id': to_user_id} # type: Dict[str, Any]
else:
to_field = {'address': parseaddr(to_email)[1]}
ScheduledEmail.objects.create(
type=EMAIL_TYPES[template_name],
scheduled_timestamp=timezone_now() + delay,
realm=realm,
data=ujson.dumps(email_fields),
**to_field)