Repository: zulip/zulip

Issue:
Use a queue with retry support for sending new-login emails
We just got this exception (basically a transient network error trying to send email), which I think prevented the user in question from logging in:

```
Traceback (most recent call last):
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/core/handlers/exception.py", line 41, in inner
    response = get_response(request)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/core/handlers/base.py", line 187, in _get_response
    response = self.process_exception_by_middleware(e, request)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/core/handlers/base.py", line 185, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "./zerver/views/auth.py", line 550, in login_page
    extra_context=extra_context, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/contrib/auth/views.py", line 54, in inner
    return func(*args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/contrib/auth/views.py", line 150, in login
    )(request)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/views/generic/base.py", line 68, in view
    return self.dispatch(request, *args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 67, in _wrapper
    return bound_func(*args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/views/decorators/debug.py", line 76, in sensitive_post_parameters_wrapper
    return view(request, *args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 63, in bound_func
    return func.__get__(self, type(self))(*args2, **kwargs2)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 67, in _wrapper
    return bound_func(*args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 149, in _wrapped_view
    response = view_func(request, *args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 63, in bound_func
    return func.__get__(self, type(self))(*args2, **kwargs2)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 67, in _wrapper
    return bound_func(*args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/views/decorators/cache.py", line 57, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/utils/decorators.py", line 63, in bound_func
    return func.__get__(self, type(self))(*args2, **kwargs2)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/contrib/auth/views.py", line 90, in dispatch
    return super(LoginView, self).dispatch(request, *args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/views/generic/base.py", line 88, in dispatch
    return handler(request, *args, **kwargs)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/views/generic/edit.py", line 183, in post
    return self.form_valid(form)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/contrib/auth/views.py", line 119, in form_valid
    auth_login(self.request, form.get_user())
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/contrib/auth/__init__.py", line 160, in login
    user_logged_in.send(sender=user.__class__, request=request, user=user)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/dispatch/dispatcher.py", line 193, in send
    for receiver in self._live_receivers(sender)
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/dispatch/dispatcher.py", line 193, in <listcomp>
    for receiver in self._live_receivers(sender)
  File "./zerver/signals.py", line 91, in email_on_new_login
    context=context)
  File "./zerver/lib/send_email.py", line 92, in send_email
    if mail.send() == 0:
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/core/mail/message.py", line 348, in send
    return self.get_connection(fail_silently).send_messages([self])
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/core/mail/backends/smtp.py", line 104, in send_messages
    new_conn_created = self.open()
  File "/home/zulip/deployments/2017-11-17-20-18-14/zulip-py3-venv/lib/python3.5/site-packages/django/core/mail/backends/smtp.py", line 64, in open
    self.connection = self.connection_class(self.host, self.port, **connection_params)
  File "/usr/lib/python3.5/smtplib.py", line 251, in __init__
    (code, msg) = self.connect(host, port)
  File "/usr/lib/python3.5/smtplib.py", line 337, in connect
    (code, msg) = self.getreply()
  File "/usr/lib/python3.5/smtplib.py", line 393, in getreply
    raise SMTPServerDisconnected("Connection unexpectedly closed")
smtplib.SMTPServerDisconnected: Connection unexpectedly closed
```

I think the right fix for this is to just move new-login emails to be sent using a queue, like many of our other emails are.  Ideally, we'd move this into an existing queue.

E.g. possibly `missedmessage_email_senders` should be turned into a more generic `email_senders` worker that several things push emails into.  


Discussion:
Hello @zulip/server-notifications, @zulip/server-production members, this issue was labeled with the **area: notifications (other)**, **area: production** labels, so you may want to check it out!

<!-- areaLabelNotification -->

@umairwaheed can you take this one?  Might also be worth reading the last notes on https://github.com/zulip/zulip/issues/6542#issuecomment-347033920 in case it makes sense to fix coverage there as well.
Sure. @zulipbot claim.

Resolving patch:
diff --git a/zerver/lib/notifications.py b/zerver/lib/notifications.py
--- a/zerver/lib/notifications.py
+++ b/zerver/lib/notifications.py
@@ -366,7 +366,7 @@ def do_send_missedmessage_events_reply_in_zulip(user_profile: UserProfile,
         'from_address': from_address,
         'reply_to_email': formataddr((reply_to_name, reply_to_address)),
         'context': context}
-    queue_json_publish("missedmessage_email_senders", email_dict)
+    queue_json_publish("email_senders", email_dict)
 
     user_profile.last_reminder = timezone_now()
     user_profile.save(update_fields=['last_reminder'])
diff --git a/zerver/signals.py b/zerver/signals.py
--- a/zerver/signals.py
+++ b/zerver/signals.py
@@ -9,6 +9,7 @@
     get_current_timezone_name as timezone_get_current_timezone_name
 from django.utils.timezone import now as timezone_now
 
+from zerver.lib.queue import queue_json_publish
 from zerver.lib.send_email import FromAddress, send_email
 from zerver.models import UserProfile
 
@@ -82,6 +83,10 @@ def email_on_new_login(sender: Any, user: UserProfile, request: Any, **kwargs: A
         context['device_info'] = device_info
         context['user'] = user
 
-        send_email('zerver/emails/notify_new_login', to_user_id=user.id,
-                   from_name='Zulip Account Security', from_address=FromAddress.NOREPLY,
-                   context=context)
+        email_dict = {
+            'template_prefix': 'zerver/emails/notify_new_login',
+            'to_user_id': user.id,
+            'from_name': 'Zulip Account Security',
+            'from_address': FromAddress.NOREPLY,
+            'context': context}
+        queue_json_publish("email_senders", email_dict)
diff --git a/zerver/worker/queue_processors.py b/zerver/worker/queue_processors.py
--- a/zerver/worker/queue_processors.py
+++ b/zerver/worker/queue_processors.py
@@ -297,8 +297,8 @@ def consume_batch(self, missed_events: List[Dict[str, Any]]) -> None:
         for user_profile_id, events in by_recipient.items():
             handle_missedmessage_emails(user_profile_id, events)
 
-@assign_queue('missedmessage_email_senders')
-class MissedMessageSendingWorker(QueueProcessingWorker):
+@assign_queue('email_senders')
+class EmailSendingWorker(QueueProcessingWorker):
     @retry_send_email_failures
     def consume(self, data):
         # type: (Dict[str, Any]) -> None
@@ -308,6 +308,19 @@ def consume(self, data):
             # TODO: Do something smarter here ..
             pass
 
+@assign_queue('missedmessage_email_senders')
+class MissedMessageSendingWorker(EmailSendingWorker):
+    """
+    Note: Class decorators are not inherited.
+
+    The `missedmessage_email_senders` queue was used up through 1.7.1, so we
+    keep consuming from it in case we've just upgraded from an old version.
+    After the 1.8 release, we can delete it and tell admins to upgrade to 1.8
+    first.
+    """
+    # TODO: zulip-1.8: Delete code related to missedmessage_email_senders queue.
+    pass
+
 @assign_queue('missedmessage_mobile_notifications')
 class PushNotificationsWorker(QueueProcessingWorker):
     def consume(self, data):


Test patch:
diff --git a/zerver/tests/test_queue_worker.py b/zerver/tests/test_queue_worker.py
--- a/zerver/tests/test_queue_worker.py
+++ b/zerver/tests/test_queue_worker.py
@@ -17,6 +17,7 @@
 from zerver.worker.queue_processors import (
     get_active_worker_queues,
     QueueProcessingWorker,
+    EmailSendingWorker,
     LoopQueueProcessingWorker,
     MissedMessageWorker,
 )
@@ -171,7 +172,7 @@ def test_email_sending_worker_retries(self) -> None:
         fake_client = self.FakeClient()
 
         data = {'test': 'test', 'id': 'test_missed'}
-        fake_client.queue.append(('missedmessage_email_senders', data))
+        fake_client.queue.append(('email_senders', data))
 
         def fake_publish(queue_name: str,
                          event: Dict[str, Any],
@@ -179,7 +180,7 @@ def fake_publish(queue_name: str,
             fake_client.queue.append((queue_name, event))
 
         with simulated_queue_client(lambda: fake_client):
-            worker = queue_processors.MissedMessageSendingWorker()
+            worker = queue_processors.EmailSendingWorker()
             worker.setup()
             with patch('zerver.worker.queue_processors.send_email_from_dict',
                        side_effect=smtplib.SMTPServerDisconnected), \
@@ -332,6 +333,7 @@ def __init__(self) -> None:
 
     def test_get_active_worker_queues(self) -> None:
         worker_queue_count = (len(QueueProcessingWorker.__subclasses__()) +
+                              len(EmailSendingWorker.__subclasses__()) +
                               len(LoopQueueProcessingWorker.__subclasses__()) - 1)
         self.assertEqual(worker_queue_count, len(get_active_worker_queues()))
         self.assertEqual(1, len(get_active_worker_queues(queue_type='test')))