emails: Add option to forward mails send in dev env to external email.

Fixes #7085.
This commit is contained in:
Vishnu Ks
2017-10-24 23:58:05 +00:00
committed by Tim Abbott
parent ac763d6eed
commit 36f29764cb
9 changed files with 246 additions and 50 deletions

View File

@@ -56,6 +56,23 @@ our custom backend, `EmailLogBackEnd`. It does the following:
* Print a friendly message on console advertising `/emails` to make * Print a friendly message on console advertising `/emails` to make
this nice and discoverable. this nice and discoverable.
You can also forward all the emails sent in the development environment
to an email id of your choice by clicking on **Forward emails to a mail
account** in `/emails` page. This feature can be used for testing how
emails gets rendered by different email clients. Before enabling this
you have to first configure the following SMTP settings.
* The hostname `EMAIL_HOST` in `zproject/dev_settings.py`
* The username `EMAIL_HOST_USER` in `zproject/dev_settings.py`.
* The password `email_password` in `zproject/dev-secrets.conf`.
See [this](prod-email.html#free-outgoing-email-services)
section for instructions on obtaining SMTP details.
**Note: The base_image_uri of the images in forwarded emails would be replaced
with `https://chat.zulip.org/static/images/emails` inorder for the email clients
to render the images. See `zproject/email_backends.py` for more details.**
While running the backend test suite, we use While running the backend test suite, we use
`django.core.mail.backends.locmem.EmailBackend` as the email `django.core.mail.backends.locmem.EmailBackend` as the email
backend. The `locmem` backend stores messages in a special attribute backend. The `locmem` backend stores messages in a special attribute

View File

@@ -23,6 +23,7 @@
It appears there are problems with the It appears there are problems with the
email configuration. email configuration.
</p> </p>
{% if not development_environment %}
<p> <p>
See <code>/var/log/zulip/errors.log</code> for more See <code>/var/log/zulip/errors.log</code> for more
details on the error. details on the error.
@@ -33,6 +34,14 @@
<a href="https://zulip.readthedocs.io/en/latest/prod-email.html"> <a href="https://zulip.readthedocs.io/en/latest/prod-email.html">
Production installation docs</a>. Production installation docs</a>.
</p> </p>
{% else %}
<p>
Please have a look at our
<a target="_blank" href="https://zulip.readthedocs.io/en/latest/email.html#development-and-testing">
setup guide</a> for forwarding emails sent in development
environment to an email account.
</p>
{% endif %}
{% endif %} {% endif %}
{% if google_error %} {% if google_error %}
@@ -43,10 +52,8 @@
{{ render_markdown_path('zerver/github-error.md', {"root_domain_uri": root_domain_uri, "settings_path": settings_path, "secrets_path": secrets_path}) }} {{ render_markdown_path('zerver/github-error.md', {"root_domain_uri": root_domain_uri, "settings_path": settings_path, "secrets_path": secrets_path}) }}
{% endif %} {% endif %}
<p>After making your changes, remember to restart {% if google_error or github_error %}
the Zulip server.</p> {% if development_environment %}
{% if development %}
<p> <p>
For more information, have a look at For more information, have a look at
the <a href="http://zulip.readthedocs.io/en/latest/settings.html#testing-google-github-authentication">authentication the <a href="http://zulip.readthedocs.io/en/latest/settings.html#testing-google-github-authentication">authentication
@@ -59,6 +66,10 @@
setup guide</a> and the comments in <code>{{ settings_comments_path }}</code>. setup guide</a> and the comments in <code>{{ settings_comments_path }}</code>.
</p> </p>
{% endif %} {% endif %}
{% endif %}
<p>After making your changes, remember to restart
the Zulip server.</p>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,51 @@
<input type="checkbox" id="toggle"/> <input type="checkbox" id="toggle"/>
<strong>Show text only version</strong> <strong>Show text only version</strong>
</label> </label>
<a href="#" data-toggle="modal" data-target="#forward_email_modal">
<strong>Forward emails to a mail account</strong>
</a>
</div>
</div>
<div style="padding-top:100px">
{{ log |safe }}
</div>
<div id="forward_email_modal" class="modal hide" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-body">
<div class="input-group">
<form id="smtp_form">
{{ csrf_input }}
<div class="alert alert-info"
id="smtp_form_status" style="display:none;">
Updated successfully.
</div>
<label for="forward">
<strong>Forwards all emails sent in the
development environment to an external
mail account.
</strong>
</label>
<label class="radio">
<input name="forward" type="radio" value="enabled" {% if forward_address %}checked{% endif %}/>Yes
</label>
<label class="radio">
<input name="forward" type="radio" value="disabled" {% if not forward_address %}checked{% endif %}/>No
</label>
<div id="forward_address_sections" {% if not forward_address %}style="display:none;"{% endif %}>
<label for="forward_address"><strong>Address to which emails should be forwarded</strong></label>
<input type="text" id="address" name="forward_address" placeholder="eg: your-email@example.com" value="{{forward_address}}"/>
</div>
<br/>
<div class="alert alert-info">
You must setup SMTP as described
<a target="_blank" href="https://zulip.readthedocs.io/en/latest/email.html#development-and-testing">
here</a> first before enabling this.
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button id='save_smptp_details'>Update</button>
<button data-dismiss="modal">Close</button>
</div> </div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@@ -33,9 +78,24 @@
}); });
} }
}); });
$('input[type=radio][name=forward]').on('change', function() {
if ($(this).val() == "enabled") {
$("#forward_address_sections").show();
} else {
$("#forward_address_sections").hide();
}
});
$("#save_smptp_details").on("click", function() {
var address = $('input[name=forward]:checked').val() == "enabled" ? $("#address").val(): "";
var csrf_token = $('input[name="csrfmiddlewaretoken"]').attr('value');
var data = {"forward_address": address, "csrfmiddlewaretoken": csrf_token};
$.post("/emails/", data, function() {
$("#smtp_form_status").show();
setTimeout(function() {
$("#smtp_form_status").hide();
}, 3000);
});
});
</script> </script>
<div style="padding-top:100px">
{{ log |safe }}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -468,7 +468,8 @@ def build_custom_checkers(by_lang):
'<td><input type="text" class="new-realm-domain" placeholder="acme.com"></input></td>')], '<td><input type="text" class="new-realm-domain" placeholder="acme.com"></input></td>')],
'exclude': set(["static/templates/settings/emoji-settings-admin.handlebars", 'exclude': set(["static/templates/settings/emoji-settings-admin.handlebars",
"static/templates/settings/realm-filter-settings-admin.handlebars", "static/templates/settings/realm-filter-settings-admin.handlebars",
"static/templates/settings/bot-settings.handlebars"])}, "static/templates/settings/bot-settings.handlebars",
"templates/zerver/email_log.html"])},
{'pattern': "placeholder='[^{]", {'pattern': "placeholder='[^{]",
'description': "`placeholder` value should be translatable."}, 'description': "`placeholder` value should be translatable."},
{'pattern': "aria-label='[^{]", {'pattern': "aria-label='[^{]",

View File

@@ -73,19 +73,6 @@ class DocPageTest(ZulipTestCase):
self._test('/devtools/', 'Useful development URLs') self._test('/devtools/', 'Useful development URLs')
self._test('/errors/404/', 'Page not found') self._test('/errors/404/', 'Page not found')
self._test('/errors/5xx/', 'Internal server error') self._test('/errors/5xx/', 'Internal server error')
with self.settings(EMAIL_BACKEND='zproject.email_backends.EmailLogBackEnd'), \
mock.patch('logging.info', return_value=None):
# For reaching full coverage for clear_emails function
result = self.client_get('/emails/clear/')
self.assertEqual(result.status_code, 302)
result = self.client_get(result['Location'])
self.assertIn('manually generate most of the emails by clicking', str(result.content))
result = self.client_get('/emails/generate/')
self.assertEqual(result.status_code, 302)
self.assertIn('emails', result['Location'])
self._test('/emails/', 'manually generate most of the emails by clicking') self._test('/emails/', 'manually generate most of the emails by clicking')
self._test('/register/', 'Sign up for Zulip') self._test('/register/', 'Sign up for Zulip')
@@ -239,4 +226,4 @@ class ConfigErrorTest(ZulipTestCase):
# type: () -> None # type: () -> None
result = self.client_get("/config-error/smtp") result = self.client_get("/config-error/smtp")
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["/var/log/zulip"], result) self.assert_in_success_response(["email configuration"], result)

View File

@@ -0,0 +1,46 @@
import os
import mock
from django.conf import settings
from zerver.lib.test_classes import ZulipTestCase
from zproject.email_backends import get_forward_address
class EmailLogTest(ZulipTestCase):
def test_get_email_log_page(self):
# type: () -> None
result = self.client_get("/emails/")
self.assert_in_success_response(["All the emails sent in the Zulip"], result)
def test_clear_email_logs(self):
# type: () -> None
result = self.client_get('/emails/clear/')
self.assertEqual(result.status_code, 302)
result = self.client_get(result['Location'])
self.assertIn('manually generate most of the emails by clicking', str(result.content))
def test_generate_emails(self):
# type: () -> None
with self.settings(EMAIL_BACKEND='zproject.email_backends.EmailLogBackEnd'), \
mock.patch('logging.info', return_value=None):
with mock.patch('zproject.email_backends.EmailLogBackEnd.send_email_smtp'):
result = self.client_get('/emails/generate/')
self.assertEqual(result.status_code, 302)
self.assertIn('emails', result['Location'])
def test_forward_address_details(self):
# type: () -> None
forward_address = "forward-to@example.com"
result = self.client_post("/emails/", {"forward_address": forward_address})
self.assert_json_success(result)
self.assertEqual(get_forward_address(), forward_address)
with self.settings(EMAIL_BACKEND='zproject.email_backends.EmailLogBackEnd'), \
mock.patch('logging.info', return_value=None):
with mock.patch('zproject.email_backends.EmailLogBackEnd.send_email_smtp'):
result = self.client_get('/emails/generate/')
self.assertEqual(result.status_code, 302)
self.assertIn('emails', result['Location'])
result = self.client_get(result['Location'])
self.assert_in_success_response([forward_address], result)
os.remove(settings.FORWARD_ADDRESS_CONFIG_FILE)

View File

@@ -3,10 +3,16 @@ from django.http import HttpRequest, HttpResponse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.test import Client from django.test import Client
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.decorators.csrf import csrf_exempt
from zerver.models import get_realm, get_user from zerver.models import get_realm, get_user
from zerver.lib.notifications import enqueue_welcome_emails from zerver.lib.notifications import enqueue_welcome_emails
import urllib from zerver.lib.response import json_success
from zproject.email_backends import (
get_forward_address,
set_forward_address,
)
from six.moves import urllib
from confirmation.models import Confirmation, confirmation_url from confirmation.models import Confirmation, confirmation_url
import os import os
@@ -17,12 +23,17 @@ client = Client()
def email_page(request): def email_page(request):
# type: (HttpRequest) -> HttpResponse # type: (HttpRequest) -> HttpResponse
if request.method == 'POST':
set_forward_address(request.POST["forward_address"])
return json_success()
try: try:
with open(settings.EMAIL_CONTENT_LOG_PATH, "r+") as f: with open(settings.EMAIL_CONTENT_LOG_PATH, "r+") as f:
content = f.read() content = f.read()
except FileNotFoundError: except FileNotFoundError:
content = "" content = ""
return render(request, 'zerver/email_log.html', {'log': content}) return render(request, 'zerver/email_log.html',
{'log': content,
'forward_address': get_forward_address()})
def clear_emails(request): def clear_emails(request):
# type: (HttpRequest) -> HttpResponse # type: (HttpRequest) -> HttpResponse

View File

@@ -8,6 +8,7 @@ from typing import Set
LOCAL_UPLOADS_DIR = 'var/uploads' LOCAL_UPLOADS_DIR = 'var/uploads'
EMAIL_LOG_DIR = "/var/log/zulip/email.log" EMAIL_LOG_DIR = "/var/log/zulip/email.log"
FORWARD_ADDRESS_CONFIG_FILE = "var/forward_address.ini"
# Check if test_settings.py set EXTERNAL_HOST. # Check if test_settings.py set EXTERNAL_HOST.
EXTERNAL_HOST = os.getenv('EXTERNAL_HOST') EXTERNAL_HOST = os.getenv('EXTERNAL_HOST')
if EXTERNAL_HOST is None: if EXTERNAL_HOST is None:
@@ -58,3 +59,8 @@ INLINE_URL_EMBED_PREVIEW = True
# Don't require anything about password strength in development # Don't require anything about password strength in development
PASSWORD_MIN_LENGTH = 0 PASSWORD_MIN_LENGTH = 0
PASSWORD_MIN_GUESSES = 0 PASSWORD_MIN_GUESSES = 0
# SMTP settings for forwarding emails sent in development
# environment to an email account.
EMAIL_HOST = ""
EMAIL_HOST_USER = ""

View File

@@ -1,13 +1,68 @@
import logging import logging
from typing import List from typing import List
from six.moves import configparser
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from django.conf import settings from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template import loader from django.template import loader
def get_forward_address():
# type: () -> str
config = configparser.ConfigParser()
config.read(settings.FORWARD_ADDRESS_CONFIG_FILE)
try:
return config.get("DEV_EMAIL", "forward_address")
except (configparser.NoSectionError, configparser.NoOptionError) as e:
return ""
def set_forward_address(forward_address):
# type: (str) -> None
config = configparser.ConfigParser()
config.read(settings.FORWARD_ADDRESS_CONFIG_FILE)
if not config.has_section("DEV_EMAIL"):
config.add_section("DEV_EMAIL")
config.set("DEV_EMAIL", "forward_address", forward_address)
with open(settings.FORWARD_ADDRESS_CONFIG_FILE, "w") as cfgfile:
config.write(cfgfile)
class EmailLogBackEnd(BaseEmailBackend): class EmailLogBackEnd(BaseEmailBackend):
def send_email_smtp(self, email):
# type: (EmailMultiAlternatives) -> None
from_email = email.from_email
to = get_forward_address()
msg = MIMEMultipart('alternative')
msg['Subject'] = email.subject
msg['From'] = from_email
msg['To'] = to
text = email.body
html = email.alternatives[0][0]
# Here, we replace the email addresses used in development
# with chat.zulip.org, so that web email providers like Gmail
# will be able to fetch the illustrations used in the emails.
localhost_email_images_base_uri = settings.ROOT_DOMAIN_URI + '/static/images/emails'
czo_email_images_base_uri = 'https://chat.zulip.org/static/images/emails'
html = html.replace(localhost_email_images_base_uri, czo_email_images_base_uri)
msg.attach(MIMEText(text, 'plain'))
msg.attach(MIMEText(html, 'html'))
smtp = smtplib.SMTP(settings.EMAIL_HOST)
smtp.starttls()
smtp.login(settings.EMAIL_HOST_USER, settings.EMAIL_HOST_PASSWORD)
smtp.sendmail(from_email, to, msg.as_string())
smtp.quit()
def log_email(self, email: EmailMultiAlternatives) -> None: def log_email(self, email: EmailMultiAlternatives) -> None:
"""Used in development to record sent emails in a nice HTML log""" """Used in development to record sent emails in a nice HTML log"""
html_message = 'Missing HTML message' html_message = 'Missing HTML message'
@@ -38,6 +93,8 @@ class EmailLogBackEnd(BaseEmailBackend):
def send_messages(self, email_messages: List[EmailMultiAlternatives]) -> int: def send_messages(self, email_messages: List[EmailMultiAlternatives]) -> int:
for email in email_messages: for email in email_messages:
self.log_email(email) self.log_email(email)
if get_forward_address():
self.send_email_smtp(email)
email_log_url = settings.ROOT_DOMAIN_URI + "/emails" email_log_url = settings.ROOT_DOMAIN_URI + "/emails"
logging.info("Emails sent in development are available at %s" % (email_log_url,)) logging.info("Emails sent in development are available at %s" % (email_log_url,))
return len(email_messages) return len(email_messages)