Add customizable invite-new-user text.

This makes life a lot easier for people inviting users to a new Zulip
organization, since they can give some form of context now.

Modified by tabbott to clean up CSS, backend code flow, and improve
the formatting of the emails.

Fixes: #1409.
This commit is contained in:
Ayush Jain
2017-02-13 01:51:31 +05:30
committed by Tim Abbott
parent cf96b1b873
commit 455c1919fc
11 changed files with 82 additions and 26 deletions

View File

@@ -94,8 +94,8 @@ class ConfirmationManager(models.Manager):
def send_confirmation(self, obj, email_address, additional_context=None, def send_confirmation(self, obj, email_address, additional_context=None,
subject_template_path=None, body_template_path=None, html_body_template_path=None, subject_template_path=None, body_template_path=None, html_body_template_path=None,
host=None): host=None, custom_body=None):
# type: (ContentType, Text, Optional[Dict[str, Any]], Optional[str], Optional[str], Optional[str], Optional[str]) -> Confirmation # type: (ContentType, Text, Optional[Dict[str, Any]], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> Confirmation
confirmation_key = generate_key() confirmation_key = generate_key()
current_site = Site.objects.get_current() current_site = Site.objects.get_current()
activate_url = self.get_activation_url(confirmation_key, host=host) activate_url = self.get_activation_url(confirmation_key, host=host)
@@ -105,6 +105,7 @@ class ConfirmationManager(models.Manager):
'confirmation_key': confirmation_key, 'confirmation_key': confirmation_key,
'target': obj, 'target': obj,
'days': getattr(settings, 'EMAIL_CONFIRMATION_DAYS', 10), 'days': getattr(settings, 'EMAIL_CONFIRMATION_DAYS', 10),
'custom_body': custom_body,
}) })
if additional_context is not None: if additional_context is not None:
context.update(additional_context) context.update(additional_context)

View File

@@ -2124,6 +2124,10 @@ button.topic_edit_cancel {
max-height: 300px; max-height: 300px;
} }
#invite-user .custom_invite_body {
margin-top: 10px;
}
#invite_status { #invite_status {
display: none; display: none;
} }

View File

@@ -13,6 +13,9 @@
<p> <p>
{{ referrer.full_name }} ({{ referrer.email }}) wants you to join them on Zulip -- the group communication tool you've always wished you had at work. {{ referrer.full_name }} ({{ referrer.email }}) wants you to join them on Zulip -- the group communication tool you've always wished you had at work.
</p> </p>
{% if custom_body %}
<p>Message from {{ referrer.full_name }}: {{ custom_body }}</p>
{% endif %}
<p> <p>
To get started, visit the link below: To get started, visit the link below:
<br /> <br />

View File

@@ -1,10 +1,11 @@
Hi there, Hi there,
{{ referrer.full_name }} ({{ referrer.email }}) wants you to join them on Zulip -- the group communication tool you've always wished you had at work. {{ referrer.full_name }} ({{ referrer.email }}) wants you to join them on Zulip -- the group communication tool you've always wished you had at work.
{% if custom_body %}
Message from {{ referrer.full_name }}: {{ custom_body }}
{% endif %}
To get started, visit the link below: To get started, visit the link below:
<{{ activate_url }}> <{{ activate_url }}>
{% if verbose_support_offers %} {% if verbose_support_offers %}
Feel free to give us a shout at <{{ support_email }}> if you have any questions. Feel free to give us a shout at <{{ support_email }}> if you have any questions.
{% else %} {% else %}

View File

@@ -2,9 +2,10 @@ Hi there,
{{ referrer.full_name }} ({{ referrer.email }}) wants you to join them on Zulip, an awesome web-based Zephyr client with desktop apps for Mac, Linux, and Windows, as well as native mobile apps. {{ referrer.full_name }} ({{ referrer.email }}) wants you to join them on Zulip, an awesome web-based Zephyr client with desktop apps for Mac, Linux, and Windows, as well as native mobile apps.
{% if custom_body %}Message from {{ referrer.full_name }}: {{ custom_body }}
{% endif %}
To get started, visit the link below: To get started, visit the link below:
<{{ activate_url }}> <{{ activate_url }}>
{% if verbose_support_offers %} {% if verbose_support_offers %}
Feel free to give us a shout at <{{ support_email }}> if you have any questions. Feel free to give us a shout at <{{ support_email }}> if you have any questions.
{% else %} {% else %}

View File

@@ -15,6 +15,12 @@
name="invitee_emails" name="invitee_emails"
placeholder="{{ _('One or more email addresses...') }}"></textarea> placeholder="{{ _('One or more email addresses...') }}"></textarea>
</div> </div>
<label class="control-label" for="custom_body">{{ _('Custom invitation message (if you want to add one)') }}</label>
<div class="controls">
<textarea rows="2" class="custom_invite_body"
name="custom_body"
placeholder="{{ _('Custom message') }}"></textarea>
</div>
</div> </div>
<div class="alert" id="invite_status"></div> <div class="alert" id="invite_status"></div>
{% if development_environment %} {% if development_environment %}

View File

@@ -2987,8 +2987,8 @@ def get_cross_realm_dicts():
'full_name': user.full_name} 'full_name': user.full_name}
for user in users] for user in users]
def do_send_confirmation_email(invitee, referrer): def do_send_confirmation_email(invitee, referrer, body):
# type: (PreregistrationUser, UserProfile) -> None # type: (PreregistrationUser, UserProfile, Optional[str]) -> None
""" """
Send the confirmation/welcome e-mail to an invited user. Send the confirmation/welcome e-mail to an invited user.
@@ -3013,7 +3013,7 @@ def do_send_confirmation_email(invitee, referrer):
subject_template_path=subject_template_path, subject_template_path=subject_template_path,
body_template_path=body_template_path, body_template_path=body_template_path,
html_body_template_path=html_body_template_path, html_body_template_path=html_body_template_path,
host=referrer.realm.host) host=referrer.realm.host, custom_body=body)
@statsd_increment("push_notifications") @statsd_increment("push_notifications")
def handle_push_notification(user_profile_id, missed_message): def handle_push_notification(user_profile_id, missed_message):
@@ -3123,8 +3123,8 @@ def validate_email(user_profile, email):
return None, None return None, None
def do_invite_users(user_profile, invitee_emails, streams): def do_invite_users(user_profile, invitee_emails, streams, body=None):
# type: (UserProfile, SizedTextIterable, Iterable[Stream]) -> Tuple[Optional[str], Dict[str, Union[List[Tuple[Text, str]], bool]]] # type: (UserProfile, SizedTextIterable, Iterable[Stream], Optional[str]) -> Tuple[Optional[str], Dict[str, Union[List[Tuple[Text, str]], bool]]]
validated_emails = [] # type: List[Text] validated_emails = [] # type: List[Text]
errors = [] # type: List[Tuple[Text, str]] errors = [] # type: List[Tuple[Text, str]]
skipped = [] # type: List[Tuple[Text, str]] skipped = [] # type: List[Tuple[Text, str]]
@@ -3168,9 +3168,9 @@ def do_invite_users(user_profile, invitee_emails, streams):
prereg_user.streams = streams prereg_user.streams = streams
prereg_user.save() prereg_user.save()
event = {"email": prereg_user.email, "referrer_email": user_profile.email} event = {"email": prereg_user.email, "referrer_email": user_profile.email, "email_body": body}
queue_json_publish("invites", event, queue_json_publish("invites", event,
lambda event: do_send_confirmation_email(prereg_user, user_profile)) lambda event: do_send_confirmation_email(prereg_user, user_profile, body))
if skipped: if skipped:
ret_error = _("Some of those addresses are already using Zulip, " ret_error = _("Some of those addresses are already using Zulip, "

View File

@@ -201,6 +201,14 @@ def find_key_by_email(address):
if address in message.to: if address in message.to:
return key_regex.search(message.body).groups()[0] return key_regex.search(message.body).groups()[0]
def find_pattern_in_email(address, pattern):
# type: (Text, Text) -> Text
from django.core.mail import outbox
key_regex = re.compile(pattern)
for message in reversed(outbox):
if address in message.to:
return key_regex.search(message.body).group(0)
def message_ids(result): def message_ids(result):
# type: (Dict[str, Any]) -> Set[int] # type: (Dict[str, Any]) -> Set[int]
return set(message['id'] for message in result['messages']) return set(message['id'] for message in result['messages'])

View File

@@ -34,7 +34,7 @@ from zerver.lib.actions import do_deactivate_realm, do_set_realm_default_languag
from zerver.lib.digest import send_digest_email from zerver.lib.digest import send_digest_email
from zerver.lib.notifications import ( from zerver.lib.notifications import (
enqueue_welcome_emails, one_click_unsubscribe_link, send_local_email_template_with_delay) enqueue_welcome_emails, one_click_unsubscribe_link, send_local_email_template_with_delay)
from zerver.lib.test_helpers import find_key_by_email, queries_captured, \ from zerver.lib.test_helpers import find_pattern_in_email, find_key_by_email, queries_captured, \
HostRequestMock HostRequestMock
from zerver.lib.test_classes import ( from zerver.lib.test_classes import (
ZulipTestCase, ZulipTestCase,
@@ -46,6 +46,8 @@ from zerver.context_processors import common_context
import re import re
import ujson import ujson
from typing import Set, Optional
from six.moves import urllib from six.moves import urllib
from six.moves import range from six.moves import range
import six import six
@@ -333,8 +335,8 @@ class LoginTest(ZulipTestCase):
class InviteUserTest(ZulipTestCase): class InviteUserTest(ZulipTestCase):
def invite(self, users, streams): def invite(self, users, streams, body=''):
# type: (str, List[Text]) -> HttpResponse # type: (str, List[Text], str) -> HttpResponse
""" """
Invites the specified users to Zulip with the specified streams. Invites the specified users to Zulip with the specified streams.
@@ -346,14 +348,23 @@ class InviteUserTest(ZulipTestCase):
return self.client_post("/json/invite_users", return self.client_post("/json/invite_users",
{"invitee_emails": users, {"invitee_emails": users,
"stream": streams}) "stream": streams,
"custom_body": body})
def check_sent_emails(self, correct_recipients): def check_sent_emails(self, correct_recipients, custom_body=None):
# type: (List[str]) -> None # type: (List[str], Optional[str]) -> None
from django.core.mail import outbox from django.core.mail import outbox
self.assertEqual(len(outbox), len(correct_recipients)) self.assertEqual(len(outbox), len(correct_recipients))
email_recipients = [email.recipients()[0] for email in outbox] email_recipients = [email.recipients()[0] for email in outbox]
self.assertEqual(sorted(email_recipients), sorted(correct_recipients)) self.assertEqual(sorted(email_recipients), sorted(correct_recipients))
if len(outbox) == 0:
return
if custom_body is None:
self.assertNotIn("Message from", outbox[0].body)
else:
self.assertIn("Message from ", outbox[0].body)
self.assertIn(custom_body, outbox[0].body)
def test_bulk_invite_users(self): def test_bulk_invite_users(self):
# type: () -> None # type: () -> None
@@ -361,7 +372,7 @@ class InviteUserTest(ZulipTestCase):
self.login('hamlet@zulip.com') self.login('hamlet@zulip.com')
invitees = ['alice@zulip.com', 'bob@zulip.com'] invitees = ['alice@zulip.com', 'bob@zulip.com']
params = { params = {
'invitee_emails': ujson.dumps(invitees) 'invitee_emails': ujson.dumps(invitees),
} }
result = self.client_post('/json/bulk_invite_users', params) result = self.client_post('/json/bulk_invite_users', params)
self.assert_json_success(result) self.assert_json_success(result)
@@ -379,6 +390,19 @@ class InviteUserTest(ZulipTestCase):
self.assertTrue(find_key_by_email(invitee)) self.assertTrue(find_key_by_email(invitee))
self.check_sent_emails([invitee]) self.check_sent_emails([invitee])
def test_successful_invite_user_with_custom_body(self):
# type: () -> None
"""
A call to /json/invite_users with valid parameters causes an invitation
email to be sent.
"""
self.login("hamlet@zulip.com")
invitee = "alice-test@zulip.com"
body = "Custom Text."
self.assert_json_success(self.invite(invitee, ["Denmark"], body))
self.assertTrue(find_pattern_in_email(invitee, body))
self.check_sent_emails([invitee], custom_body=body)
def test_successful_invite_user_with_name(self): def test_successful_invite_user_with_name(self):
# type: () -> None # type: () -> None
""" """
@@ -458,7 +482,8 @@ earl-test@zulip.com""", ["Denmark"]))
""" """
self.login("hamlet@zulip.com") self.login("hamlet@zulip.com")
self.assert_json_error( self.assert_json_error(
self.client_post("/json/invite_users", {"invitee_emails": "foo@zulip.com"}), self.client_post("/json/invite_users", {"invitee_emails": "foo@zulip.com",
"custom_body": ''}),
"You must specify at least one stream for invitees to join.") "You must specify at least one stream for invitees to join.")
for address in ("noatsign.com", "outsideyourdomain@example.net"): for address in ("noatsign.com", "outsideyourdomain@example.net"):
@@ -486,7 +511,8 @@ earl-test@zulip.com""", ["Denmark"]))
self.assert_json_error( self.assert_json_error(
self.client_post("/json/invite_users", self.client_post("/json/invite_users",
{"invitee_emails": "hamlet@zulip.com", {"invitee_emails": "hamlet@zulip.com",
"stream": ["Denmark"]}), "stream": ["Denmark"],
"custom_body": ''}),
"We weren't able to invite anyone.") "We weren't able to invite anyone.")
self.assertRaises(PreregistrationUser.DoesNotExist, self.assertRaises(PreregistrationUser.DoesNotExist,
lambda: PreregistrationUser.objects.get( lambda: PreregistrationUser.objects.get(
@@ -505,7 +531,8 @@ earl-test@zulip.com""", ["Denmark"]))
result = self.client_post("/json/invite_users", result = self.client_post("/json/invite_users",
{"invitee_emails": "\n".join(existing + new), {"invitee_emails": "\n".join(existing + new),
"stream": ["Denmark"]}) "stream": ["Denmark"],
"custom_body": ''})
self.assert_json_error(result, self.assert_json_error(result,
"Some of those addresses are already using Zulip, \ "Some of those addresses are already using Zulip, \
so we didn't send them an invitation. We did send invitations to everyone else!") so we didn't send them an invitation. We did send invitations to everyone else!")

View File

@@ -19,10 +19,14 @@ import re
@authenticated_json_post_view @authenticated_json_post_view
@has_request_variables @has_request_variables
def json_invite_users(request, user_profile, invitee_emails_raw=REQ("invitee_emails")): def json_invite_users(request, user_profile,
# type: (HttpRequest, UserProfile, str) -> HttpResponse invitee_emails_raw=REQ("invitee_emails"),
body=REQ("custom_body", default=None)):
# type: (HttpRequest, UserProfile, str, Optional[str]) -> HttpResponse
if not invitee_emails_raw: if not invitee_emails_raw:
return json_error(_("You must specify at least one email address.")) return json_error(_("You must specify at least one email address."))
if body == '':
body = None
invitee_emails = get_invitee_emails_set(invitee_emails_raw) invitee_emails = get_invitee_emails_set(invitee_emails_raw)
@@ -44,7 +48,7 @@ def json_invite_users(request, user_profile, invitee_emails_raw=REQ("invitee_ema
return json_error(_("Stream does not exist: %s. No invites were sent.") % (stream_name,)) return json_error(_("Stream does not exist: %s. No invites were sent.") % (stream_name,))
streams.append(stream) streams.append(stream)
ret_error, error_data = do_invite_users(user_profile, invitee_emails, streams) ret_error, error_data = do_invite_users(user_profile, invitee_emails, streams, body)
if ret_error is not None: if ret_error is not None:
return json_error(data=error_data, msg=ret_error) return json_error(data=error_data, msg=ret_error)

View File

@@ -148,7 +148,8 @@ class ConfirmationEmailWorker(QueueProcessingWorker):
# type: (Mapping[str, Any]) -> None # type: (Mapping[str, Any]) -> None
invitee = get_prereg_user_by_email(data["email"]) invitee = get_prereg_user_by_email(data["email"])
referrer = get_user_profile_by_email(data["referrer_email"]) referrer = get_user_profile_by_email(data["referrer_email"])
do_send_confirmation_email(invitee, referrer) body = data["email_body"]
do_send_confirmation_email(invitee, referrer, body)
# queue invitation reminder for two days from now. # queue invitation reminder for two days from now.
link = Confirmation.objects.get_link_for_object(invitee, host=referrer.realm.host) link = Confirmation.objects.get_link_for_object(invitee, host=referrer.realm.host)