From aba5389326372be43b2a3bdcda16646fd197e807 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 3 Nov 2009 12:53:26 +0000 Subject: [PATCH] Fixed #10355 -- Added an API for pluggable e-mail backends. Thanks to Andi Albrecht for his work on this patch, and to everyone else that contributed during design and development. git-svn-id: http://code.djangoproject.com/svn/django/trunk@11709 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- AUTHORS | 1 + django/conf/global_settings.py | 6 + django/core/mail/__init__.py | 110 ++++++ django/core/mail/backends/__init__.py | 1 + django/core/mail/backends/base.py | 39 ++ django/core/mail/backends/console.py | 34 ++ django/core/mail/backends/dummy.py | 9 + django/core/mail/backends/filebased.py | 59 +++ django/core/mail/backends/locmem.py | 24 ++ django/core/mail/backends/smtp.py | 103 +++++ django/core/{mail.py => mail/message.py} | 178 +-------- django/core/mail/utils.py | 19 + django/test/utils.py | 30 +- docs/internals/deprecation.txt | 3 + docs/ref/settings.txt | 23 ++ docs/topics/email.txt | 405 +++++++++++++++----- docs/topics/testing.txt | 12 +- tests/regressiontests/mail/custombackend.py | 15 + tests/regressiontests/mail/tests.py | 223 ++++++++++- 19 files changed, 1009 insertions(+), 285 deletions(-) create mode 100644 django/core/mail/__init__.py create mode 100644 django/core/mail/backends/__init__.py create mode 100644 django/core/mail/backends/base.py create mode 100644 django/core/mail/backends/console.py create mode 100644 django/core/mail/backends/dummy.py create mode 100644 django/core/mail/backends/filebased.py create mode 100644 django/core/mail/backends/locmem.py create mode 100644 django/core/mail/backends/smtp.py rename django/core/{mail.py => mail/message.py} (60%) create mode 100644 django/core/mail/utils.py create mode 100644 tests/regressiontests/mail/custombackend.py diff --git a/AUTHORS b/AUTHORS index 72f6b35f2a..62946930d7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -27,6 +27,7 @@ answer newbie questions, and generally made Django that much better: ajs alang@bright-green.com + Andi Albrecht Marty Alchin Ahmad Alhashemi Daniel Alves Barbosa de Oliveira Vaz diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index a0ce96a818..70d9c1e259 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -131,6 +131,12 @@ DATABASE_HOST = '' # Set to empty string for localhost. Not used wit DATABASE_PORT = '' # Set to empty string for default. Not used with sqlite3. DATABASE_OPTIONS = {} # Set to empty dictionary for default. +# The email backend to use. For possible shortcuts see django.core.mail. +# The default is to use the SMTP backend. +# Third-party backends can be specified by providing a Python path +# to a module that defines an EmailBackend class. +EMAIL_BACKEND = 'django.core.mail.backends.smtp' + # Host for sending e-mail. EMAIL_HOST = 'localhost' diff --git a/django/core/mail/__init__.py b/django/core/mail/__init__.py new file mode 100644 index 0000000000..b02575793d --- /dev/null +++ b/django/core/mail/__init__.py @@ -0,0 +1,110 @@ +""" +Tools for sending email. +""" + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.utils.importlib import import_module + +# Imported for backwards compatibility, and for the sake +# of a cleaner namespace. These symbols used to be in +# django/core/mail.py before the introduction of email +# backends and the subsequent reorganization (See #10355) +from django.core.mail.utils import CachedDnsName, DNS_NAME +from django.core.mail.message import \ + EmailMessage, EmailMultiAlternatives, \ + SafeMIMEText, SafeMIMEMultipart, \ + DEFAULT_ATTACHMENT_MIME_TYPE, make_msgid, \ + BadHeaderError, forbid_multi_line_headers +from django.core.mail.backends.smtp import EmailBackend as _SMTPConnection + +def get_connection(backend=None, fail_silently=False, **kwds): + """Load an e-mail backend and return an instance of it. + + If backend is None (default) settings.EMAIL_BACKEND is used. + + Both fail_silently and other keyword arguments are used in the + constructor of the backend. + """ + path = backend or settings.EMAIL_BACKEND + try: + mod = import_module(path) + except ImportError, e: + raise ImproperlyConfigured(('Error importing email backend %s: "%s"' + % (path, e))) + try: + cls = getattr(mod, 'EmailBackend') + except AttributeError: + raise ImproperlyConfigured(('Module "%s" does not define a ' + '"EmailBackend" class' % path)) + return cls(fail_silently=fail_silently, **kwds) + + +def send_mail(subject, message, from_email, recipient_list, + fail_silently=False, auth_user=None, auth_password=None, + connection=None): + """ + Easy wrapper for sending a single message to a recipient list. All members + of the recipient list will see the other recipients in the 'To' field. + + If auth_user is None, the EMAIL_HOST_USER setting is used. + If auth_password is None, the EMAIL_HOST_PASSWORD setting is used. + + Note: The API for this method is frozen. New code wanting to extend the + functionality should use the EmailMessage class directly. + """ + connection = connection or get_connection(username=auth_user, + password=auth_password, + fail_silently=fail_silently) + return EmailMessage(subject, message, from_email, recipient_list, + connection=connection).send() + + +def send_mass_mail(datatuple, fail_silently=False, auth_user=None, + auth_password=None, connection=None): + """ + Given a datatuple of (subject, message, from_email, recipient_list), sends + each message to each recipient list. Returns the number of e-mails sent. + + If from_email is None, the DEFAULT_FROM_EMAIL setting is used. + If auth_user and auth_password are set, they're used to log in. + If auth_user is None, the EMAIL_HOST_USER setting is used. + If auth_password is None, the EMAIL_HOST_PASSWORD setting is used. + + Note: The API for this method is frozen. New code wanting to extend the + functionality should use the EmailMessage class directly. + """ + connection = connection or get_connection(username=auth_user, + password=auth_password, + fail_silently=fail_silently) + messages = [EmailMessage(subject, message, sender, recipient) + for subject, message, sender, recipient in datatuple] + return connection.send_messages(messages) + + +def mail_admins(subject, message, fail_silently=False, connection=None): + """Sends a message to the admins, as defined by the ADMINS setting.""" + if not settings.ADMINS: + return + EmailMessage(settings.EMAIL_SUBJECT_PREFIX + subject, message, + settings.SERVER_EMAIL, [a[1] for a in settings.ADMINS], + connection=connection).send(fail_silently=fail_silently) + + +def mail_managers(subject, message, fail_silently=False, connection=None): + """Sends a message to the managers, as defined by the MANAGERS setting.""" + if not settings.MANAGERS: + return + EmailMessage(settings.EMAIL_SUBJECT_PREFIX + subject, message, + settings.SERVER_EMAIL, [a[1] for a in settings.MANAGERS], + connection=connection).send(fail_silently=fail_silently) + + +class SMTPConnection(_SMTPConnection): + def __init__(self, *args, **kwds): + import warnings + warnings.warn( + 'mail.SMTPConnection is deprecated; use mail.get_connection() instead.', + DeprecationWarning + ) + super(SMTPConnection, self).__init__(*args, **kwds) diff --git a/django/core/mail/backends/__init__.py b/django/core/mail/backends/__init__.py new file mode 100644 index 0000000000..5973b499b0 --- /dev/null +++ b/django/core/mail/backends/__init__.py @@ -0,0 +1 @@ +# Mail backends shipped with Django. diff --git a/django/core/mail/backends/base.py b/django/core/mail/backends/base.py new file mode 100644 index 0000000000..9a3092849d --- /dev/null +++ b/django/core/mail/backends/base.py @@ -0,0 +1,39 @@ +"""Base email backend class.""" + +class BaseEmailBackend(object): + """ + Base class for email backend implementations. + + Subclasses must at least overwrite send_messages(). + """ + def __init__(self, fail_silently=False, **kwargs): + self.fail_silently = fail_silently + + def open(self): + """Open a network connection. + + This method can be overwritten by backend implementations to + open a network connection. + + It's up to the backend implementation to track the status of + a network connection if it's needed by the backend. + + This method can be called by applications to force a single + network connection to be used when sending mails. See the + send_messages() method of the SMTP backend for a reference + implementation. + + The default implementation does nothing. + """ + pass + + def close(self): + """Close a network connection.""" + pass + + def send_messages(self, email_messages): + """ + Sends one or more EmailMessage objects and returns the number of email + messages sent. + """ + raise NotImplementedError diff --git a/django/core/mail/backends/console.py b/django/core/mail/backends/console.py new file mode 100644 index 0000000000..705497520a --- /dev/null +++ b/django/core/mail/backends/console.py @@ -0,0 +1,34 @@ +""" +Email backend that writes messages to console instead of sending them. +""" +import sys +import threading + +from django.core.mail.backends.base import BaseEmailBackend + +class EmailBackend(BaseEmailBackend): + def __init__(self, *args, **kwargs): + self.stream = kwargs.pop('stream', sys.stdout) + self._lock = threading.RLock() + super(EmailBackend, self).__init__(*args, **kwargs) + + def send_messages(self, email_messages): + """Write all messages to the stream in a thread-safe way.""" + if not email_messages: + return + self._lock.acquire() + try: + stream_created = self.open() + for message in email_messages: + self.stream.write('%s\n' % message.message().as_string()) + self.stream.write('-'*79) + self.stream.write('\n') + self.stream.flush() # flush after each message + if stream_created: + self.close() + except: + if not self.fail_silently: + raise + finally: + self._lock.release() + return len(email_messages) diff --git a/django/core/mail/backends/dummy.py b/django/core/mail/backends/dummy.py new file mode 100644 index 0000000000..273aa0d88e --- /dev/null +++ b/django/core/mail/backends/dummy.py @@ -0,0 +1,9 @@ +""" +Dummy email backend that does nothing. +""" + +from django.core.mail.backends.base import BaseEmailBackend + +class EmailBackend(BaseEmailBackend): + def send_messages(self, email_messages): + return len(email_messages) diff --git a/django/core/mail/backends/filebased.py b/django/core/mail/backends/filebased.py new file mode 100644 index 0000000000..3f6b99b057 --- /dev/null +++ b/django/core/mail/backends/filebased.py @@ -0,0 +1,59 @@ +"""Email backend that writes messages to a file.""" + +import datetime +import os + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.mail.backends.console import EmailBackend as ConsoleEmailBackend + +class EmailBackend(ConsoleEmailBackend): + def __init__(self, *args, **kwargs): + self._fname = None + if 'file_path' in kwargs: + self.file_path = kwargs.pop('file_path') + else: + self.file_path = getattr(settings, 'EMAIL_FILE_PATH',None) + # Make sure self.file_path is a string. + if not isinstance(self.file_path, basestring): + raise ImproperlyConfigured('Path for saving emails is invalid: %r' % self.file_path) + self.file_path = os.path.abspath(self.file_path) + # Make sure that self.file_path is an directory if it exists. + if os.path.exists(self.file_path) and not os.path.isdir(self.file_path): + raise ImproperlyConfigured('Path for saving email messages exists, but is not a directory: %s' % self.file_path) + # Try to create it, if it not exists. + elif not os.path.exists(self.file_path): + try: + os.makedirs(self.file_path) + except OSError, err: + raise ImproperlyConfigured('Could not create directory for saving email messages: %s (%s)' % (self.file_path, err)) + # Make sure that self.file_path is writable. + if not os.access(self.file_path, os.W_OK): + raise ImproperlyConfigured('Could not write to directory: %s' % self.file_path) + # Finally, call super(). + # Since we're using the console-based backend as a base, + # force the stream to be None, so we don't default to stdout + kwargs['stream'] = None + super(EmailBackend, self).__init__(*args, **kwargs) + + def _get_filename(self): + """Return a unique file name.""" + if self._fname is None: + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + fname = "%s-%s.log" % (timestamp, abs(id(self))) + self._fname = os.path.join(self.file_path, fname) + return self._fname + + def open(self): + if self.stream is None: + self.stream = open(self._get_filename(), 'a') + return True + return False + + def close(self): + try: + if self.stream is not None: + self.stream.close() + finally: + self.stream = None + diff --git a/django/core/mail/backends/locmem.py b/django/core/mail/backends/locmem.py new file mode 100644 index 0000000000..642bfc49fb --- /dev/null +++ b/django/core/mail/backends/locmem.py @@ -0,0 +1,24 @@ +""" +Backend for test environment. +""" + +from django.core import mail +from django.core.mail.backends.base import BaseEmailBackend + +class EmailBackend(BaseEmailBackend): + """A email backend for use during test sessions. + + The test connection stores email messages in a dummy outbox, + rather than sending them out on the wire. + + The dummy outbox is accessible through the outbox instance attribute. + """ + def __init__(self, *args, **kwargs): + super(EmailBackend, self).__init__(*args, **kwargs) + if not hasattr(mail, 'outbox'): + mail.outbox = [] + + def send_messages(self, messages): + """Redirect messages to the dummy outbox""" + mail.outbox.extend(messages) + return len(messages) diff --git a/django/core/mail/backends/smtp.py b/django/core/mail/backends/smtp.py new file mode 100644 index 0000000000..12cb26c059 --- /dev/null +++ b/django/core/mail/backends/smtp.py @@ -0,0 +1,103 @@ +"""SMTP email backend class.""" + +import smtplib +import socket +import threading + +from django.conf import settings +from django.core.mail.backends.base import BaseEmailBackend +from django.core.mail.utils import DNS_NAME + +class EmailBackend(BaseEmailBackend): + """ + A wrapper that manages the SMTP network connection. + """ + def __init__(self, host=None, port=None, username=None, password=None, + use_tls=None, fail_silently=False, **kwargs): + super(EmailBackend, self).__init__(fail_silently=fail_silently) + self.host = host or settings.EMAIL_HOST + self.port = port or settings.EMAIL_PORT + self.username = username or settings.EMAIL_HOST_USER + self.password = password or settings.EMAIL_HOST_PASSWORD + self.use_tls = (use_tls is not None) and use_tls or settings.EMAIL_USE_TLS + self.connection = None + self._lock = threading.RLock() + + def open(self): + """ + Ensures we have a connection to the email server. Returns whether or + not a new connection was required (True or False). + """ + if self.connection: + # Nothing to do if the connection is already open. + return False + try: + # If local_hostname is not specified, socket.getfqdn() gets used. + # For performance, we use the cached FQDN for local_hostname. + self.connection = smtplib.SMTP(self.host, self.port, + local_hostname=DNS_NAME.get_fqdn()) + if self.use_tls: + self.connection.ehlo() + self.connection.starttls() + self.connection.ehlo() + if self.username and self.password: + self.connection.login(self.username, self.password) + return True + except: + if not self.fail_silently: + raise + + def close(self): + """Closes the connection to the email server.""" + try: + try: + self.connection.quit() + except socket.sslerror: + # This happens when calling quit() on a TLS connection + # sometimes. + self.connection.close() + except: + if self.fail_silently: + return + raise + finally: + self.connection = None + + def send_messages(self, email_messages): + """ + Sends one or more EmailMessage objects and returns the number of email + messages sent. + """ + if not email_messages: + return + self._lock.acquire() + try: + new_conn_created = self.open() + if not self.connection: + # We failed silently on open(). + # Trying to send would be pointless. + return + num_sent = 0 + for message in email_messages: + sent = self._send(message) + if sent: + num_sent += 1 + if new_conn_created: + self.close() + finally: + self._lock.release() + return num_sent + + def _send(self, email_message): + """A helper method that does the actual sending.""" + if not email_message.recipients(): + return False + try: + self.connection.sendmail(email_message.from_email, + email_message.recipients(), + email_message.message().as_string()) + except: + if not self.fail_silently: + raise + return False + return True diff --git a/django/core/mail.py b/django/core/mail/message.py similarity index 60% rename from django/core/mail.py rename to django/core/mail/message.py index c305699158..7252a5a620 100644 --- a/django/core/mail.py +++ b/django/core/mail/message.py @@ -1,13 +1,7 @@ -""" -Tools for sending email. -""" - import mimetypes import os -import smtplib -import socket -import time import random +import time from email import Charset, Encoders from email.MIMEText import MIMEText from email.MIMEMultipart import MIMEMultipart @@ -16,6 +10,7 @@ from email.Header import Header from email.Utils import formatdate, parseaddr, formataddr from django.conf import settings +from django.core.mail.utils import DNS_NAME from django.utils.encoding import smart_str, force_unicode # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from @@ -26,18 +21,10 @@ Charset.add_charset('utf-8', Charset.SHORTEST, Charset.QP, 'utf-8') # and cannot be guessed). DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream' -# Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of -# seconds, which slows down the restart of the server. -class CachedDnsName(object): - def __str__(self): - return self.get_fqdn() - def get_fqdn(self): - if not hasattr(self, '_fqdn'): - self._fqdn = socket.getfqdn() - return self._fqdn +class BadHeaderError(ValueError): + pass -DNS_NAME = CachedDnsName() # Copied from Python standard library, with the following modifications: # * Used cached hostname for performance. @@ -66,8 +53,6 @@ def make_msgid(idstring=None): msgid = '<%s.%s.%s%s@%s>' % (utcdate, pid, randint, idstring, idhost) return msgid -class BadHeaderError(ValueError): - pass def forbid_multi_line_headers(name, val): """Forbids multi-line headers, to prevent header injection.""" @@ -91,104 +76,18 @@ def forbid_multi_line_headers(name, val): val = Header(val) return name, val + class SafeMIMEText(MIMEText): def __setitem__(self, name, val): name, val = forbid_multi_line_headers(name, val) MIMEText.__setitem__(self, name, val) + class SafeMIMEMultipart(MIMEMultipart): def __setitem__(self, name, val): name, val = forbid_multi_line_headers(name, val) MIMEMultipart.__setitem__(self, name, val) -class SMTPConnection(object): - """ - A wrapper that manages the SMTP network connection. - """ - - def __init__(self, host=None, port=None, username=None, password=None, - use_tls=None, fail_silently=False): - self.host = host or settings.EMAIL_HOST - self.port = port or settings.EMAIL_PORT - self.username = username or settings.EMAIL_HOST_USER - self.password = password or settings.EMAIL_HOST_PASSWORD - self.use_tls = (use_tls is not None) and use_tls or settings.EMAIL_USE_TLS - self.fail_silently = fail_silently - self.connection = None - - def open(self): - """ - Ensures we have a connection to the email server. Returns whether or - not a new connection was required (True or False). - """ - if self.connection: - # Nothing to do if the connection is already open. - return False - try: - # If local_hostname is not specified, socket.getfqdn() gets used. - # For performance, we use the cached FQDN for local_hostname. - self.connection = smtplib.SMTP(self.host, self.port, - local_hostname=DNS_NAME.get_fqdn()) - if self.use_tls: - self.connection.ehlo() - self.connection.starttls() - self.connection.ehlo() - if self.username and self.password: - self.connection.login(self.username, self.password) - return True - except: - if not self.fail_silently: - raise - - def close(self): - """Closes the connection to the email server.""" - try: - try: - self.connection.quit() - except socket.sslerror: - # This happens when calling quit() on a TLS connection - # sometimes. - self.connection.close() - except: - if self.fail_silently: - return - raise - finally: - self.connection = None - - def send_messages(self, email_messages): - """ - Sends one or more EmailMessage objects and returns the number of email - messages sent. - """ - if not email_messages: - return - new_conn_created = self.open() - if not self.connection: - # We failed silently on open(). Trying to send would be pointless. - return - num_sent = 0 - for message in email_messages: - sent = self._send(message) - if sent: - num_sent += 1 - if new_conn_created: - self.close() - return num_sent - - def _send(self, email_message): - """A helper method that does the actual sending.""" - if not email_message.recipients(): - return False - try: - self.connection.sendmail(email_message.from_email, - email_message.recipients(), - email_message.message().as_string()) - except: - if not self.fail_silently: - raise - return False - return True class EmailMessage(object): """ @@ -199,14 +98,14 @@ class EmailMessage(object): encoding = None # None => use settings default def __init__(self, subject='', body='', from_email=None, to=None, bcc=None, - connection=None, attachments=None, headers=None): + connection=None, attachments=None, headers=None): """ Initialize a single email message (which can be sent to multiple recipients). - All strings used to create the message can be unicode strings (or UTF-8 - bytestrings). The SafeMIMEText class will handle any necessary encoding - conversions. + All strings used to create the message can be unicode strings + (or UTF-8 bytestrings). The SafeMIMEText class will handle any + necessary encoding conversions. """ if to: assert not isinstance(to, basestring), '"to" argument must be a list or tuple' @@ -226,8 +125,9 @@ class EmailMessage(object): self.connection = connection def get_connection(self, fail_silently=False): + from django.core.mail import get_connection if not self.connection: - self.connection = SMTPConnection(fail_silently=fail_silently) + self.connection = get_connection(fail_silently=fail_silently) return self.connection def message(self): @@ -332,6 +232,7 @@ class EmailMessage(object): filename=filename) return attachment + class EmailMultiAlternatives(EmailMessage): """ A version of EmailMessage that makes it easy to send multipart/alternative @@ -371,56 +272,3 @@ class EmailMultiAlternatives(EmailMessage): for alternative in self.alternatives: msg.attach(self._create_mime_attachment(*alternative)) return msg - -def send_mail(subject, message, from_email, recipient_list, - fail_silently=False, auth_user=None, auth_password=None): - """ - Easy wrapper for sending a single message to a recipient list. All members - of the recipient list will see the other recipients in the 'To' field. - - If auth_user is None, the EMAIL_HOST_USER setting is used. - If auth_password is None, the EMAIL_HOST_PASSWORD setting is used. - - Note: The API for this method is frozen. New code wanting to extend the - functionality should use the EmailMessage class directly. - """ - connection = SMTPConnection(username=auth_user, password=auth_password, - fail_silently=fail_silently) - return EmailMessage(subject, message, from_email, recipient_list, - connection=connection).send() - -def send_mass_mail(datatuple, fail_silently=False, auth_user=None, - auth_password=None): - """ - Given a datatuple of (subject, message, from_email, recipient_list), sends - each message to each recipient list. Returns the number of e-mails sent. - - If from_email is None, the DEFAULT_FROM_EMAIL setting is used. - If auth_user and auth_password are set, they're used to log in. - If auth_user is None, the EMAIL_HOST_USER setting is used. - If auth_password is None, the EMAIL_HOST_PASSWORD setting is used. - - Note: The API for this method is frozen. New code wanting to extend the - functionality should use the EmailMessage class directly. - """ - connection = SMTPConnection(username=auth_user, password=auth_password, - fail_silently=fail_silently) - messages = [EmailMessage(subject, message, sender, recipient) - for subject, message, sender, recipient in datatuple] - return connection.send_messages(messages) - -def mail_admins(subject, message, fail_silently=False): - """Sends a message to the admins, as defined by the ADMINS setting.""" - if not settings.ADMINS: - return - EmailMessage(settings.EMAIL_SUBJECT_PREFIX + subject, message, - settings.SERVER_EMAIL, [a[1] for a in settings.ADMINS] - ).send(fail_silently=fail_silently) - -def mail_managers(subject, message, fail_silently=False): - """Sends a message to the managers, as defined by the MANAGERS setting.""" - if not settings.MANAGERS: - return - EmailMessage(settings.EMAIL_SUBJECT_PREFIX + subject, message, - settings.SERVER_EMAIL, [a[1] for a in settings.MANAGERS] - ).send(fail_silently=fail_silently) diff --git a/django/core/mail/utils.py b/django/core/mail/utils.py new file mode 100644 index 0000000000..322a3a1b79 --- /dev/null +++ b/django/core/mail/utils.py @@ -0,0 +1,19 @@ +""" +Email message and email sending related helper functions. +""" + +import socket + + +# Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of +# seconds, which slows down the restart of the server. +class CachedDnsName(object): + def __str__(self): + return self.get_fqdn() + + def get_fqdn(self): + if not hasattr(self, '_fqdn'): + self._fqdn = socket.getfqdn() + return self._fqdn + +DNS_NAME = CachedDnsName() diff --git a/django/test/utils.py b/django/test/utils.py index d34dd33d15..a30bb7e41c 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -2,6 +2,7 @@ import sys, time, os from django.conf import settings from django.db import connection from django.core import mail +from django.core.mail.backends import locmem from django.test import signals from django.template import Template from django.utils.translation import deactivate @@ -28,37 +29,22 @@ def instrumented_test_render(self, context): signals.template_rendered.send(sender=self, template=self, context=context) return self.nodelist.render(context) -class TestSMTPConnection(object): - """A substitute SMTP connection for use during test sessions. - The test connection stores email messages in a dummy outbox, - rather than sending them out on the wire. - - """ - def __init__(*args, **kwargs): - pass - def open(self): - "Mock the SMTPConnection open() interface" - pass - def close(self): - "Mock the SMTPConnection close() interface" - pass - def send_messages(self, messages): - "Redirect messages to the dummy outbox" - mail.outbox.extend(messages) - return len(messages) def setup_test_environment(): """Perform any global pre-test setup. This involves: - Installing the instrumented test renderer - - Diverting the email sending functions to a test buffer + - Set the email backend to the locmem email backend. - Setting the active locale to match the LANGUAGE_CODE setting. """ Template.original_render = Template.render Template.render = instrumented_test_render mail.original_SMTPConnection = mail.SMTPConnection - mail.SMTPConnection = TestSMTPConnection + mail.SMTPConnection = locmem.EmailBackend + + settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem' + mail.original_email_backend = settings.EMAIL_BACKEND mail.outbox = [] @@ -77,8 +63,10 @@ def teardown_test_environment(): mail.SMTPConnection = mail.original_SMTPConnection del mail.original_SMTPConnection - del mail.outbox + settings.EMAIL_BACKEND = mail.original_email_backend + del mail.original_email_backend + del mail.outbox def get_runner(settings): test_path = settings.TEST_RUNNER.split('.') diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index cdb012e5e4..6cf62137dd 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -22,6 +22,9 @@ their deprecation, as per the :ref:`Django deprecation policy * The old imports for CSRF functionality (``django.contrib.csrf.*``), which moved to core in 1.2, will be removed. + * ``SMTPConnection``. The 1.2 release deprecated the ``SMTPConnection`` + class in favor of a generic E-mail backend API. + * 2.0 * ``django.views.defaults.shortcut()``. This function has been moved to ``django.contrib.contenttypes.views.shortcut()`` as part of the diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index a3120501aa..ad34e1d414 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -424,6 +424,29 @@ are not allowed to visit any page, systemwide. Use this for bad robots/crawlers. This is only used if ``CommonMiddleware`` is installed (see :ref:`topics-http-middleware`). +.. setting:: EMAIL_BACKEND + +EMAIL_BACKEND +------------- + +.. versionadded:: 1.2 + +Default: ``'smtp'`` + +The backend to use for sending emails. For the list of available backends see +:ref:`topics-email`. + +.. setting:: EMAIL_FILE_PATH + +EMAIL_FILE_PATH +--------------- + +.. versionadded:: 1.2 + +Default: Not defined + +The directory used by the ``file`` email backend to store output files. + .. setting:: EMAIL_HOST EMAIL_HOST diff --git a/docs/topics/email.txt b/docs/topics/email.txt index c80a035b53..92e3c0263d 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -7,11 +7,13 @@ Sending e-mail .. module:: django.core.mail :synopsis: Helpers to easily send e-mail. -Although Python makes sending e-mail relatively easy via the `smtplib library`_, -Django provides a couple of light wrappers over it, to make sending e-mail -extra quick. +Although Python makes sending e-mail relatively easy via the `smtplib +library`_, Django provides a couple of light wrappers over it. These wrappers +are provided to make sending e-mail extra quick, to make it easy to test +email sending during development, and to provide support for platforms that +can't use SMTP. -The code lives in a single module: ``django.core.mail``. +The code lives in the ``django.core.mail`` module. .. _smtplib library: http://docs.python.org/library/smtplib.html @@ -25,11 +27,11 @@ In two lines:: send_mail('Subject here', 'Here is the message.', 'from@example.com', ['to@example.com'], fail_silently=False) -Mail is sent using the SMTP host and port specified in the :setting:`EMAIL_HOST` -and :setting:`EMAIL_PORT` settings. The :setting:`EMAIL_HOST_USER` and -:setting:`EMAIL_HOST_PASSWORD` settings, if set, are used to authenticate to the -SMTP server, and the :setting:`EMAIL_USE_TLS` setting controls whether a secure -connection is used. +Mail is sent using the SMTP host and port specified in the +:setting:`EMAIL_HOST` and :setting:`EMAIL_PORT` settings. The +:setting:`EMAIL_HOST_USER` and :setting:`EMAIL_HOST_PASSWORD` settings, if +set, are used to authenticate to the SMTP server, and the +:setting:`EMAIL_USE_TLS` setting controls whether a secure connection is used. .. note:: @@ -42,7 +44,7 @@ send_mail() The simplest way to send e-mail is using the function ``django.core.mail.send_mail()``. Here's its definition: - .. function:: send_mail(subject, message, from_email, recipient_list, fail_silently=False, auth_user=None, auth_password=None) + .. function:: send_mail(subject, message, from_email, recipient_list, fail_silently=False, auth_user=None, auth_password=None, connection=None) The ``subject``, ``message``, ``from_email`` and ``recipient_list`` parameters are required. @@ -62,6 +64,10 @@ are required. * ``auth_password``: The optional password to use to authenticate to the SMTP server. If this isn't provided, Django will use the value of the ``EMAIL_HOST_PASSWORD`` setting. + * ``connection``: The optional email backend to use to send the mail. + If unspecified, an instance of the default backend will be used. + See the documentation on :ref:`E-mail backends ` + for more details. .. _smtplib docs: http://docs.python.org/library/smtplib.html @@ -71,26 +77,29 @@ send_mass_mail() ``django.core.mail.send_mass_mail()`` is intended to handle mass e-mailing. Here's the definition: - .. function:: send_mass_mail(datatuple, fail_silently=False, auth_user=None, auth_password=None) + .. function:: send_mass_mail(datatuple, fail_silently=False, auth_user=None, auth_password=None, connection=None) ``datatuple`` is a tuple in which each element is in this format:: (subject, message, from_email, recipient_list) ``fail_silently``, ``auth_user`` and ``auth_password`` have the same functions -as in ``send_mail()``. +as in :meth:`~django.core.mail.send_mail()`. Each separate element of ``datatuple`` results in a separate e-mail message. -As in ``send_mail()``, recipients in the same ``recipient_list`` will all see -the other addresses in the e-mail messages' "To:" field. +As in :meth:`~django.core.mail.send_mail()`, recipients in the same +``recipient_list`` will all see the other addresses in the e-mail messages' +"To:" field. send_mass_mail() vs. send_mail() -------------------------------- -The main difference between ``send_mass_mail()`` and ``send_mail()`` is that -``send_mail()`` opens a connection to the mail server each time it's executed, -while ``send_mass_mail()`` uses a single connection for all of its messages. -This makes ``send_mass_mail()`` slightly more efficient. +The main difference between :meth:`~django.core.mail.send_mass_mail()` and +:meth:`~django.core.mail.send_mail()` is that +:meth:`~django.core.mail.send_mail()` opens a connection to the mail server +each time it's executed, while :meth:`~django.core.mail.send_mass_mail()` uses +a single connection for all of its messages. This makes +:meth:`~django.core.mail.send_mass_mail()` slightly more efficient. mail_admins() ============= @@ -98,7 +107,7 @@ mail_admins() ``django.core.mail.mail_admins()`` is a shortcut for sending an e-mail to the site admins, as defined in the :setting:`ADMINS` setting. Here's the definition: - .. function:: mail_admins(subject, message, fail_silently=False) + .. function:: mail_admins(subject, message, fail_silently=False, connection=None) ``mail_admins()`` prefixes the subject with the value of the :setting:`EMAIL_SUBJECT_PREFIX` setting, which is ``"[Django] "`` by default. @@ -115,7 +124,7 @@ mail_managers() function sends an e-mail to the site managers, as defined in the :setting:`MANAGERS` setting. Here's the definition: - .. function:: mail_managers(subject, message, fail_silently=False) + .. function:: mail_managers(subject, message, fail_silently=False, connection=None) Examples ======== @@ -145,7 +154,7 @@ scripts generate. The Django e-mail functions outlined above all protect against header injection by forbidding newlines in header values. If any ``subject``, ``from_email`` or ``recipient_list`` contains a newline (in either Unix, Windows or Mac style), -the e-mail function (e.g. ``send_mail()``) will raise +the e-mail function (e.g. :meth:`~django.core.mail.send_mail()`) will raise ``django.core.mail.BadHeaderError`` (a subclass of ``ValueError``) and, hence, will not send the e-mail. It's your responsibility to validate all data before passing it to the e-mail functions. @@ -178,41 +187,47 @@ from the request's POST data, sends that to admin@example.com and redirects to .. _emailmessage-and-smtpconnection: -The EmailMessage and SMTPConnection classes -=========================================== +The EmailMessage class +====================== .. versionadded:: 1.0 -Django's ``send_mail()`` and ``send_mass_mail()`` functions are actually thin -wrappers that make use of the ``EmailMessage`` and ``SMTPConnection`` classes -in ``django.core.mail``. If you ever need to customize the way Django sends -e-mail, you can subclass these two classes to suit your needs. +Django's :meth:`~django.core.mail.send_mail()` and +:meth:`~django.core.mail.send_mass_mail()` functions are actually thin +wrappers that make use of the :class:`~django.core.mail.EmailMessage` class. + +Not all features of the :class:`~django.core.mail.EmailMessage` class are +available through the :meth:`~django.core.mail.send_mail()` and related +wrapper functions. If you wish to use advanced features, such as BCC'ed +recipients, file attachments, or multi-part e-mail, you'll need to create +:class:`~django.core.mail.EmailMessage` instances directly. .. note:: - Not all features of the ``EmailMessage`` class are available through the - ``send_mail()`` and related wrapper functions. If you wish to use advanced - features, such as BCC'ed recipients, file attachments, or multi-part - e-mail, you'll need to create ``EmailMessage`` instances directly. + This is a design feature. :meth:`~django.core.mail.send_mail()` and + related functions were originally the only interface Django provided. + However, the list of parameters they accepted was slowly growing over + time. It made sense to move to a more object-oriented design for e-mail + messages and retain the original functions only for backwards + compatibility. - This is a design feature. ``send_mail()`` and related functions were - originally the only interface Django provided. However, the list of - parameters they accepted was slowly growing over time. It made sense to - move to a more object-oriented design for e-mail messages and retain the - original functions only for backwards compatibility. +:class:`~django.core.mail.EmailMessage` is responsible for creating the e-mail +message itself. The :ref:`e-mail backend ` is then +responsible for sending the e-mail. -In general, ``EmailMessage`` is responsible for creating the e-mail message -itself. ``SMTPConnection`` is responsible for the network connection side of -the operation. This means you can reuse the same connection (an -``SMTPConnection`` instance) for multiple messages. +For convenience, :class:`~django.core.mail.EmailMessage` provides a simple +``send()`` method for sending a single email. If you need to send multiple +messages, the email backend API :ref:`provides an alternative +`. EmailMessage Objects -------------------- .. class:: EmailMessage -The ``EmailMessage`` class is initialized with the following parameters (in -the given order, if positional arguments are used). All parameters are -optional and can be set at any time prior to calling the ``send()`` method. +The :class:`~django.core.mail.EmailMessage` class is initialized with the +following parameters (in the given order, if positional arguments are used). +All parameters are optional and can be set at any time prior to calling the +``send()`` method. * ``subject``: The subject line of the e-mail. @@ -227,7 +242,7 @@ optional and can be set at any time prior to calling the ``send()`` method. * ``bcc``: A list or tuple of addresses used in the "Bcc" header when sending the e-mail. - * ``connection``: An ``SMTPConnection`` instance. Use this parameter if + * ``connection``: An e-mail backend instance. Use this parameter if you want to use the same connection for multiple messages. If omitted, a new connection is created when ``send()`` is called. @@ -248,18 +263,18 @@ For example:: The class has the following methods: - * ``send(fail_silently=False)`` sends the message, using either - the connection that is specified in the ``connection`` - attribute, or creating a new connection if none already - exists. If the keyword argument ``fail_silently`` is ``True``, - exceptions raised while sending the message will be quashed. + * ``send(fail_silently=False)`` sends the message. If a connection was + specified when the email was constructed, that connection will be used. + Otherwise, an instance of the default backend will be instantiated and + used. If the keyword argument ``fail_silently`` is ``True``, exceptions + raised while sending the message will be quashed. * ``message()`` constructs a ``django.core.mail.SafeMIMEText`` object (a subclass of Python's ``email.MIMEText.MIMEText`` class) or a - ``django.core.mail.SafeMIMEMultipart`` object holding the - message to be sent. If you ever need to extend the ``EmailMessage`` class, - you'll probably want to override this method to put the content you want - into the MIME object. + ``django.core.mail.SafeMIMEMultipart`` object holding the message to be + sent. If you ever need to extend the + :class:`~django.core.mail.EmailMessage` class, you'll probably want to + override this method to put the content you want into the MIME object. * ``recipients()`` returns a list of all the recipients of the message, whether they're recorded in the ``to`` or ``bcc`` attributes. This is @@ -299,13 +314,13 @@ The class has the following methods: Sending alternative content types ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -It can be useful to include multiple versions of the content in an e-mail; -the classic example is to send both text and HTML versions of a message. With +It can be useful to include multiple versions of the content in an e-mail; the +classic example is to send both text and HTML versions of a message. With Django's e-mail library, you can do this using the ``EmailMultiAlternatives`` -class. This subclass of ``EmailMessage`` has an ``attach_alternative()`` method -for including extra versions of the message body in the e-mail. All the other -methods (including the class initialization) are inherited directly from -``EmailMessage``. +class. This subclass of :class:`~django.core.mail.EmailMessage` has an +``attach_alternative()`` method for including extra versions of the message +body in the e-mail. All the other methods (including the class initialization) +are inherited directly from :class:`~django.core.mail.EmailMessage`. To send a text and HTML combination, you could write:: @@ -318,41 +333,231 @@ To send a text and HTML combination, you could write:: msg.attach_alternative(html_content, "text/html") msg.send() -By default, the MIME type of the ``body`` parameter in an ``EmailMessage`` is -``"text/plain"``. It is good practice to leave this alone, because it -guarantees that any recipient will be able to read the e-mail, regardless of -their mail client. However, if you are confident that your recipients can -handle an alternative content type, you can use the ``content_subtype`` -attribute on the ``EmailMessage`` class to change the main content type. The -major type will always be ``"text"``, but you can change it to the subtype. For -example:: +By default, the MIME type of the ``body`` parameter in an +:class:`~django.core.mail.EmailMessage` is ``"text/plain"``. It is good +practice to leave this alone, because it guarantees that any recipient will be +able to read the e-mail, regardless of their mail client. However, if you are +confident that your recipients can handle an alternative content type, you can +use the ``content_subtype`` attribute on the +:class:`~django.core.mail.EmailMessage` class to change the main content type. +The major type will always be ``"text"``, but you can change it to the +subtype. For example:: msg = EmailMessage(subject, html_content, from_email, [to]) msg.content_subtype = "html" # Main content is now text/html msg.send() -SMTPConnection Objects ----------------------- +.. _topic-email-backends: -.. class:: SMTPConnection +E-Mail Backends +=============== -The ``SMTPConnection`` class is initialized with the host, port, username and -password for the SMTP server. If you don't specify one or more of those -options, they are read from your settings file. +.. versionadded:: 1.2 -If you're sending lots of messages at once, the ``send_messages()`` method of -the ``SMTPConnection`` class is useful. It takes a list of ``EmailMessage`` -instances (or subclasses) and sends them over a single connection. For example, -if you have a function called ``get_notification_email()`` that returns a -list of ``EmailMessage`` objects representing some periodic e-mail you wish to -send out, you could send this with:: +The actual sending of an e-mail is handled by the e-mail backend. - connection = SMTPConnection() # Use default settings for connection +The e-mail backend class has the following methods: + + * ``open()`` instantiates an long-lived email-sending connection. + + * ``close()`` closes the current email-sending connection. + + * ``send_messages(email_messages)`` sends a list of + :class:`~django.core.mail.EmailMessage` objects. If the connection is + not open, this call will implicitly open the connection, and close the + connection afterwards. If the connection is already open, it will be + left open after mail has been sent. + +Obtaining an instance of an e-mail backend +------------------------------------------ + +The :meth:`get_connection` function in ``django.core.mail`` returns an +instance of the e-mail backend that you can use. + +.. currentmodule:: django.core.mail + +.. function:: get_connection(backend=None, fail_silently=False, *args, **kwargs) + +By default, a call to ``get_connection()`` will return an instance of the +email backend specified in :setting:`EMAIL_BACKEND`. If you specify the +``backend`` argument, an instance of that backend will be instantiated. + +The ``fail_silently`` argument controls how the backend should handle errors. +If ``fail_silently`` is True, exceptions during the email sending process +will be silently ignored. + +All other arguments are passed directly to the constructor of the +e-mail backend. + +Django ships with several e-mail sending backends. With the exception of the +SMTP backend (which is the default), these backends are only useful during +testing and development. If you have special email sending requirements, you +can :ref:`write your own email backend `. + +.. _topic-email-smtp-backend: + +SMTP backend +~~~~~~~~~~~~ + +This is the default backend. E-mail will be sent through a SMTP server. +The server address and authentication credentials are set in the +:setting:`EMAIL_HOST`, :setting:`EMAIL_POST`, :setting:`EMAIL_HOST_USER`, +:setting:`EMAIL_HOST_PASSWORD` and :setting:`EMAIL_USE_TLS` settings in your +settings file. + +The SMTP backend is the default configuration inherited by Django. If you +want to specify it explicitly, put the following in your settings:: + + EMAIL_BACKEND = 'django.core.mail.backends.smtp' + +.. admonition:: SMTPConnection objects + + Prior to version 1.2, Django provided a + :class:`~django.core.mail.SMTPConnection` class. This class provided a way + to directly control the use of SMTP to send email. This class has been + deprecated in favor of the generic email backend API. + + For backwards compatibility :class:`~django.core.mail.SMTPConnection` is + still available in ``django.core.mail`` as an alias for the SMTP backend. + New code should use :meth:`~django.core.mail.get_connection` instead. + +Console backend +~~~~~~~~~~~~~~~ + +Instead of sending out real e-mails the console backend just writes the +e-mails that would be send to the standard output. By default, the console +backend writes to ``stdout``. You can use a different stream-like object by +providing the ``stream`` keyword argument when constructing the connection. + +To specify this backend, put the following in your settings:: + + EMAIL_BACKEND = 'django.core.mail.backends.console' + +This backend is not intended for use in production -- it is provided as a +convenience that can be used during development. + +File backend +~~~~~~~~~~~~ + +The file backend writes e-mails to a file. A new file is created for each new +session that is opened on this backend. The directory to which the files are +written is either taken from the :setting:`EMAIL_FILE_PATH` setting or from +the ``file_path`` keyword when creating a connection with +:meth:`~django.core.mail.get_connection`. + +To specify this backend, put the following in your settings:: + + EMAIL_BACKEND = 'django.core.mail.backends.filebased' + EMAIL_FILE_PATH = '/tmp/app-messages' # change this to a proper location + +This backend is not intended for use in production -- it is provided as a +convenience that can be used during development. + +In-memory backend +~~~~~~~~~~~~~~~~~ + +The ``'locmem'`` backend stores messages in a special attribute of the +``django.core.mail`` module. The ``outbox`` attribute is created when the +first message is send. It's a list with an +:class:`~django.core.mail.EmailMessage` instance for each message that would +be send. + +To specify this backend, put the following in your settings:: + + EMAIL_BACKEND = 'django.core.mail.backends.locmem' + +This backend is not intended for use in production -- it is provided as a +convenience that can be used during development and testing. + +Dummy backend +~~~~~~~~~~~~~ + +As the name suggests the dummy backend does nothing with your messages. To +specify this backend, put the following in your settings:: + + EMAIL_BACKEND = 'django.core.mail.backends.dummy' + +This backend is not intended for use in production -- it is provided as a +convenience that can be used during development. + +.. _topic-custom-email-backend: + +Defining a custom e-mail backend +-------------------------------- + +If you need to change how e-mails are send you can write your own e-mail +backend. The ``EMAIL_BACKEND`` setting in your settings file is then the +Python import path for your backend. + +Custom e-mail backends should subclass ``BaseEmailBackend`` that is located in +the ``django.core.mail.backends.base`` module. A custom e-mail backend must +implement the ``send_messages(email_messages)`` method. This method receives a +list of :class:`~django.core.mail.EmailMessage` instances and returns the +number of successfully delivered messages. If your backend has any concept of +a persistent session or connection, you should also implement the ``open()`` +and ``close()`` methods. Refer to ``SMTPEmailBackend`` for a reference +implementation. + +.. _topics-sending-multiple-emails: + +Sending multiple emails +----------------------- + +Establishing and closing an SMTP connection (or any other network connection, +for that matter) is an expensive process. If you have a lot of emails to send, +it makes sense to reuse an SMTP connection, rather than creating and +destroying a connection every time you want to send an email. + +There are two ways you tell an email backend to reuse a connection. + +Firstly, you can use the ``send_messages()`` method. ``send_messages()`` takes +a list of :class:`~django.core.mail.EmailMessage` instances (or subclasses), +and sends them all using a single connection. + +For example, if you have a function called ``get_notification_email()`` that +returns a list of :class:`~django.core.mail.EmailMessage` objects representing +some periodic e-mail you wish to send out, you could send these emails using +a single call to send_messages:: + + from django.core import mail + connection = mail.get_connection() # Use default email connection messages = get_notification_email() connection.send_messages(messages) +In this example, the call to ``send_messages()`` opens a connection on the +backend, sends the list of messages, and then closes the connection again. + +The second approach is to use the ``open()`` and ``close()`` methods on the +email backend to manually control the connection. ``send_messages()`` will not +manually open or close the connection if it is already open, so if you +manually open the connection, you can control when it is closed. For example:: + + from django.core import mail + connection = mail.get_connection() + + # Manually open the connection + connection.open() + + # Construct an email message that uses the connection + email1 = mail.EmailMessage('Hello', 'Body goes here', 'from@example.com', + ['to1@example.com'], connection=connection) + email1.send() # Send the email + + # Construct two more messages + email2 = mail.EmailMessage('Hello', 'Body goes here', 'from@example.com', + ['to2@example.com']) + email3 = mail.EmailMessage('Hello', 'Body goes here', 'from@example.com', + ['to3@example.com']) + + # Send the two emails in a single call - + connection.send_messages([email2, email3]) + # The connection was already open so send_messages() doesn't close it. + # We need to manually close the connection. + connection.close() + + Testing e-mail sending ----------------------- +====================== The are times when you do not want Django to send e-mails at all. For example, while developing a website, you probably don't want to send out thousands of @@ -360,19 +565,41 @@ e-mails -- but you may want to validate that e-mails will be sent to the right people under the right conditions, and that those e-mails will contain the correct content. -The easiest way to test your project's use of e-mail is to use a "dumb" e-mail -server that receives the e-mails locally and displays them to the terminal, -but does not actually send anything. Python has a built-in way to accomplish -this with a single command:: +The easiest way to test your project's use of e-mail is to use the ``console`` +email backend. This backend redirects all email to stdout, allowing you to +inspect the content of mail. + +The ``file`` email backend can also be useful during development -- this backend +dumps the contents of every SMTP connection to a file that can be inspected +at your leisure. + +Another approach is to use a "dumb" SMTP server that receives the e-mails +locally and displays them to the terminal, but does not actually send +anything. Python has a built-in way to accomplish this with a single command:: python -m smtpd -n -c DebuggingServer localhost:1025 This command will start a simple SMTP server listening on port 1025 of -localhost. This server simply prints to standard output all email headers and -the email body. You then only need to set the :setting:`EMAIL_HOST` and +localhost. This server simply prints to standard output all e-mail headers and +the e-mail body. You then only need to set the :setting:`EMAIL_HOST` and :setting:`EMAIL_PORT` accordingly, and you are set. -For more entailed testing and processing of e-mails locally, see the Python -documentation on the `SMTP Server`_. +For a more detailed discussion of testing and processing of e-mails locally, +see the Python documentation on the `SMTP Server`_. .. _SMTP Server: http://docs.python.org/library/smtpd.html + +SMTPConnection +============== + +.. class:: SMTPConnection + +.. deprecated:: 1.2 + +The ``SMTPConnection`` class has been deprecated in favor of the generic email +backend API. + +For backwards compatibility ``SMTPConnection`` is still available in +``django.core.mail`` as an alias for the :ref:`SMTP backend +`. New code should use +:meth:`~django.core.mail.get_connection` instead. diff --git a/docs/topics/testing.txt b/docs/topics/testing.txt index 25d2f083fd..6648461014 100644 --- a/docs/topics/testing.txt +++ b/docs/topics/testing.txt @@ -1104,6 +1104,8 @@ applications: ``target_status_code`` will be the url and status code for the final point of the redirect chain. +.. _topics-testing-email: + E-mail services --------------- @@ -1117,7 +1119,7 @@ test every aspect of sending e-mail -- from the number of messages sent to the contents of each message -- without actually sending the messages. The test runner accomplishes this by transparently replacing the normal -:class:`~django.core.mail.SMTPConnection` class with a different version. +email backend with a testing backend. (Don't worry -- this has no effect on any other e-mail senders outside of Django, such as your machine's mail server, if you're running one.) @@ -1128,14 +1130,8 @@ Django, such as your machine's mail server, if you're running one.) During test running, each outgoing e-mail is saved in ``django.core.mail.outbox``. This is a simple list of all :class:`~django.core.mail.EmailMessage` instances that have been sent. -It does not exist under normal execution conditions, i.e., when you're not -running unit tests. The outbox is created during test setup, along with the -dummy :class:`~django.core.mail.SMTPConnection`. When the test framework is -torn down, the standard :class:`~django.core.mail.SMTPConnection` class is -restored, and the test outbox is destroyed. - The ``outbox`` attribute is a special attribute that is created *only* when -the tests are run. It doesn't normally exist as part of the +the ``locmem`` e-mail backend is used. It doesn't normally exist as part of the :mod:`django.core.mail` module and you can't import it directly. The code below shows how to access this attribute correctly. diff --git a/tests/regressiontests/mail/custombackend.py b/tests/regressiontests/mail/custombackend.py new file mode 100644 index 0000000000..6b0e15ad0a --- /dev/null +++ b/tests/regressiontests/mail/custombackend.py @@ -0,0 +1,15 @@ +"""A custom backend for testing.""" + +from django.core.mail.backends.base import BaseEmailBackend + + +class EmailBackend(BaseEmailBackend): + + def __init__(self, *args, **kwargs): + super(EmailBackend, self).__init__(*args, **kwargs) + self.test_outbox = [] + + def send_messages(self, email_messages): + # Messages are stored in a instance variable for testing. + self.test_outbox.extend(email_messages) + return len(email_messages) diff --git a/tests/regressiontests/mail/tests.py b/tests/regressiontests/mail/tests.py index e90d77366f..5033581a09 100644 --- a/tests/regressiontests/mail/tests.py +++ b/tests/regressiontests/mail/tests.py @@ -1,10 +1,18 @@ # coding: utf-8 + r""" # Tests for the django.core.mail. +>>> import os +>>> import shutil +>>> import tempfile +>>> from StringIO import StringIO >>> from django.conf import settings >>> from django.core import mail >>> from django.core.mail import EmailMessage, mail_admins, mail_managers, EmailMultiAlternatives +>>> from django.core.mail import send_mail, send_mass_mail +>>> from django.core.mail.backends.base import BaseEmailBackend +>>> from django.core.mail.backends import console, dummy, locmem, filebased, smtp >>> from django.utils.translation import ugettext_lazy # Test normal ascii character case: @@ -85,8 +93,6 @@ BadHeaderError: Header values can't contain newlines (got u'Subject\nInjection T >>> mail_managers('hi','there') >>> len(mail.outbox) 1 ->>> settings.ADMINS = old_admins ->>> settings.MANAGERS = old_managers # Make sure we can manually set the From header (#9214) @@ -138,4 +144,217 @@ Content-Disposition: attachment; filename="an attachment.pdf" JVBERi0xLjQuJS4uLg== ... +# Make sure that the console backend writes to stdout by default +>>> connection = console.EmailBackend() +>>> email = EmailMessage('Subject', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> connection.send_messages([email]) +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: Subject +From: from@example.com +To: to@example.com +Date: ... +Message-ID: ... + +Content +------------------------------------------------------------------------------- +1 + +# Test that the console backend can be pointed at an arbitrary stream +>>> s = StringIO() +>>> connection = mail.get_connection('django.core.mail.backends.console', stream=s) +>>> send_mail('Subject', 'Content', 'from@example.com', ['to@example.com'], connection=connection) +1 +>>> print s.getvalue() +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: Subject +From: from@example.com +To: to@example.com +Date: ... +Message-ID: ... + +Content +------------------------------------------------------------------------------- + +# Make sure that dummy backends returns correct number of sent messages +>>> connection = dummy.EmailBackend() +>>> email = EmailMessage('Subject', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> connection.send_messages([email, email, email]) +3 + +# Make sure that locmen backend populates the outbox +>>> mail.outbox = [] +>>> connection = locmem.EmailBackend() +>>> email1 = EmailMessage('Subject', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> email2 = EmailMessage('Subject 2', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> connection.send_messages([email1, email2]) +2 +>>> len(mail.outbox) +2 +>>> mail.outbox[0].subject +'Subject' +>>> mail.outbox[1].subject +'Subject 2' + +# Make sure that multiple locmem connections share mail.outbox +>>> mail.outbox = [] +>>> connection1 = locmem.EmailBackend() +>>> connection2 = locmem.EmailBackend() +>>> email = EmailMessage('Subject', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> connection1.send_messages([email]) +1 +>>> connection2.send_messages([email]) +1 +>>> len(mail.outbox) +2 + +# Make sure that the file backend write to the right location +>>> tmp_dir = tempfile.mkdtemp() +>>> connection = filebased.EmailBackend(file_path=tmp_dir) +>>> email = EmailMessage('Subject', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> connection.send_messages([email]) +1 +>>> len(os.listdir(tmp_dir)) +1 +>>> print open(os.path.join(tmp_dir, os.listdir(tmp_dir)[0])).read() +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: Subject +From: from@example.com +To: to@example.com +Date: ... +Message-ID: ... + +Content +------------------------------------------------------------------------------- + +>>> connection2 = filebased.EmailBackend(file_path=tmp_dir) +>>> connection2.send_messages([email]) +1 +>>> len(os.listdir(tmp_dir)) +2 +>>> connection.send_messages([email]) +1 +>>> len(os.listdir(tmp_dir)) +2 +>>> email.connection = filebased.EmailBackend(file_path=tmp_dir) +>>> connection_created = connection.open() +>>> num_sent = email.send() +>>> len(os.listdir(tmp_dir)) +3 +>>> num_sent = email.send() +>>> len(os.listdir(tmp_dir)) +3 +>>> connection.close() +>>> shutil.rmtree(tmp_dir) + +# Make sure that get_connection() accepts arbitrary keyword that might be +# used with custom backends. +>>> c = mail.get_connection(fail_silently=True, foo='bar') +>>> c.fail_silently +True + +# Test custom backend defined in this suite. +>>> conn = mail.get_connection('regressiontests.mail.custombackend') +>>> hasattr(conn, 'test_outbox') +True +>>> email = EmailMessage('Subject', 'Content', 'bounce@example.com', ['to@example.com'], headers={'From': 'from@example.com'}) +>>> conn.send_messages([email]) +1 +>>> len(conn.test_outbox) +1 + +# Test backend argument of mail.get_connection() +>>> isinstance(mail.get_connection('django.core.mail.backends.smtp'), smtp.EmailBackend) +True +>>> isinstance(mail.get_connection('django.core.mail.backends.locmem'), locmem.EmailBackend) +True +>>> isinstance(mail.get_connection('django.core.mail.backends.dummy'), dummy.EmailBackend) +True +>>> isinstance(mail.get_connection('django.core.mail.backends.console'), console.EmailBackend) +True +>>> tmp_dir = tempfile.mkdtemp() +>>> isinstance(mail.get_connection('django.core.mail.backends.filebased', file_path=tmp_dir), filebased.EmailBackend) +True +>>> shutil.rmtree(tmp_dir) +>>> isinstance(mail.get_connection(), locmem.EmailBackend) +True + +# Test connection argument of send_mail() et al +>>> connection = mail.get_connection('django.core.mail.backends.console') +>>> send_mail('Subject', 'Content', 'from@example.com', ['to@example.com'], connection=connection) +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: Subject +From: from@example.com +To: to@example.com +Date: ... +Message-ID: ... + +Content +------------------------------------------------------------------------------- +1 + +>>> send_mass_mail([ +... ('Subject1', 'Content1', 'from1@example.com', ['to1@example.com']), +... ('Subject2', 'Content2', 'from2@example.com', ['to2@example.com']) +... ], connection=connection) +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: Subject1 +From: from1@example.com +To: to1@example.com +Date: ... +Message-ID: ... + +Content1 +------------------------------------------------------------------------------- +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: Subject2 +From: from2@example.com +To: to2@example.com +Date: ... +Message-ID: ... + +Content2 +------------------------------------------------------------------------------- +2 + +>>> mail_admins('Subject', 'Content', connection=connection) +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: [Django] Subject +From: root@localhost +To: nobody@example.com +Date: ... +Message-ID: ... + +Content +------------------------------------------------------------------------------- + +>>> mail_managers('Subject', 'Content', connection=connection) +Content-Type: text/plain; charset="utf-8" +MIME-Version: 1.0 +Content-Transfer-Encoding: quoted-printable +Subject: [Django] Subject +From: root@localhost +To: nobody@example.com +Date: ... +Message-ID: ... + +Content +------------------------------------------------------------------------------- + +>>> settings.ADMINS = old_admins +>>> settings.MANAGERS = old_managers + """