From 29c4a578af58f6da7c77830a0ff99260f2338d36 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 6 Dec 2010 14:21:51 +0000 Subject: [PATCH] Fixed #10863 -- Added HTML support to mail_managers() and mail_admins(), and used this to provide more and prettier detail in error emails. Thanks to boxed for the suggestion, and to Rob Hudson and Brodie Rao for their work on the patch. git-svn-id: http://code.djangoproject.com/svn/django/trunk@14844 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/core/mail/__init__.py | 24 ++++++++----- django/utils/log.py | 9 ++++- django/views/debug.py | 54 ++++++++++++++++++----------- docs/releases/1.3.txt | 6 ++++ docs/topics/email.txt | 10 ++++-- tests/regressiontests/mail/tests.py | 32 ++++++++++++++++- 6 files changed, 103 insertions(+), 32 deletions(-) diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py index 8d064223cd..a3ad837bc9 100644 --- a/django/core/mail/__init__.py +++ b/django/core/mail/__init__.py @@ -83,22 +83,30 @@ def send_mass_mail(datatuple, fail_silently=False, auth_user=None, return connection.send_messages(messages) -def mail_admins(subject, message, fail_silently=False, connection=None): +def mail_admins(subject, message, fail_silently=False, connection=None, + html_message=None): """Sends a message to the admins, as defined by the ADMINS setting.""" if not settings.ADMINS: return - EmailMessage(u'%s%s' % (settings.EMAIL_SUBJECT_PREFIX, subject), message, - settings.SERVER_EMAIL, [a[1] for a in settings.ADMINS], - connection=connection).send(fail_silently=fail_silently) + mail = EmailMultiAlternatives(u'%s%s' % (settings.EMAIL_SUBJECT_PREFIX, subject), + message, settings.SERVER_EMAIL, [a[1] for a in settings.ADMINS], + connection=connection) + if html_message: + mail.attach_alternative(html_message, 'text/html') + mail.send(fail_silently=fail_silently) -def mail_managers(subject, message, fail_silently=False, connection=None): +def mail_managers(subject, message, fail_silently=False, connection=None, + html_message=None): """Sends a message to the managers, as defined by the MANAGERS setting.""" if not settings.MANAGERS: return - EmailMessage(u'%s%s' % (settings.EMAIL_SUBJECT_PREFIX, subject), message, - settings.SERVER_EMAIL, [a[1] for a in settings.MANAGERS], - connection=connection).send(fail_silently=fail_silently) + mail = EmailMultiAlternatives(u'%s%s' % (settings.EMAIL_SUBJECT_PREFIX, subject), + message, settings.SERVER_EMAIL, [a[1] for a in settings.MANAGERS], + connection=connection) + if html_message: + mail.attach_alternative(html_message, 'text/html') + mail.send(fail_silently=fail_silently) class SMTPConnection(_SMTPConnection): diff --git a/django/utils/log.py b/django/utils/log.py index b9d32e339b..d3c8e67a30 100644 --- a/django/utils/log.py +++ b/django/utils/log.py @@ -56,6 +56,7 @@ class AdminEmailHandler(logging.Handler): def emit(self, record): import traceback from django.conf import settings + from django.views.debug import ExceptionReporter try: if sys.version_info < (2,5): @@ -75,12 +76,18 @@ class AdminEmailHandler(logging.Handler): request_repr = repr(request) except: subject = 'Error: Unknown URL' + request = None request_repr = "Request repr() unavailable" if record.exc_info: + exc_info = record.exc_info stack_trace = '\n'.join(traceback.format_exception(*record.exc_info)) else: + exc_info = () stack_trace = 'No stack trace available' message = "%s\n\n%s" % (stack_trace, request_repr) - mail.mail_admins(subject, message, fail_silently=True) + reporter = ExceptionReporter(request, *exc_info, is_email=True) + html_message = reporter.get_traceback_html() + mail.mail_admins(subject, message, fail_silently=True, + html_message=html_message) diff --git a/django/views/debug.py b/django/views/debug.py index 7050ea38fb..5c75a49e9a 100644 --- a/django/views/debug.py +++ b/django/views/debug.py @@ -62,11 +62,12 @@ class ExceptionReporter: """ A class to organize and coordinate reporting on exceptions. """ - def __init__(self, request, exc_type, exc_value, tb): + def __init__(self, request, exc_type, exc_value, tb, is_email=False): self.request = request self.exc_type = exc_type self.exc_value = exc_value self.tb = tb + self.is_email = is_email self.template_info = None self.template_does_not_exist = False @@ -118,6 +119,7 @@ class ExceptionReporter: from django import get_version t = Template(TECHNICAL_500_TEMPLATE, name='Technical 500 template') c = Context({ + 'is_email': self.is_email, 'exception_type': self.exc_type.__name__, 'exception_value': smart_unicode(self.exc_value, errors='replace'), 'unicode_hint': unicode_hint, @@ -324,7 +326,7 @@ TECHNICAL_500_TEMPLATE = """ table.vars { margin:5px 0 2px 40px; } table.vars td, table.req td { font-family:monospace; } table td.code { width:100%; } - table td.code div { overflow:hidden; } + table td.code pre { overflow:hidden; } table.source th { color:#666; } table.source td { font-family:monospace; white-space:pre; border-bottom:1px solid #eee; } ul.traceback { list-style-type:none; } @@ -353,6 +355,7 @@ TECHNICAL_500_TEMPLATE = """ span.commands a:link {color:#5E5694;} pre.exception_value { font-family: sans-serif; color: #666; font-size: 1.5em; margin: 10px 0 10px 0; } + {% if not is_email %} + {% endif %}
-

{{ exception_type }} at {{ request.path_info|escape }}

+

{{ exception_type }}{% if request %} at {{ request.path_info|escape }}{% endif %}

{{ exception_value|force_escape }}
@@ -448,7 +452,7 @@ TECHNICAL_500_TEMPLATE = """ - + @@ -498,7 +502,7 @@ TECHNICAL_500_TEMPLATE = """ {% endif %}
-

Traceback Switch to copy-and-paste view

+

Traceback {% if not is_email %}Switch to copy-and-paste view{% endif %}

{% autoescape off %}
    @@ -508,19 +512,23 @@ TECHNICAL_500_TEMPLATE = """ {% if frame.context_line %}
    - {% if frame.pre_context %} -
      {% for line in frame.pre_context %}
    1. {{ line|escape }}
    2. {% endfor %}
    + {% if frame.pre_context and not is_email %} +
      {% for line in frame.pre_context %}
    1. {{ line|escape }}
    2. {% endfor %}
    {% endif %} -
    1. {{ frame.context_line|escape }} ...
    - {% if frame.post_context %} -
      {% for line in frame.post_context %}
    1. {{ line|escape }}
    2. {% endfor %}
    +
    1. {{ frame.context_line|escape }}
      {% if not is_email %} ...{% endif %}
    + {% if frame.post_context and not is_email %} +
      {% for line in frame.post_context %}
    1. {{ line|escape }}
    2. {% endfor %}
    {% endif %}
    {% endif %} {% if frame.vars %}
    - Local vars + {% if is_email %} +

    Local Vars

    + {% else %} + Local vars + {% endif %}
Python Path:{{ sys_path }}
{{ sys_path|pprint }}
Server time:
@@ -533,7 +541,7 @@ TECHNICAL_500_TEMPLATE = """ {% for var in frame.vars|dictsort:"0" %} - + {% endfor %} @@ -545,16 +553,19 @@ TECHNICAL_500_TEMPLATE = """ {% endautoescape %} +{% if not is_email %}
- +

@@ -589,10 +600,12 @@ Exception Value: {{ exception_value|force_escape }}
+{% endif %}

Request information

+{% if request %}

GET

{% if request.GET %}
{{ var.0|force_escape }}
{{ var.1|pprint|force_escape }}
{{ var.1|pprint|force_escape }}
@@ -606,7 +619,7 @@ Exception Value: {{ exception_value|force_escape }} {% for var in request.GET.items %} - + {% endfor %} @@ -628,7 +641,7 @@ Exception Value: {{ exception_value|force_escape }} {% for var in request.POST.items %} - + {% endfor %} @@ -649,7 +662,7 @@ Exception Value: {{ exception_value|force_escape }} {% for var in request.FILES.items %} - + {% endfor %} @@ -672,7 +685,7 @@ Exception Value: {{ exception_value|force_escape }} {% for var in request.COOKIES.items %} - + {% endfor %} @@ -693,11 +706,12 @@ Exception Value: {{ exception_value|force_escape }} {% for var in request.META.items|dictsort:"0" %} - + {% endfor %}
{{ var.0 }}
{{ var.1|pprint }}
{{ var.1|pprint }}
{{ var.0 }}
{{ var.1|pprint }}
{{ var.1|pprint }}
{{ var.0 }}
{{ var.1|pprint }}
{{ var.1|pprint }}
{{ var.0 }}
{{ var.1|pprint }}
{{ var.1|pprint }}
{{ var.0 }}
{{ var.1|pprint }}
{{ var.1|pprint }}
+{% endif %}

Settings

Using settings module {{ settings.SETTINGS_MODULE }}

@@ -712,7 +726,7 @@ Exception Value: {{ exception_value|force_escape }} {% for var in settings.items|dictsort:"0" %} {{ var.0 }} -
{{ var.1|pprint }}
+
{{ var.1|pprint }}
{% endfor %} diff --git a/docs/releases/1.3.txt b/docs/releases/1.3.txt index 4e01fca972..79ab8b3f16 100644 --- a/docs/releases/1.3.txt +++ b/docs/releases/1.3.txt @@ -163,6 +163,12 @@ requests. These include: * Support for _HTTPOnly cookies. + * mail_admins() and mail_managers() now support easily attaching + HTML content to messages. + + * Error emails now include more of the detail and formatting of + the debug server error page. + .. _HTTPOnly: http://www.owasp.org/index.php/HTTPOnly .. _backwards-incompatible-changes-1.3: diff --git a/docs/topics/email.txt b/docs/topics/email.txt index c5c848c67a..ad148a5a6f 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -109,7 +109,7 @@ a single connection for all of its messages. This makes mail_admins() ============= -.. function:: mail_admins(subject, message, fail_silently=False, connection=None) +.. function:: mail_admins(subject, message, fail_silently=False, connection=None, html_message=None) ``django.core.mail.mail_admins()`` is a shortcut for sending an e-mail to the site admins, as defined in the :setting:`ADMINS` setting. @@ -122,10 +122,16 @@ The "From:" header of the e-mail will be the value of the This method exists for convenience and readability. +.. versionchanged:: 1.3 + +If ``html_message`` is provided, the resulting e-mail will be a +multipart/alternative e-mail with ``message`` as the "text/plain" +content type and ``html_message`` as the "text/html" content type. + mail_managers() =============== -.. function:: mail_managers(subject, message, fail_silently=False, connection=None) +.. function:: mail_managers(subject, message, fail_silently=False, connection=None, html_message=None) ``django.core.mail.mail_managers()`` is just like ``mail_admins()``, except it sends an e-mail to the site managers, as defined in the :setting:`MANAGERS` diff --git a/tests/regressiontests/mail/tests.py b/tests/regressiontests/mail/tests.py index a84417e21b..327159eb7f 100644 --- a/tests/regressiontests/mail/tests.py +++ b/tests/regressiontests/mail/tests.py @@ -232,7 +232,7 @@ class MailTests(TestCase): self.assertEqual(len(mail.outbox), 2) self.assertEqual(mail.outbox[0].subject, 'Subject') self.assertEqual(mail.outbox[1].subject, 'Subject 2') - + # Make sure that multiple locmem connections share mail.outbox mail.outbox = [] connection2 = locmem.EmailBackend() @@ -364,6 +364,36 @@ class MailTests(TestCase): settings.ADMINS = old_admins settings.MANAGERS = old_managers + def test_html_mail_admins(self): + """Test html_message argument to mail_admins and mail_managers""" + old_admins = settings.ADMINS + settings.ADMINS = [('nobody','nobody@example.com')] + + mail.outbox = [] + mail_admins('Subject', 'Content', html_message='HTML Content') + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0] + self.assertEqual(message.subject, '[Django] Subject') + self.assertEqual(message.body, 'Content') + self.assertEqual(message.alternatives, [('HTML Content', 'text/html')]) + + settings.ADMINS = old_admins + + def test_html_mail_managers(self): + """Test html_message argument to mail_admins and mail_managers""" + old_managers = settings.MANAGERS + settings.MANAGERS = [('nobody','nobody@example.com')] + + mail.outbox = [] + mail_managers('Subject', 'Content', html_message='HTML Content') + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0] + self.assertEqual(message.subject, '[Django] Subject') + self.assertEqual(message.body, 'Content') + self.assertEqual(message.alternatives, [('HTML Content', 'text/html')]) + + settings.MANAGERS = old_managers + def test_idn_validation(self): """Test internationalized email adresses""" # Regression for #14301.