mirror of https://github.com/zulip/zulip.git
Split out zerver/lib/notifications.py from actions.py.
(imported from commit 784b82834ee4fcb4431e77f8fb1c526f8eec82ad)
This commit is contained in:
parent
cf16d95437
commit
da90d63046
|
@ -13,8 +13,8 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity,
|
|||
to_dict_cache_key, get_realm, stringify_message_dict, bulk_get_recipients, \
|
||||
resolve_email_to_domain, email_to_username, display_recipient_cache_key, \
|
||||
get_stream_cache_key, to_dict_cache_key_id, is_super_user, \
|
||||
UserActivityInterval, get_active_user_dicts_in_realm, RealmAlias, \
|
||||
ScheduledJob, realm_filters_for_domain, RealmFilter
|
||||
UserActivityInterval, get_active_user_dicts_in_realm, \
|
||||
realm_filters_for_domain, RealmFilter, receives_offline_notifications
|
||||
from zerver.lib.avatar import get_avatar_url
|
||||
from guardian.shortcuts import assign_perm, remove_perm
|
||||
|
||||
|
@ -22,8 +22,7 @@ from django.db import transaction, IntegrityError
|
|||
from django.db.models import F, Q
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.importlib import import_module
|
||||
from django.template import loader
|
||||
from django.core.mail import EmailMultiAlternatives, EmailMessage
|
||||
from django.core.mail import EmailMessage
|
||||
from django.utils.timezone import now
|
||||
|
||||
from confirmation.models import Confirmation
|
||||
|
@ -41,7 +40,7 @@ from zerver.lib.cache import cache_with_key, cache_set, \
|
|||
user_profile_by_email_cache_key, cache_set_many, \
|
||||
cache_delete, cache_delete_many, message_cache_key
|
||||
from zerver.decorator import get_user_profile_by_email, json_to_list, JsonableError, \
|
||||
statsd_increment, uses_mandrill
|
||||
statsd_increment
|
||||
from zerver.lib.event_queue import request_event_queue, get_user_events
|
||||
from zerver.lib.utils import log_statsd_event, statsd
|
||||
from zerver.lib.html_diff import highlight_html_differences
|
||||
|
@ -49,6 +48,7 @@ from zerver.lib.alert_words import user_alert_words, add_user_alert_words, \
|
|||
remove_user_alert_words, set_user_alert_words
|
||||
from zerver.lib.push_notifications import num_push_devices_for_user, \
|
||||
send_apple_push_notification, send_android_push_notification
|
||||
from zerver.lib.notifications import clear_followup_emails_queue
|
||||
from zerver.lib.narrow import check_supported_events_narrow_filter
|
||||
|
||||
from zerver import tornado_callbacks
|
||||
|
@ -64,8 +64,6 @@ import platform
|
|||
import logging
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
import urllib
|
||||
import subprocess
|
||||
|
||||
# Store an event in the log for re-importing messages
|
||||
def log_event(event):
|
||||
|
@ -2048,244 +2046,6 @@ def do_send_confirmation_email(invitee, referrer):
|
|||
subject_template_path=subject_template_path,
|
||||
body_template_path=body_template_path)
|
||||
|
||||
def hashchange_encode(string):
|
||||
# Do the same encoding operation as hashchange.encodeHashComponent on the
|
||||
# frontend.
|
||||
return urllib.quote(
|
||||
string.encode("utf-8")).replace(".", "%2E").replace("%", ".")
|
||||
|
||||
def pm_narrow_url(participants):
|
||||
participants.sort()
|
||||
base_url = "https://%s/#narrow/pm-with/" % (settings.EXTERNAL_HOST,)
|
||||
return base_url + hashchange_encode(",".join(participants))
|
||||
|
||||
def stream_narrow_url(stream):
|
||||
base_url = "https://%s/#narrow/stream/" % (settings.EXTERNAL_HOST,)
|
||||
return base_url + hashchange_encode(stream)
|
||||
|
||||
def topic_narrow_url(stream, topic):
|
||||
base_url = "https://%s/#narrow/stream/" % (settings.EXTERNAL_HOST,)
|
||||
return "%s%s/topic/%s" % (base_url, hashchange_encode(stream),
|
||||
hashchange_encode(topic))
|
||||
|
||||
def build_message_list(user_profile, messages):
|
||||
"""
|
||||
Builds the message list object for the missed message email template.
|
||||
The messages are collapsed into per-recipient and per-sender blocks, like
|
||||
our web interface
|
||||
"""
|
||||
messages_to_render = []
|
||||
|
||||
def sender_string(message):
|
||||
sender = ''
|
||||
if message.recipient.type in (Recipient.STREAM, Recipient.HUDDLE):
|
||||
sender = message.sender.full_name
|
||||
return sender
|
||||
|
||||
def relative_to_full_url(content):
|
||||
# URLs for uploaded content are of the form
|
||||
# "/user_uploads/abc.png". Make them full paths.
|
||||
#
|
||||
# There's a small chance of colliding with non-Zulip URLs containing
|
||||
# "/user_uploads/", but we don't have much information about the
|
||||
# structure of the URL to leverage.
|
||||
content = re.sub(
|
||||
r"/user_uploads/(\S*)",
|
||||
settings.EXTERNAL_HOST + r"/user_uploads/\1", content)
|
||||
|
||||
# Our proxying user-uploaded images seems to break inline images in HTML
|
||||
# emails, so scrub the image but leave the link.
|
||||
content = re.sub(
|
||||
r"<img src=(\S+)/user_uploads/(\S+)>", "", content)
|
||||
|
||||
# URLs for emoji are of the form
|
||||
# "static/third/gemoji/images/emoji/snowflake.png".
|
||||
content = re.sub(
|
||||
r"static/third/gemoji/images/emoji/",
|
||||
settings.EXTERNAL_HOST + r"/static/third/gemoji/images/emoji/",
|
||||
content)
|
||||
|
||||
return content
|
||||
|
||||
def fix_plaintext_image_urls(content):
|
||||
# Replace image URLs in plaintext content of the form
|
||||
# [image name](image url)
|
||||
# with a simple hyperlink.
|
||||
return re.sub(r"\[(\S*)\]\((\S*)\)", r"\2", content)
|
||||
|
||||
def fix_emoji_sizes(html):
|
||||
return html.replace(' class="emoji"', ' height="20px"')
|
||||
|
||||
def build_message_payload(message):
|
||||
plain = message.content
|
||||
plain = fix_plaintext_image_urls(plain)
|
||||
plain = relative_to_full_url(plain)
|
||||
|
||||
html = message.rendered_content
|
||||
html = relative_to_full_url(html)
|
||||
html = fix_emoji_sizes(html)
|
||||
|
||||
return {'plain': plain, 'html': html}
|
||||
|
||||
def build_sender_payload(message):
|
||||
sender = sender_string(message)
|
||||
return {'sender': sender,
|
||||
'content': [build_message_payload(message)]}
|
||||
|
||||
def message_header(user_profile, message):
|
||||
disp_recipient = get_display_recipient(message.recipient)
|
||||
if message.recipient.type == Recipient.PERSONAL:
|
||||
header = "You and %s" % (message.sender.full_name)
|
||||
html_link = pm_narrow_url([message.sender.email])
|
||||
header_html = "<a style='color: #ffffff;' href='%s'>%s</a>" % (html_link, header)
|
||||
elif message.recipient.type == Recipient.HUDDLE:
|
||||
other_recipients = [r['full_name'] for r in disp_recipient
|
||||
if r['email'] != user_profile.email]
|
||||
header = "You and %s" % (", ".join(other_recipients),)
|
||||
html_link = pm_narrow_url([r["email"] for r in disp_recipient
|
||||
if r["email"] != user_profile.email])
|
||||
header_html = "<a style='color: #ffffff;' href='%s'>%s</a>" % (html_link, header)
|
||||
else:
|
||||
header = "%s > %s" % (disp_recipient, message.subject)
|
||||
stream_link = stream_narrow_url(disp_recipient)
|
||||
topic_link = topic_narrow_url(disp_recipient, message.subject)
|
||||
header_html = "<a href='%s'>%s</a> > <a href='%s'>%s</a>" % (
|
||||
stream_link, disp_recipient, topic_link, message.subject)
|
||||
return {"plain": header,
|
||||
"html": header_html,
|
||||
"stream_message": message.recipient.type_name() == "stream"}
|
||||
|
||||
# # Collapse message list to
|
||||
# [
|
||||
# {
|
||||
# "header": {
|
||||
# "plain":"header",
|
||||
# "html":"htmlheader"
|
||||
# }
|
||||
# "senders":[
|
||||
# {
|
||||
# "sender":"sender_name",
|
||||
# "content":[
|
||||
# {
|
||||
# "plain":"content",
|
||||
# "html":"htmlcontent"
|
||||
# }
|
||||
# {
|
||||
# "plain":"content",
|
||||
# "html":"htmlcontent"
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# ]
|
||||
|
||||
messages.sort(key=lambda message: message.pub_date)
|
||||
|
||||
for message in messages:
|
||||
header = message_header(user_profile, message)
|
||||
|
||||
# If we want to collapse into the previous recipient block
|
||||
if len(messages_to_render) > 0 and messages_to_render[-1]['header'] == header:
|
||||
sender = sender_string(message)
|
||||
sender_block = messages_to_render[-1]['senders']
|
||||
|
||||
# Same message sender, collapse again
|
||||
if sender_block[-1]['sender'] == sender:
|
||||
sender_block[-1]['content'].append(build_message_payload(message))
|
||||
else:
|
||||
# Start a new sender block
|
||||
sender_block.append(build_sender_payload(message))
|
||||
else:
|
||||
# New recipient and sender block
|
||||
recipient_block = {'header': header,
|
||||
'senders': [build_sender_payload(message)]}
|
||||
|
||||
messages_to_render.append(recipient_block)
|
||||
|
||||
return messages_to_render
|
||||
|
||||
def unsubscribe_token(user_profile):
|
||||
# Leverage the Django confirmations framework to generate and track unique
|
||||
# unsubscription tokens.
|
||||
return Confirmation.objects.get_link_for_object(user_profile).split("/")[-1]
|
||||
|
||||
def one_click_unsubscribe_link(user_profile, endpoint):
|
||||
"""
|
||||
Generate a unique link that a logged-out user can visit to unsubscribe from
|
||||
Zulip e-mails without having to first log in.
|
||||
"""
|
||||
token = unsubscribe_token(user_profile)
|
||||
base_url = "https://" + settings.EXTERNAL_HOST
|
||||
resource_path = "accounts/unsubscribe/%s/%s" % (endpoint, token)
|
||||
return "%s/%s" % (base_url.rstrip("/"), resource_path)
|
||||
|
||||
@statsd_increment("missed_message_reminders")
|
||||
def do_send_missedmessage_events(user_profile, missed_messages):
|
||||
"""
|
||||
Send a reminder email and/or push notifications to a user if she's missed some PMs by being offline
|
||||
|
||||
`user_profile` is the user to send the reminder to
|
||||
`missed_messages` is a list of Message objects to remind about
|
||||
"""
|
||||
senders = set(m.sender.full_name for m in missed_messages)
|
||||
sender_str = ", ".join(senders)
|
||||
plural_messages = 's' if len(missed_messages) > 1 else ''
|
||||
if user_profile.enable_offline_email_notifications:
|
||||
template_payload = {'name': user_profile.full_name,
|
||||
'messages': build_message_list(user_profile, missed_messages),
|
||||
'message_count': len(missed_messages),
|
||||
'url': 'https://%s' % (settings.EXTERNAL_HOST,),
|
||||
'reply_warning': False,
|
||||
'external_host': settings.EXTERNAL_HOST}
|
||||
headers = {}
|
||||
if all(msg.recipient.type in (Recipient.HUDDLE, Recipient.PERSONAL)
|
||||
for msg in missed_messages):
|
||||
# If we have one huddle, set a reply-to to all of the members
|
||||
# of the huddle except the user herself
|
||||
disp_recipients = [", ".join(recipient['email']
|
||||
for recipient in get_display_recipient(mesg.recipient)
|
||||
if recipient['email'] != user_profile.email)
|
||||
for mesg in missed_messages]
|
||||
if all(msg.recipient.type == Recipient.HUDDLE for msg in missed_messages) and \
|
||||
len(set(disp_recipients)) == 1:
|
||||
headers['Reply-To'] = disp_recipients[0]
|
||||
elif len(senders) == 1:
|
||||
headers['Reply-To'] = missed_messages[0].sender.email
|
||||
else:
|
||||
template_payload['reply_warning'] = True
|
||||
else:
|
||||
# There are some @-mentions mixed in with personals
|
||||
template_payload['mention'] = True
|
||||
template_payload['reply_warning'] = True
|
||||
headers['Reply-To'] = "Nobody <%s>" % (settings.NOREPLY_EMAIL_ADDRESS,)
|
||||
|
||||
# Give users a one-click unsubscribe link they can use to stop getting
|
||||
# missed message emails without having to log in first.
|
||||
unsubscribe_link = one_click_unsubscribe_link(user_profile, "missed_messages")
|
||||
template_payload["unsubscribe_link"] = unsubscribe_link
|
||||
|
||||
subject = "Missed Zulip%s from %s" % (plural_messages, sender_str)
|
||||
from_email = "%s (via Zulip) <%s>" % (sender_str, settings.NOREPLY_EMAIL_ADDRESS)
|
||||
|
||||
text_content = loader.render_to_string('zerver/missed_message_email.txt', template_payload)
|
||||
html_content = loader.render_to_string('zerver/missed_message_email_html.txt', template_payload)
|
||||
|
||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [user_profile.email],
|
||||
headers = headers)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
user_profile.last_reminder = datetime.datetime.now()
|
||||
user_profile.save(update_fields=['last_reminder'])
|
||||
|
||||
return
|
||||
|
||||
def receives_offline_notifications(user_profile):
|
||||
return ((user_profile.enable_offline_email_notifications or
|
||||
user_profile.enable_offline_push_notifications) and
|
||||
not user_profile.is_bot)
|
||||
|
||||
@statsd_increment("push_notifications")
|
||||
def handle_push_notification(user_profile_id, missed_message):
|
||||
try:
|
||||
|
@ -2350,20 +2110,6 @@ def handle_push_notification(user_profile_id, missed_message):
|
|||
except UserMessage.DoesNotExist:
|
||||
logging.error("Could not find UserMessage with message_id %s" %(missed_message['message_id'],))
|
||||
|
||||
def handle_missedmessage_emails(user_profile_id, missed_email_events):
|
||||
message_ids = [event.get('message_id') for event in missed_email_events]
|
||||
|
||||
user_profile = get_user_profile_by_id(user_profile_id)
|
||||
if not receives_offline_notifications(user_profile):
|
||||
return
|
||||
|
||||
messages = [um.message for um in UserMessage.objects.filter(user_profile=user_profile,
|
||||
message__id__in=message_ids,
|
||||
flags=~UserMessage.flags.read)]
|
||||
|
||||
if messages:
|
||||
do_send_missedmessage_events(user_profile, messages)
|
||||
|
||||
def is_inactive(value):
|
||||
try:
|
||||
if get_user_profile_by_email(value).is_active:
|
||||
|
@ -2533,171 +2279,5 @@ def get_emails_from_user_ids(user_ids):
|
|||
# We may eventually use memcached to speed this up, but the DB is fast.
|
||||
return UserProfile.emails_from_ids(user_ids)
|
||||
|
||||
@uses_mandrill
|
||||
def clear_followup_emails_queue(email, mail_client=None):
|
||||
"""
|
||||
Clear out queued emails (from Mandrill's queue) that would otherwise
|
||||
be sent to a specific email address. Optionally specify which sender
|
||||
to filter by (useful when there are more Zulip subsystems using our
|
||||
mandrill account).
|
||||
|
||||
`email` is a string representing the recipient email
|
||||
`from_email` is a string representing the zulip email account used
|
||||
to send the email (for example `support@zulip.com` or `signups@zulip.com`)
|
||||
"""
|
||||
# Zulip Enterprise implementation
|
||||
if not mail_client:
|
||||
items = ScheduledJob.objects.filter(type=ScheduledJob.EMAIL, filter_string__iexact = email)
|
||||
items.delete()
|
||||
return
|
||||
|
||||
# Mandrill implementation
|
||||
for email in mail_client.messages.list_scheduled(to=email):
|
||||
result = mail_client.messages.cancel_scheduled(id=email["_id"])
|
||||
if result.get("status") == "error":
|
||||
print result.get("name"), result.get("error")
|
||||
return
|
||||
|
||||
@uses_mandrill
|
||||
def send_future_email(recipients, email_html, email_text, subject,
|
||||
delay=datetime.timedelta(0), sender=None,
|
||||
tags=[], mail_client=None):
|
||||
"""
|
||||
Sends email via Mandrill, with optional delay
|
||||
|
||||
'mail_client' is filled in by the decorator
|
||||
"""
|
||||
# When sending real emails while testing locally, don't accidentally send
|
||||
# emails to non-zulip.com users.
|
||||
if not settings.DEPLOYED and \
|
||||
settings.EMAIL_BACKEND != 'django.core.mail.backends.console.EmailBackend':
|
||||
for recipient in recipients:
|
||||
email = recipient.get("email")
|
||||
if get_user_profile_by_email(email).realm.domain != "zulip.com":
|
||||
raise ValueError("digest: refusing to send emails to non-zulip.com users.")
|
||||
|
||||
# message = {"from_email": "othello@zulip.com",
|
||||
# "from_name": "Othello",
|
||||
# "html": "<p>hello</p> there",
|
||||
# "tags": ["signup-reminders"],
|
||||
# "to": [{'email':"acrefoot@zulip.com", 'name': "thingamajig"}]
|
||||
# }
|
||||
|
||||
# Zulip Enterprise implementation
|
||||
if not mail_client:
|
||||
if sender is None:
|
||||
# This may likely overridden by settings.DEFAULT_FROM_EMAIL
|
||||
sender = {'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}
|
||||
for recipient in recipients:
|
||||
email_fields = {'email_html': email_html,
|
||||
'email_subject': subject,
|
||||
'email_text': email_text,
|
||||
'recipient_email': recipient.get('email'),
|
||||
'recipient_name': recipient.get('name'),
|
||||
'sender_email': sender['email'],
|
||||
'sender_name': sender['name']}
|
||||
ScheduledJob.objects.create(type=ScheduledJob.EMAIL, filter_string=recipient.get('email'),
|
||||
data=ujson.dumps(email_fields),
|
||||
scheduled_timestamp=datetime.datetime.utcnow() + delay)
|
||||
return
|
||||
|
||||
# Mandrill implementation
|
||||
if sender is None:
|
||||
sender = {'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}
|
||||
|
||||
message = {'from_email': sender['email'],
|
||||
'from_name': sender['name'],
|
||||
'to': recipients,
|
||||
'subject': subject,
|
||||
'html': email_html,
|
||||
'text': email_text,
|
||||
'tags': tags,
|
||||
}
|
||||
# ignore any delays smaller than 1-minute because it's cheaper just to sent them immediately
|
||||
if type(delay) is not datetime.timedelta:
|
||||
raise TypeError("specified delay is of the wrong type: %s" % (type(delay),))
|
||||
if delay < datetime.timedelta(minutes=1):
|
||||
results = mail_client.messages.send(message=message, async=False, ip_pool="Main Pool")
|
||||
else:
|
||||
send_time = (datetime.datetime.utcnow() + delay).__format__("%Y-%m-%d %H:%M:%S")
|
||||
results = mail_client.messages.send(message=message, async=False, ip_pool="Main Pool", send_at=send_time)
|
||||
problems = [result for result in results if (result['status'] in ('rejected', 'invalid'))]
|
||||
if problems:
|
||||
raise Exception("While sending email (%s), encountered problems with these recipients: %r"
|
||||
% (subject, problems))
|
||||
return
|
||||
|
||||
def send_local_email_template_with_delay(recipients, template_prefix,
|
||||
template_payload, delay,
|
||||
tags=[], sender={'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}):
|
||||
html_content = loader.render_to_string(template_prefix + ".html", template_payload)
|
||||
text_content = loader.render_to_string(template_prefix + ".text", template_payload)
|
||||
subject = loader.render_to_string(template_prefix + ".subject", template_payload).strip()
|
||||
|
||||
return send_future_email(recipients,
|
||||
html_content,
|
||||
text_content,
|
||||
subject,
|
||||
delay=delay,
|
||||
sender=sender,
|
||||
tags=tags)
|
||||
|
||||
def enqueue_welcome_emails(email, name):
|
||||
sender = {'email': 'wdaher@zulip.com', 'name': 'Waseem Daher'}
|
||||
if settings.ENTERPRISE:
|
||||
sender = {'email': settings.ZULIP_ADMINISTRATOR, 'name': 'Zulip'}
|
||||
|
||||
user_profile = get_user_profile_by_email(email)
|
||||
unsubscribe_link = one_click_unsubscribe_link(user_profile, "welcome")
|
||||
|
||||
template_payload = {'name': name,
|
||||
'not_enterprise': not settings.ENTERPRISE,
|
||||
'external_host': settings.EXTERNAL_HOST,
|
||||
'unsubscribe_link': unsubscribe_link}
|
||||
|
||||
#Send day 1 email
|
||||
send_local_email_template_with_delay([{'email': email, 'name': name}],
|
||||
"zerver/emails/followup/day1",
|
||||
template_payload,
|
||||
datetime.timedelta(hours=1),
|
||||
tags=["followup-emails"],
|
||||
sender=sender)
|
||||
#Send day 2 email
|
||||
tomorrow = datetime.datetime.utcnow() + datetime.timedelta(hours=24)
|
||||
# 11 AM EDT
|
||||
tomorrow_morning = datetime.datetime(tomorrow.year, tomorrow.month, tomorrow.day, 15, 0)
|
||||
assert(datetime.datetime.utcnow() < tomorrow_morning)
|
||||
send_local_email_template_with_delay([{'email': email, 'name': name}],
|
||||
"zerver/emails/followup/day2",
|
||||
template_payload,
|
||||
tomorrow_morning - datetime.datetime.utcnow(),
|
||||
tags=["followup-emails"],
|
||||
sender=sender)
|
||||
|
||||
def realm_aliases(realm):
|
||||
return [alias.domain for alias in realm.realmalias_set.all()]
|
||||
|
||||
def convert_html_to_markdown(html):
|
||||
# On Linux, the tool installs as html2markdown, and there's a command called
|
||||
# html2text that does something totally different. On OSX, the tool installs
|
||||
# as html2text.
|
||||
commands = ["html2markdown", "html2text"]
|
||||
|
||||
for command in commands:
|
||||
try:
|
||||
# A body width of 0 means do not try to wrap the text for us.
|
||||
p = subprocess.Popen(
|
||||
[command, "--body-width=0"], stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
markdown = p.communicate(input=html.encode("utf-8"))[0].strip()
|
||||
# We want images to get linked and inline previewed, but html2text will turn
|
||||
# them into links of the form `![](http://foo.com/image.png)`, which is
|
||||
# ugly. Run a regex over the resulting description, turning links of the
|
||||
# form `![](http://foo.com/image.png?12345)` into
|
||||
# `[image.png](http://foo.com/image.png)`.
|
||||
return re.sub(r"!\[\]\((\S*)/(\S*)\?(\S*)\)",
|
||||
r"[\2](\1/\2)", markdown).decode("utf-8")
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.db.models import Q
|
|||
from django.template import loader
|
||||
from django.conf import settings
|
||||
|
||||
from zerver.lib.actions import build_message_list, hashchange_encode, \
|
||||
from zerver.lib.notifications import build_message_list, hashchange_encode, \
|
||||
send_future_email, one_click_unsubscribe_link
|
||||
from zerver.models import UserProfile, UserMessage, Recipient, Stream, \
|
||||
Subscription
|
||||
|
|
|
@ -10,7 +10,8 @@ from email.header import decode_header
|
|||
|
||||
from django.conf import settings
|
||||
|
||||
from zerver.lib.actions import decode_email_address, convert_html_to_markdown
|
||||
from zerver.lib.actions import decode_email_address
|
||||
from zerver.lib.notifications import convert_html_to_markdown
|
||||
from zerver.lib.upload import upload_message_image
|
||||
from zerver.models import Stream, get_user_profile_by_email, UserProfile
|
||||
|
||||
|
|
|
@ -0,0 +1,428 @@
|
|||
from confirmation.models import Confirmation
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template import loader
|
||||
from zerver.decorator import statsd_increment, uses_mandrill
|
||||
from zerver.models import Recipient, ScheduledJob, UserMessage, \
|
||||
get_display_recipient, get_user_profile_by_email, get_user_profile_by_id, \
|
||||
receives_offline_notifications
|
||||
|
||||
import datetime
|
||||
import re
|
||||
import subprocess
|
||||
import ujson
|
||||
import urllib
|
||||
|
||||
def unsubscribe_token(user_profile):
|
||||
# Leverage the Django confirmations framework to generate and track unique
|
||||
# unsubscription tokens.
|
||||
return Confirmation.objects.get_link_for_object(user_profile).split("/")[-1]
|
||||
|
||||
def one_click_unsubscribe_link(user_profile, endpoint):
|
||||
"""
|
||||
Generate a unique link that a logged-out user can visit to unsubscribe from
|
||||
Zulip e-mails without having to first log in.
|
||||
"""
|
||||
token = unsubscribe_token(user_profile)
|
||||
base_url = "https://" + settings.EXTERNAL_HOST
|
||||
resource_path = "accounts/unsubscribe/%s/%s" % (endpoint, token)
|
||||
return "%s/%s" % (base_url.rstrip("/"), resource_path)
|
||||
|
||||
def hashchange_encode(string):
|
||||
# Do the same encoding operation as hashchange.encodeHashComponent on the
|
||||
# frontend.
|
||||
return urllib.quote(
|
||||
string.encode("utf-8")).replace(".", "%2E").replace("%", ".")
|
||||
|
||||
def pm_narrow_url(participants):
|
||||
participants.sort()
|
||||
base_url = "https://%s/#narrow/pm-with/" % (settings.EXTERNAL_HOST,)
|
||||
return base_url + hashchange_encode(",".join(participants))
|
||||
|
||||
def stream_narrow_url(stream):
|
||||
base_url = "https://%s/#narrow/stream/" % (settings.EXTERNAL_HOST,)
|
||||
return base_url + hashchange_encode(stream)
|
||||
|
||||
def topic_narrow_url(stream, topic):
|
||||
base_url = "https://%s/#narrow/stream/" % (settings.EXTERNAL_HOST,)
|
||||
return "%s%s/topic/%s" % (base_url, hashchange_encode(stream),
|
||||
hashchange_encode(topic))
|
||||
|
||||
def build_message_list(user_profile, messages):
|
||||
"""
|
||||
Builds the message list object for the missed message email template.
|
||||
The messages are collapsed into per-recipient and per-sender blocks, like
|
||||
our web interface
|
||||
"""
|
||||
messages_to_render = []
|
||||
|
||||
def sender_string(message):
|
||||
sender = ''
|
||||
if message.recipient.type in (Recipient.STREAM, Recipient.HUDDLE):
|
||||
sender = message.sender.full_name
|
||||
return sender
|
||||
|
||||
def relative_to_full_url(content):
|
||||
# URLs for uploaded content are of the form
|
||||
# "/user_uploads/abc.png". Make them full paths.
|
||||
#
|
||||
# There's a small chance of colliding with non-Zulip URLs containing
|
||||
# "/user_uploads/", but we don't have much information about the
|
||||
# structure of the URL to leverage.
|
||||
content = re.sub(
|
||||
r"/user_uploads/(\S*)",
|
||||
settings.EXTERNAL_HOST + r"/user_uploads/\1", content)
|
||||
|
||||
# Our proxying user-uploaded images seems to break inline images in HTML
|
||||
# emails, so scrub the image but leave the link.
|
||||
content = re.sub(
|
||||
r"<img src=(\S+)/user_uploads/(\S+)>", "", content)
|
||||
|
||||
# URLs for emoji are of the form
|
||||
# "static/third/gemoji/images/emoji/snowflake.png".
|
||||
content = re.sub(
|
||||
r"static/third/gemoji/images/emoji/",
|
||||
settings.EXTERNAL_HOST + r"/static/third/gemoji/images/emoji/",
|
||||
content)
|
||||
|
||||
return content
|
||||
|
||||
def fix_plaintext_image_urls(content):
|
||||
# Replace image URLs in plaintext content of the form
|
||||
# [image name](image url)
|
||||
# with a simple hyperlink.
|
||||
return re.sub(r"\[(\S*)\]\((\S*)\)", r"\2", content)
|
||||
|
||||
def fix_emoji_sizes(html):
|
||||
return html.replace(' class="emoji"', ' height="20px"')
|
||||
|
||||
def build_message_payload(message):
|
||||
plain = message.content
|
||||
plain = fix_plaintext_image_urls(plain)
|
||||
plain = relative_to_full_url(plain)
|
||||
|
||||
html = message.rendered_content
|
||||
html = relative_to_full_url(html)
|
||||
html = fix_emoji_sizes(html)
|
||||
|
||||
return {'plain': plain, 'html': html}
|
||||
|
||||
def build_sender_payload(message):
|
||||
sender = sender_string(message)
|
||||
return {'sender': sender,
|
||||
'content': [build_message_payload(message)]}
|
||||
|
||||
def message_header(user_profile, message):
|
||||
disp_recipient = get_display_recipient(message.recipient)
|
||||
if message.recipient.type == Recipient.PERSONAL:
|
||||
header = "You and %s" % (message.sender.full_name)
|
||||
html_link = pm_narrow_url([message.sender.email])
|
||||
header_html = "<a style='color: #ffffff;' href='%s'>%s</a>" % (html_link, header)
|
||||
elif message.recipient.type == Recipient.HUDDLE:
|
||||
other_recipients = [r['full_name'] for r in disp_recipient
|
||||
if r['email'] != user_profile.email]
|
||||
header = "You and %s" % (", ".join(other_recipients),)
|
||||
html_link = pm_narrow_url([r["email"] for r in disp_recipient
|
||||
if r["email"] != user_profile.email])
|
||||
header_html = "<a style='color: #ffffff;' href='%s'>%s</a>" % (html_link, header)
|
||||
else:
|
||||
header = "%s > %s" % (disp_recipient, message.subject)
|
||||
stream_link = stream_narrow_url(disp_recipient)
|
||||
topic_link = topic_narrow_url(disp_recipient, message.subject)
|
||||
header_html = "<a href='%s'>%s</a> > <a href='%s'>%s</a>" % (
|
||||
stream_link, disp_recipient, topic_link, message.subject)
|
||||
return {"plain": header,
|
||||
"html": header_html,
|
||||
"stream_message": message.recipient.type_name() == "stream"}
|
||||
|
||||
# # Collapse message list to
|
||||
# [
|
||||
# {
|
||||
# "header": {
|
||||
# "plain":"header",
|
||||
# "html":"htmlheader"
|
||||
# }
|
||||
# "senders":[
|
||||
# {
|
||||
# "sender":"sender_name",
|
||||
# "content":[
|
||||
# {
|
||||
# "plain":"content",
|
||||
# "html":"htmlcontent"
|
||||
# }
|
||||
# {
|
||||
# "plain":"content",
|
||||
# "html":"htmlcontent"
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
# },
|
||||
# ]
|
||||
|
||||
messages.sort(key=lambda message: message.pub_date)
|
||||
|
||||
for message in messages:
|
||||
header = message_header(user_profile, message)
|
||||
|
||||
# If we want to collapse into the previous recipient block
|
||||
if len(messages_to_render) > 0 and messages_to_render[-1]['header'] == header:
|
||||
sender = sender_string(message)
|
||||
sender_block = messages_to_render[-1]['senders']
|
||||
|
||||
# Same message sender, collapse again
|
||||
if sender_block[-1]['sender'] == sender:
|
||||
sender_block[-1]['content'].append(build_message_payload(message))
|
||||
else:
|
||||
# Start a new sender block
|
||||
sender_block.append(build_sender_payload(message))
|
||||
else:
|
||||
# New recipient and sender block
|
||||
recipient_block = {'header': header,
|
||||
'senders': [build_sender_payload(message)]}
|
||||
|
||||
messages_to_render.append(recipient_block)
|
||||
|
||||
return messages_to_render
|
||||
|
||||
@statsd_increment("missed_message_reminders")
|
||||
def do_send_missedmessage_events(user_profile, missed_messages):
|
||||
"""
|
||||
Send a reminder email and/or push notifications to a user if she's missed some PMs by being offline
|
||||
|
||||
`user_profile` is the user to send the reminder to
|
||||
`missed_messages` is a list of Message objects to remind about
|
||||
"""
|
||||
senders = set(m.sender.full_name for m in missed_messages)
|
||||
sender_str = ", ".join(senders)
|
||||
plural_messages = 's' if len(missed_messages) > 1 else ''
|
||||
if user_profile.enable_offline_email_notifications:
|
||||
template_payload = {'name': user_profile.full_name,
|
||||
'messages': build_message_list(user_profile, missed_messages),
|
||||
'message_count': len(missed_messages),
|
||||
'url': 'https://%s' % (settings.EXTERNAL_HOST,),
|
||||
'reply_warning': False,
|
||||
'external_host': settings.EXTERNAL_HOST}
|
||||
headers = {}
|
||||
if all(msg.recipient.type in (Recipient.HUDDLE, Recipient.PERSONAL)
|
||||
for msg in missed_messages):
|
||||
# If we have one huddle, set a reply-to to all of the members
|
||||
# of the huddle except the user herself
|
||||
disp_recipients = [", ".join(recipient['email']
|
||||
for recipient in get_display_recipient(mesg.recipient)
|
||||
if recipient['email'] != user_profile.email)
|
||||
for mesg in missed_messages]
|
||||
if all(msg.recipient.type == Recipient.HUDDLE for msg in missed_messages) and \
|
||||
len(set(disp_recipients)) == 1:
|
||||
headers['Reply-To'] = disp_recipients[0]
|
||||
elif len(senders) == 1:
|
||||
headers['Reply-To'] = missed_messages[0].sender.email
|
||||
else:
|
||||
template_payload['reply_warning'] = True
|
||||
else:
|
||||
# There are some @-mentions mixed in with personals
|
||||
template_payload['mention'] = True
|
||||
template_payload['reply_warning'] = True
|
||||
headers['Reply-To'] = "Nobody <%s>" % (settings.NOREPLY_EMAIL_ADDRESS,)
|
||||
|
||||
# Give users a one-click unsubscribe link they can use to stop getting
|
||||
# missed message emails without having to log in first.
|
||||
unsubscribe_link = one_click_unsubscribe_link(user_profile, "missed_messages")
|
||||
template_payload["unsubscribe_link"] = unsubscribe_link
|
||||
|
||||
subject = "Missed Zulip%s from %s" % (plural_messages, sender_str)
|
||||
from_email = "%s (via Zulip) <%s>" % (sender_str, settings.NOREPLY_EMAIL_ADDRESS)
|
||||
|
||||
text_content = loader.render_to_string('zerver/missed_message_email.txt', template_payload)
|
||||
html_content = loader.render_to_string('zerver/missed_message_email_html.txt', template_payload)
|
||||
|
||||
msg = EmailMultiAlternatives(subject, text_content, from_email, [user_profile.email],
|
||||
headers = headers)
|
||||
msg.attach_alternative(html_content, "text/html")
|
||||
msg.send()
|
||||
|
||||
user_profile.last_reminder = datetime.datetime.now()
|
||||
user_profile.save(update_fields=['last_reminder'])
|
||||
|
||||
return
|
||||
|
||||
|
||||
def handle_missedmessage_emails(user_profile_id, missed_email_events):
|
||||
message_ids = [event.get('message_id') for event in missed_email_events]
|
||||
|
||||
user_profile = get_user_profile_by_id(user_profile_id)
|
||||
if not receives_offline_notifications(user_profile):
|
||||
return
|
||||
|
||||
messages = [um.message for um in UserMessage.objects.filter(user_profile=user_profile,
|
||||
message__id__in=message_ids,
|
||||
flags=~UserMessage.flags.read)]
|
||||
|
||||
if messages:
|
||||
do_send_missedmessage_events(user_profile, messages)
|
||||
|
||||
@uses_mandrill
|
||||
def clear_followup_emails_queue(email, mail_client=None):
|
||||
"""
|
||||
Clear out queued emails (from Mandrill's queue) that would otherwise
|
||||
be sent to a specific email address. Optionally specify which sender
|
||||
to filter by (useful when there are more Zulip subsystems using our
|
||||
mandrill account).
|
||||
|
||||
`email` is a string representing the recipient email
|
||||
`from_email` is a string representing the zulip email account used
|
||||
to send the email (for example `support@zulip.com` or `signups@zulip.com`)
|
||||
"""
|
||||
# Zulip Enterprise implementation
|
||||
if not mail_client:
|
||||
items = ScheduledJob.objects.filter(type=ScheduledJob.EMAIL, filter_string__iexact = email)
|
||||
items.delete()
|
||||
return
|
||||
|
||||
# Mandrill implementation
|
||||
for email in mail_client.messages.list_scheduled(to=email):
|
||||
result = mail_client.messages.cancel_scheduled(id=email["_id"])
|
||||
if result.get("status") == "error":
|
||||
print result.get("name"), result.get("error")
|
||||
return
|
||||
|
||||
@uses_mandrill
|
||||
def send_future_email(recipients, email_html, email_text, subject,
|
||||
delay=datetime.timedelta(0), sender=None,
|
||||
tags=[], mail_client=None):
|
||||
"""
|
||||
Sends email via Mandrill, with optional delay
|
||||
|
||||
'mail_client' is filled in by the decorator
|
||||
"""
|
||||
# When sending real emails while testing locally, don't accidentally send
|
||||
# emails to non-zulip.com users.
|
||||
if not settings.DEPLOYED and \
|
||||
settings.EMAIL_BACKEND != 'django.core.mail.backends.console.EmailBackend':
|
||||
for recipient in recipients:
|
||||
email = recipient.get("email")
|
||||
if get_user_profile_by_email(email).realm.domain != "zulip.com":
|
||||
raise ValueError("digest: refusing to send emails to non-zulip.com users.")
|
||||
|
||||
# message = {"from_email": "othello@zulip.com",
|
||||
# "from_name": "Othello",
|
||||
# "html": "<p>hello</p> there",
|
||||
# "tags": ["signup-reminders"],
|
||||
# "to": [{'email':"acrefoot@zulip.com", 'name': "thingamajig"}]
|
||||
# }
|
||||
|
||||
# Zulip Enterprise implementation
|
||||
if not mail_client:
|
||||
if sender is None:
|
||||
# This may likely overridden by settings.DEFAULT_FROM_EMAIL
|
||||
sender = {'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}
|
||||
for recipient in recipients:
|
||||
email_fields = {'email_html': email_html,
|
||||
'email_subject': subject,
|
||||
'email_text': email_text,
|
||||
'recipient_email': recipient.get('email'),
|
||||
'recipient_name': recipient.get('name'),
|
||||
'sender_email': sender['email'],
|
||||
'sender_name': sender['name']}
|
||||
ScheduledJob.objects.create(type=ScheduledJob.EMAIL, filter_string=recipient.get('email'),
|
||||
data=ujson.dumps(email_fields),
|
||||
scheduled_timestamp=datetime.datetime.utcnow() + delay)
|
||||
return
|
||||
|
||||
# Mandrill implementation
|
||||
if sender is None:
|
||||
sender = {'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}
|
||||
|
||||
message = {'from_email': sender['email'],
|
||||
'from_name': sender['name'],
|
||||
'to': recipients,
|
||||
'subject': subject,
|
||||
'html': email_html,
|
||||
'text': email_text,
|
||||
'tags': tags,
|
||||
}
|
||||
# ignore any delays smaller than 1-minute because it's cheaper just to sent them immediately
|
||||
if type(delay) is not datetime.timedelta:
|
||||
raise TypeError("specified delay is of the wrong type: %s" % (type(delay),))
|
||||
if delay < datetime.timedelta(minutes=1):
|
||||
results = mail_client.messages.send(message=message, async=False, ip_pool="Main Pool")
|
||||
else:
|
||||
send_time = (datetime.datetime.utcnow() + delay).__format__("%Y-%m-%d %H:%M:%S")
|
||||
results = mail_client.messages.send(message=message, async=False, ip_pool="Main Pool", send_at=send_time)
|
||||
problems = [result for result in results if (result['status'] in ('rejected', 'invalid'))]
|
||||
if problems:
|
||||
raise Exception("While sending email (%s), encountered problems with these recipients: %r"
|
||||
% (subject, problems))
|
||||
return
|
||||
|
||||
def send_local_email_template_with_delay(recipients, template_prefix,
|
||||
template_payload, delay,
|
||||
tags=[], sender={'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}):
|
||||
html_content = loader.render_to_string(template_prefix + ".html", template_payload)
|
||||
text_content = loader.render_to_string(template_prefix + ".text", template_payload)
|
||||
subject = loader.render_to_string(template_prefix + ".subject", template_payload).strip()
|
||||
|
||||
return send_future_email(recipients,
|
||||
html_content,
|
||||
text_content,
|
||||
subject,
|
||||
delay=delay,
|
||||
sender=sender,
|
||||
tags=tags)
|
||||
|
||||
def enqueue_welcome_emails(email, name):
|
||||
sender = {'email': 'wdaher@zulip.com', 'name': 'Waseem Daher'}
|
||||
if settings.ENTERPRISE:
|
||||
sender = {'email': settings.ZULIP_ADMINISTRATOR, 'name': 'Zulip'}
|
||||
|
||||
user_profile = get_user_profile_by_email(email)
|
||||
unsubscribe_link = one_click_unsubscribe_link(user_profile, "welcome")
|
||||
|
||||
template_payload = {'name': name,
|
||||
'not_enterprise': not settings.ENTERPRISE,
|
||||
'external_host': settings.EXTERNAL_HOST,
|
||||
'unsubscribe_link': unsubscribe_link}
|
||||
|
||||
#Send day 1 email
|
||||
send_local_email_template_with_delay([{'email': email, 'name': name}],
|
||||
"zerver/emails/followup/day1",
|
||||
template_payload,
|
||||
datetime.timedelta(hours=1),
|
||||
tags=["followup-emails"],
|
||||
sender=sender)
|
||||
#Send day 2 email
|
||||
tomorrow = datetime.datetime.utcnow() + datetime.timedelta(hours=24)
|
||||
# 11 AM EDT
|
||||
tomorrow_morning = datetime.datetime(tomorrow.year, tomorrow.month, tomorrow.day, 15, 0)
|
||||
assert(datetime.datetime.utcnow() < tomorrow_morning)
|
||||
send_local_email_template_with_delay([{'email': email, 'name': name}],
|
||||
"zerver/emails/followup/day2",
|
||||
template_payload,
|
||||
tomorrow_morning - datetime.datetime.utcnow(),
|
||||
tags=["followup-emails"],
|
||||
sender=sender)
|
||||
|
||||
def convert_html_to_markdown(html):
|
||||
# On Linux, the tool installs as html2markdown, and there's a command called
|
||||
# html2text that does something totally different. On OSX, the tool installs
|
||||
# as html2text.
|
||||
commands = ["html2markdown", "html2text"]
|
||||
|
||||
for command in commands:
|
||||
try:
|
||||
# A body width of 0 means do not try to wrap the text for us.
|
||||
p = subprocess.Popen(
|
||||
[command, "--body-width=0"], stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
markdown = p.communicate(input=html.encode("utf-8"))[0].strip()
|
||||
# We want images to get linked and inline previewed, but html2text will turn
|
||||
# them into links of the form `![](http://foo.com/image.png)`, which is
|
||||
# ugly. Run a regex over the resulting description, turning links of the
|
||||
# form `![](http://foo.com/image.png?12345)` into
|
||||
# `[image.png](http://foo.com/image.png)`.
|
||||
return re.sub(r"!\[\]\((\S*)/(\S*)\?(\S*)\)",
|
||||
r"[\2](\1/\2)", markdown).decode("utf-8")
|
|
@ -365,6 +365,11 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
|
|||
# in a more complicated way, like certain realms may allow only admins to create streams.
|
||||
return True
|
||||
|
||||
def receives_offline_notifications(user_profile):
|
||||
return ((user_profile.enable_offline_email_notifications or
|
||||
user_profile.enable_offline_push_notifications) and
|
||||
not user_profile.is_bot)
|
||||
|
||||
# Make sure we flush the UserProfile object from our memcached
|
||||
# whenever we save it.
|
||||
post_save.connect(update_user_profile_cache, sender=UserProfile)
|
||||
|
|
|
@ -24,8 +24,8 @@ from zerver.lib.actions import check_send_message, gather_subscriptions, \
|
|||
do_remove_subscription, do_add_realm_filter, do_remove_realm_filter, do_change_full_name, \
|
||||
create_stream_if_needed, do_add_subscription, compute_mit_user_fullname, \
|
||||
do_add_realm_emoji, do_remove_realm_emoji, check_message, do_create_user, \
|
||||
set_default_streams, get_emails_from_user_ids, one_click_unsubscribe_link, \
|
||||
do_deactivate_user, do_reactivate_user, enqueue_welcome_emails, do_change_is_admin, \
|
||||
set_default_streams, get_emails_from_user_ids, \
|
||||
do_deactivate_user, do_reactivate_user, do_change_is_admin, \
|
||||
do_rename_stream, do_change_stream_description
|
||||
from zerver.lib.rate_limiter import add_ratelimit_rule, remove_ratelimit_rule
|
||||
from zerver.lib import bugdown
|
||||
|
@ -38,6 +38,7 @@ from zerver.lib.alert_words import alert_words_in_realm, user_alert_words, \
|
|||
from zerver.lib.digest import send_digest_email
|
||||
from zerver.lib.db import TimeTrackingCursor
|
||||
from zerver.forms import not_mit_mailing_list
|
||||
from zerver.lib.notifications import enqueue_welcome_emails, one_click_unsubscribe_link
|
||||
from zerver.lib.validator import check_string, check_list, check_dict, \
|
||||
check_bool, check_int
|
||||
from zerver.middleware import is_slow_query
|
||||
|
|
|
@ -4,7 +4,8 @@ from __future__ import absolute_import
|
|||
|
||||
from django.conf import settings
|
||||
from zerver.models import UserProfile, get_client, get_user_profile_by_email
|
||||
from zerver.lib.actions import check_send_message, convert_html_to_markdown
|
||||
from zerver.lib.actions import check_send_message
|
||||
from zerver.lib.notifications import convert_html_to_markdown
|
||||
from zerver.lib.response import json_success, json_error
|
||||
from zerver.decorator import authenticated_api_view, REQ, \
|
||||
has_request_variables, json_to_dict, authenticated_rest_api_view, \
|
||||
|
|
|
@ -9,11 +9,12 @@ from zerver.models import get_user_profile_by_email, \
|
|||
from zerver.lib.context_managers import lockfile
|
||||
from zerver.lib.queue import SimpleQueueClient, queue_json_publish
|
||||
from zerver.lib.timestamp import timestamp_to_datetime
|
||||
from zerver.lib.actions import handle_missedmessage_emails, do_send_confirmation_email, \
|
||||
from zerver.lib.notifications import handle_missedmessage_emails, enqueue_welcome_emails, \
|
||||
clear_followup_emails_queue, send_local_email_template_with_delay
|
||||
from zerver.lib.actions import do_send_confirmation_email, \
|
||||
do_update_user_activity, do_update_user_activity_interval, do_update_user_presence, \
|
||||
internal_send_message, send_local_email_template_with_delay, clear_followup_emails_queue, \
|
||||
check_send_message, extract_recipients, one_click_unsubscribe_link, \
|
||||
enqueue_welcome_emails, handle_push_notification
|
||||
internal_send_message, check_send_message, extract_recipients, \
|
||||
handle_push_notification
|
||||
from zerver.lib.digest import handle_digest_email
|
||||
from zerver.lib.email_mirror import process_message as mirror_email
|
||||
from zerver.decorator import JsonableError
|
||||
|
|
Loading…
Reference in New Issue