Skip to content
Browse files

[Bug 850215] Safe email translation.

I made a decorator that calls a function, and catches any errors that
might be localization issues. If one of these types of exceptions is
raised, it logs the request and sets the locale to English and tries
again. Then I found all the places where @willkg's `uselocale` context
manager was being used and converted it to a function that used this
decorator. In my testing this makes emails robust to localization
errors.

I tried to make this a modification to the context manager originally,
but to run the wrapped code again would require ugly bad horrible
bytecode hacks. So I decided better not do that. Luckily, this was is
generic enough that it might be reusable for web traffic too. I didn't
look into this very much.

I tried to log these errors in a way that will make them easy to work
on. I am assuming that we have Sentry/Raven configured to automatically
add stack traces. If this assumption is wrong, we can add the stack
trace into the error message later. There is a test that verifies that
logging does indeed happen.

Fix tests, move inline functions out of loops.

Move things around, test logging.

* Move translation functions out of loops
* Move locale selection closer to where it gets used.
* Test that logging actually happens.
  • Loading branch information...
1 parent 7762caa commit 5382aa9857a3e092ca10653c95c20882eaa94bf2 @mythmon committed Mar 22, 2013
View
26 apps/announcements/tasks.py
@@ -8,7 +8,7 @@
from tower import ugettext as _
from announcements.models import Announcement
-from sumo.email_utils import uselocale, render_email
+from sumo.email_utils import render_email, safe_translation
@task
@@ -27,19 +27,21 @@ def send_group_email(announcement_id):
email_kwargs = {'content': plain_content,
'domain': Site.objects.get_current().domain}
template = 'announcements/email/announcement.ltxt'
+
+ @safe_translation
+ def _make_mail(locale, user):
+ subject = _('New announcement for {group}').format(
+ group=group.name)
+ msg = render_email(template, email_kwargs)
+
+ m = EmailMessage(subject, msg, settings.TIDINGS_FROM_ADDRESS,
+ [user.email])
+ return [m]
+
try:
for u in users:
# Localize email each time.
- with uselocale(u.profile.locale or settings.LANGUAGE_CODE):
- subject = _('New announcement for {group}').format(
- group=group.name)
- msg = render_email(template, email_kwargs)
-
- m = EmailMessage(subject,
- msg,
- settings.TIDINGS_FROM_ADDRESS,
- [u.email])
- connection.send_messages([m])
-
+ locale = u.profile.locale or settings.LANGUAGE_CODE
+ connection.send_messages(_make_mail(locale, u))
finally:
connection.close()
View
22 apps/messages/tasks.py
@@ -4,13 +4,12 @@
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.core.urlresolvers import reverse
-from django.template import Context, loader
from celery.task import task
from tower import ugettext as _
from messages.models import InboxMessage
-from sumo.email_utils import uselocale, render_email
+from sumo.email_utils import safe_translation, render_email
log = logging.getLogger('k.task')
@@ -23,16 +22,12 @@ def email_private_message(inbox_message_id):
log.debug('Sending email for user (%s)' % (inbox_message.to,))
user = inbox_message.to
- if hasattr(user, 'profile'):
- locale = user.profile.locale
- else:
- locale = settings.WIKI_DEFAULT_LANGUAGE
- with uselocale(locale):
+ @safe_translation
+ def _send_mail(locale):
subject = _(u'[SUMO] You have a new private message from [{sender}]')
subject = subject.format(sender=inbox_message.sender.username)
-
context = {
'sender': inbox_message.sender.username,
'message': inbox_message.message,
@@ -44,5 +39,12 @@ def email_private_message(inbox_message_id):
template = 'messages/email/private_message.ltxt'
msg = render_email(template, context)
- send_mail(subject, msg, settings.TIDINGS_FROM_ADDRESS,
- [inbox_message.to.email])
+ send_mail(subject, msg, settings.TIDINGS_FROM_ADDRESS,
+ [inbox_message.to.email])
+
+ if hasattr(user, 'profile'):
+ locale = user.profile.locale
+ else:
+ locale = settings.WIKI_DEFAULT_LANGUAGE
+
+ _send_mail(locale)
View
76 apps/questions/events.py
@@ -26,21 +26,25 @@ def _activation_email(cls, watch, email):
# If the watch has an associated user, use that
# locale. Otherwise it's an anonymous watch and we don't know
# what locale they want, so we give them en-US.
- if watch.user:
- locale = watch.user.profile.locale
- else:
- locale = 'en-US'
- with email_utils.uselocale(locale):
+ @email_utils.safe_translation
+ def _make_mail(locale):
subject = _('Please confirm your email address')
email_kwargs = {'activation_url': cls._activation_url(watch),
'domain': Site.objects.get_current().domain,
'watch_description': cls.description_of_watch(watch)}
template = 'questions/email/activate_watch.ltxt'
message = email_utils.render_email(template, email_kwargs)
- return EmailMessage(subject, message,
- settings.TIDINGS_FROM_ADDRESS, [email])
+ return EmailMessage(subject, message,
+ settings.TIDINGS_FROM_ADDRESS, [email])
+
+ if watch.user:
+ locale = watch.user.profile.locale
+ else:
+ locale = 'en-US'
+
+ return _make_mail(locale)
@classmethod
def _activation_url(cls, watch):
@@ -64,6 +68,25 @@ def _mails(self, users_and_watches):
'host': Site.objects.get_current().domain,
'answer_url': self.answer.get_absolute_url()}
+ @email_utils.safe_translation
+ def _make_mail(locale, user, context):
+ is_asker = asker_id == user.id
+ if is_asker:
+ subject = _(u'%s posted an answer to your question "%s"' %
+ (self.answer.creator.username, self.instance.title))
+ template = 'questions/email/new_answer_to_asker.ltxt'
+ else:
+ subject = _(u'%s commented on a Firefox question '
+ "you're watching" % self.answer.creator.username)
+ template = 'questions/email/new_answer.ltxt'
+
+ msg = email_utils.render_email(template, context)
+
+ return EmailMessage(subject,
+ msg,
+ settings.TIDINGS_FROM_ADDRESS,
+ [user.email])
+
for u, w in users_and_watches:
c['helpful_url'] = self.answer.get_helpful_answer_url()
c['solution_url'] = self.answer.get_solution_url(watch=w[0])
@@ -78,24 +101,7 @@ def _mails(self, users_and_watches):
else:
locale = 'en-US'
- with email_utils.uselocale(locale):
- is_asker = asker_id == u.id
- if is_asker:
- subject = _(u'%s posted an answer to your question "%s"' %
- (self.answer.creator.username, self.instance.title))
- template = 'questions/email/new_answer_to_asker.ltxt'
-
- else:
- subject = _(u'%s commented on a Firefox question '
- "you're watching" % self.answer.creator.username)
- template = 'questions/email/new_answer.ltxt'
-
- msg = email_utils.render_email(template, c)
-
- yield EmailMessage(subject,
- msg,
- settings.TIDINGS_FROM_ADDRESS,
- [u.email])
+ yield _make_mail(locale, u, c)
@classmethod
def description_of_watch(cls, watch):
@@ -113,6 +119,15 @@ def _mails(self, users_and_watches):
question.solution = self.answer
question.solution.question = question
+ @email_utils.safe_translation
+ def _make_mail(locale, user, context):
+ subject = _(u'Solution found to Firefox Help question')
+ content = email_utils.render_email(
+ 'questions/email/solution.ltxt', context)
+
+ return EmailMessage(subject, content,
+ settings.TIDINGS_FROM_ADDRESS, [user.email])
+
c = {'answerer': question.solution.creator,
'asker': question.creator.username,
'question_title': question.title,
@@ -131,16 +146,7 @@ def _mails(self, users_and_watches):
else:
locale = 'en-US'
- with email_utils.uselocale(locale):
- subject = _(u'Solution found to Firefox Help question')
- content = email_utils.render_email(
- 'questions/email/solution.ltxt', c)
-
- yield EmailMessage(
- subject,
- content,
- settings.TIDINGS_FROM_ADDRESS,
- [u.email])
+ yield _make_mail(locale, u, c)
@classmethod
def description_of_watch(cls, watch):
View
82 apps/sumo/email_utils.py
@@ -1,4 +1,6 @@
+import logging
from contextlib import contextmanager
+from functools import wraps
from django.conf import settings
from django.core import mail
@@ -10,6 +12,9 @@
from test_utils import RequestFactory
+log = logging.getLogger('k.email')
+
+
def send_messages(messages):
"""Sends a a bunch of EmailMessages."""
conn = mail.get_connection(fail_silently=True)
@@ -46,13 +51,57 @@ def uselocale(locale):
tower.activate(currlocale)
+def safe_translation(f):
+ """Call `f` which has first argument `locale`. If `f` raises an
+ exception indicative of a bad localization of a string, try again in
+ `settings.WIKI_DEFAULT_LANGUAGE`.
+
+ NB: This means `f` will be called up to two times!
+ """
+ @wraps(f)
+ def wrapper(locale, *args, **kwargs):
+ try:
+ with uselocale(locale):
+ return f(locale, *args, **kwargs)
+ except (TypeError, KeyError, ValueError, IndexError) as e:
+ # Types of errors, and examples.
+ #
+ # TypeError: Not enough arguments for string
+ # '%s %s %s' % ('foo', 'bar')
+ # KeyError: Bad variable name
+ # '%(Foo)s' % {'foo': 10} or '{Foo}'.format(foo=10')
+ # ValueError: Incomplete Format, or bad format string.
+ # '%(foo)a' or '%(foo)' or '{foo'
+ # IndexError: Not enough arguments for .format() style string.
+ # '{0} {1}'.format(42)
+ log.error('Bad translation in locale "%s": %s', locale, e)
+
+ with uselocale(settings.WIKI_DEFAULT_LANGUAGE):
+ return f(settings.WIKI_DEFAULT_LANGUAGE, *args, **kwargs)
+
+ return wrapper
+
+
def render_email(template, context):
- """Renders a template in the currently set locale."""
- req = RequestFactory()
- req.META = {}
- req.locale = translation.get_language()
+ """Renders a template in the currently set locale.
- return jingo.render_to_string(req, template, context)
+ Falls back to WIKI_DEFAULT_LANGUAGE in case of error.
+ """
+
+ @safe_translation
+ def _render(locale):
+ """Render an email in the given locale.
+
+ Because of safe_translation decorator, if this fails,
+ the function will be run again in English.
+ """
+ req = RequestFactory()
+ req.META = {}
+ req.locale = locale
+
+ return jingo.render_to_string(req, template, context)
+
+ return _render(translation.get_language())
def emails_with_users_and_watches(subject,
@@ -85,19 +134,22 @@ def emails_with_users_and_watches(subject,
:returns: generator of EmailMessage objects
"""
+ @safe_translation
+ def _make_mail(locale, user, watch):
+ context_vars['user'] = user
+ context_vars['watch'] = watch[0]
+ context_vars['watches'] = watch
+
+ return EmailMessage(subject.format(**context_vars),
+ render_email(template_path, context_vars),
+ from_email,
+ [user.email],
+ **extra_kwargs)
+
for u, w in users_and_watches:
if hasattr(u, 'profile'):
locale = u.profile.locale
else:
locale = default_locale
- with uselocale(locale):
- context_vars['user'] = u
- context_vars['watch'] = w[0]
- context_vars['watches'] = w
-
- yield EmailMessage(subject.format(**context_vars),
- render_email(template_path, context_vars),
- from_email,
- [u.email],
- **extra_kwargs)
+ yield _make_mail(locale, u, w)
View
5 apps/sumo/parser.py
@@ -207,7 +207,8 @@ def parse(self, text, show_toc=None, tags=None, attributes=None,
parser_kwargs = {'tags': tags} if tags else {}
- with email_utils.uselocale(locale):
+ @email_utils.safe_translation
+ def _parse(locale):
return super(WikiParser, self).parse(
text,
show_toc=show_toc,
@@ -217,6 +218,8 @@ def parse(self, text, show_toc=None, tags=None, attributes=None,
strip_comments=True,
**parser_kwargs)
+ return _parse(locale)
+
def _hook_internal_link(self, parser, space, name):
"""Parses text and returns internal link."""
text = False
View
126 apps/sumo/tests/test_email_utils.py
@@ -0,0 +1,126 @@
+from mock import patch
+from nose.tools import eq_
+
+from django.conf import settings
+from django.utils.translation import get_language
+from django.utils.functional import lazy
+
+from sumo.email_utils import uselocale, safe_translation
+from sumo.tests import TestCase
+
+
+mock_translations = {
+ 'Hello': {
+ 'en-us': 'Hello',
+ 'fr': 'Bonjour',
+ 'es': 'Hola',
+ },
+ 'Hello {name}': {
+ 'en-us': 'Hello {name}',
+ 'fr': 'Bonjour {0}',
+ 'es': 'Hola {name}',
+ }
+}
+
+
+def mock_ugettext(msg_id):
+ locale = get_language()
+ return mock_translations[msg_id][locale]
+
+mock_ugettext_lazy = lazy(mock_ugettext)
+
+
+def mock_gettext(f):
+ f = patch('tower.ugettext', mock_ugettext)(f)
+ f = patch('tower.ugettext_lazy', mock_ugettext_lazy)(f)
+ return f
+
+
+class SafeTranslationTests(TestCase):
+
+ def setUp(self):
+ # These tests assume English is the fall back language. If it
+ # isn't we are gonna have a bad time.
+ eq_('en-US', settings.WIKI_DEFAULT_LANGUAGE)
+
+ @mock_gettext
+ def test_mocked_gettext(self):
+ """I'm not entirely sure about the mocking, so test that."""
+ # Import tower now so it is affected by the mock.
+ from tower import ugettext as _
+
+ with uselocale('en-US'):
+ eq_(_('Hello'), 'Hello')
+ with uselocale('fr'):
+ eq_(_('Hello'), 'Bonjour')
+ with uselocale('es'):
+ eq_(_('Hello'), 'Hola')
+
+ @mock_gettext
+ def test_safe_translation_noop(self):
+ """Test that safe_translation doesn't mess with good translations."""
+ # Import tower now so it is affected by the mock.
+ from tower import ugettext as _
+
+ @safe_translation
+ def simple(locale):
+ return _('Hello')
+
+ # These should just work normally.
+ eq_(simple('en-US'), 'Hello')
+ eq_(simple('fr'), 'Bonjour')
+ eq_(simple('es'), 'Hola')
+
+ @mock_gettext
+ def test_safe_translation_bad_trans(self):
+ """Test that safe_translation insulates from bad translations."""
+ # Import tower now so it is affected by the mock.
+ from tower import ugettext as _
+
+ # `safe_translation` will call this with the given locale, and
+ # if that fails, fall back to English.
+ @safe_translation
+ def bad_trans(locale):
+ return _('Hello {name}').format(name='Mike')
+
+ # French should come back as English, because it has a bad
+ # translation, but Spanish should come back in Spanish.
+ eq_(bad_trans('en-US'), 'Hello Mike')
+ eq_(bad_trans('fr'), 'Hello Mike')
+ eq_(bad_trans('es'), 'Hola Mike')
+
+ @mock_gettext
+ @patch('sumo.email_utils.log')
+ def test_safe_translation_logging(self, mocked_log):
+ """Logging translation errors is really important, so test it."""
+ # Import tower now so it is affected by the mock.
+ from tower import ugettext as _
+
+ # Assert that bad translations cause error logging.
+ @safe_translation
+ def bad_trans(locale):
+ return _('Hello {name}').format(name='Mike')
+
+ # English and Spanish should not log anything. French should.
+ bad_trans('en-US')
+ bad_trans('es')
+ eq_(len(mocked_log.method_calls), 0)
+ bad_trans('fr')
+ eq_(len(mocked_log.method_calls), 1)
+
+ method_name, method_args, method_kwargs = mocked_log.method_calls[0]
+ eq_(method_name, 'error')
+ assert 'Bad translation' in method_args[0]
+ eq_(method_args[1], 'fr')
+
+
+class UseLocaleTests(TestCase):
+
+ def test_uselocale(self):
+ """Test that uselocale does what it says on the tin."""
+ with uselocale('en-US'):
+ eq_(get_language(), 'en-us')
+ with uselocale('de'):
+ eq_(get_language(), 'de')
+ with uselocale('fr'):
+ eq_(get_language(), 'fr')
View
24 apps/users/forms.py
@@ -6,7 +6,6 @@
from django.contrib.auth.models import User
from django.contrib.sites.models import get_current_site
from django.core.mail import send_mail
-from django.template import Context, loader
from tower import ugettext as _, ugettext_lazy as _lazy
@@ -294,8 +293,8 @@ def clean_email(self):
self.user = User.objects.get(email__iexact=email, is_active=True)
except User.DoesNotExist:
raise forms.ValidationError(
- _(u"That e-mail address doesn't have an associated user"
- " account. Are you sure you've registered?"))
+ _(u"That e-mail address doesn't have an associated user "
+ u"account. Are you sure you've registered?"))
return email
def save(self, email_template='users/email/forgot_username.ltxt',
@@ -306,6 +305,14 @@ def save(self, email_template='users/email/forgot_username.ltxt',
site_name = current_site.name
domain = current_site.domain
+ @email_utils.safe_translation
+ def _send_mail(locale, user, context):
+ subject = _("Your username on %s") % site_name
+ message = email_utils.render_email(email_template, context)
+
+ send_mail(subject, message, settings.TIDINGS_FROM_ADDRESS,
+ [user.email])
+
c = {
'email': user.email,
'domain': domain,
@@ -314,11 +321,12 @@ def save(self, email_template='users/email/forgot_username.ltxt',
'username': user.username,
'protocol': use_https and 'https' or 'http'}
- subject = _("Your username on %s") % site_name
- message = email_utils.render_email(email_template, c)
-
- send_mail(subject, message, settings.TIDINGS_FROM_ADDRESS,
- [user.email])
+ # The user is not logged in, the user object comes from the
+ # supplied email address, and is filled in by `clean_email`. If
+ # an invalid email address was given, an exception would have
+ # been raised already.
+ locale = user.profile.locale or settings.WIKI_DEFAULT_LANGUAGE
+ _send_mail(locale, user, c)
def _check_password(password):
View
8 apps/users/models.py
@@ -143,10 +143,12 @@ def _send_email(self, confirmation_profile, url,
else:
locale = confirmation_profile.user.profile.locale
- with email_utils.uselocale(locale):
- message = email_utils.render_email(email_template, email_kwargs)
+ @email_utils.safe_translation
+ def _message(locale):
+ return email_utils.render_email(email_template, email_kwargs)
- mail.send_mail(subject, message, settings.DEFAULT_FROM_EMAIL,
+
+ mail.send_mail(subject, _message(locale), settings.DEFAULT_FROM_EMAIL,
[send_to])
def send_confirmation_email(self, *args, **kwargs):
View
1 apps/users/tests/test_templates.py
@@ -530,6 +530,7 @@ def test_GET(self):
def test_POST(self, get_current):
get_current.return_value.domain = 'testserver.com'
u = user(save=True, email='a@b.com', is_active=True)
+ profile(user=u) # save=True is forced.
r = self.client.post(reverse('users.forgot_username'),
{'email': u.email})
View
85 apps/wiki/events.py
@@ -236,51 +236,54 @@ def _mails(self, users_and_watches):
log.debug('Sending approved/ready notifications for revision (id=%s)' %
revision.id)
+ # Localize the subject and message with the appropriate
+ # context. If there is an error, fall back to English.
+ @email_utils.safe_translation
+ def _make_mail(locale, user, watches):
+ if (is_ready and
+ ReadyRevisionEvent.event_type in
+ (w.event_type for w in watches)):
+ c = context_dict(revision, ready_for_l10n=True)
+ # TODO: Expose all watches
+ c['watch'] = watches[0]
+ c['url'] = django_reverse('wiki.select_locale',
+ args=[document.slug])
+
+ subject = _(u'{title} has a revision ready for '
+ 'localization')
+ template = 'wiki/email/ready_for_l10n.ltxt'
+
+ else:
+ c = context_dict(revision, revision_approved=True)
+ approved_url = reverse('wiki.document',
+ locale=document.locale,
+ args=[document.slug])
+
+ c['url'] = approved_url
+ # TODO: Expose all watches.
+ c['watch'] = watches[0]
+ c['reviewer'] = revision.reviewer.username
+
+ subject = _(u'{title} ({locale}) has a new approved '
+ 'revision ({reviewer})')
+ template = 'wiki/email/approved.ltxt'
+
+ subject = subject.format(
+ title=document.title,
+ reviewer=revision.reviewer.username,
+ locale=document.locale)
+ msg = email_utils.render_email(template, c)
+
+ return EmailMessage(subject,
+ msg,
+ settings.TIDINGS_FROM_ADDRESS,
+ [user.email])
+
for user, watches in users_and_watches:
# Figure out the locale to use for l10n.
if hasattr(user, 'profile'):
locale = user.profile.locale
else:
locale = document.locale
- # Localize the subject and message with the appropriate
- # context.
- with email_utils.uselocale(locale):
- if (is_ready and
- ReadyRevisionEvent.event_type in
- (w.event_type for w in watches)):
- c = context_dict(revision, ready_for_l10n=True)
- # TODO: Expose all watches
- c['watch'] = watches[0]
- c['url'] = django_reverse('wiki.select_locale',
- args=[document.slug])
-
- subject = _(u'{title} has a revision ready for '
- 'localization')
- template = 'wiki/email/ready_for_l10n.ltxt'
-
- else:
- c = context_dict(revision, revision_approved=True)
- approved_url = reverse('wiki.document',
- locale=document.locale,
- args=[document.slug])
-
- c['url'] = approved_url
- # TODO: Expose all watches.
- c['watch'] = watches[0]
- c['reviewer'] = revision.reviewer.username
-
- subject = _(u'{title} ({locale}) has a new approved '
- 'revision ({reviewer})')
- template = 'wiki/email/approved.ltxt'
-
- subject = subject.format(
- title=document.title,
- reviewer=revision.reviewer.username,
- locale=document.locale)
- msg = email_utils.render_email(template, c)
-
- yield EmailMessage(subject,
- msg,
- settings.TIDINGS_FROM_ADDRESS,
- [user.email])
+ yield _make_mail(locale, user, watches)
View
63 apps/wiki/tasks.py
@@ -47,26 +47,28 @@ def send_reviewed_notification(revision, document, message):
msgs = []
+ @email_utils.safe_translation
+ def _make_mail(locale, user):
+ if revision.is_approved:
+ subject = _(u'Your revision has been approved: {title}')
+ else:
+ subject = _(u'Your revision has been reviewed: {title}')
+ subject = subject.format(title=document.title)
+
+ template = 'wiki/email/reviewed.ltxt'
+ msg = email_utils.render_email(template, c)
+
+ msgs.append(EmailMessage(subject, msg,
+ settings.TIDINGS_FROM_ADDRESS,
+ [user.email]))
+
for user in [revision.creator, revision.reviewer]:
if hasattr(user, 'profile'):
locale = user.profile.locale
else:
locale = settings.WIKI_DEFAULT_LANGUAGE
- with email_utils.uselocale(locale):
- if revision.is_approved:
- subject = _(u'Your revision has been approved: {title}')
- else:
- subject = _(u'Your revision has been reviewed: {title}')
- subject = subject.format(title=document.title)
-
- template = 'wiki/email/reviewed.ltxt'
- msg = email_utils.render_email(template, c)
-
- msgs.append(EmailMessage(subject,
- msg,
- settings.TIDINGS_FROM_ADDRESS,
- [user.email]))
+ _make_mail(locale, user)
email_utils.send_messages(msgs)
@@ -86,6 +88,23 @@ def send_contributor_notification(based_on, revision, document, message):
'host': Site.objects.get_current().domain}
msgs = []
+
+ @email_utils.safe_translation
+ def _make_mail(locale, user):
+ if revision.is_approved:
+ subject = _(u'A revision you contributed to has '
+ 'been approved: {title}')
+ else:
+ subject = _(u'A revision you contributed to has '
+ 'been reviewed: {title}')
+ subject = subject.format(title=document.title)
+
+ msg = email_utils.render_email(template, c)
+
+ msgs.append(EmailMessage(subject, msg,
+ settings.TIDINGS_FROM_ADDRESS,
+ [user.email]))
+
for r in based_on:
# Send email to all contributors except the reviewer and the creator
# of the approved revision.
@@ -99,21 +118,7 @@ def send_contributor_notification(based_on, revision, document, message):
else:
locale = settings.WIKI_DEFAULT_LANGUAGE
- with email_utils.uselocale(locale):
- if revision.is_approved:
- subject = _(u'A revision you contributed to has '
- 'been approved: {title}')
- else:
- subject = _(u'A revision you contributed to has '
- 'been reviewed: {title}')
- subject = subject.format(title=document.title)
-
- msg = email_utils.render_email(template, c)
-
- msgs.append(EmailMessage(subject,
- msg,
- settings.TIDINGS_FROM_ADDRESS,
- [user.email]))
+ _make_mail(locale, user)
email_utils.send_messages(msgs)

0 comments on commit 5382aa9

Please sign in to comment.
Something went wrong with that request. Please try again.