From aba5389326372be43b2a3bdcda16646fd197e807 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 3 Nov 2009 12:53:26 +0000 Subject: 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 --- django/core/mail.py | 426 --------------------------------- 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/message.py | 274 +++++++++++++++++++++ django/core/mail/utils.py | 19 ++ 11 files changed, 672 insertions(+), 426 deletions(-) delete mode 100644 django/core/mail.py 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 create mode 100644 django/core/mail/message.py create mode 100644 django/core/mail/utils.py (limited to 'django/core') diff --git a/django/core/mail.py b/django/core/mail.py deleted file mode 100644 index c305699158..0000000000 --- a/django/core/mail.py +++ /dev/null @@ -1,426 +0,0 @@ -""" -Tools for sending email. -""" - -import mimetypes -import os -import smtplib -import socket -import time -import random -from email import Charset, Encoders -from email.MIMEText import MIMEText -from email.MIMEMultipart import MIMEMultipart -from email.MIMEBase import MIMEBase -from email.Header import Header -from email.Utils import formatdate, parseaddr, formataddr - -from django.conf import settings -from django.utils.encoding import smart_str, force_unicode - -# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from -# some spam filters. -Charset.add_charset('utf-8', Charset.SHORTEST, Charset.QP, 'utf-8') - -# Default MIME type to use on attachments (if it is not explicitly given -# 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 - -DNS_NAME = CachedDnsName() - -# Copied from Python standard library, with the following modifications: -# * Used cached hostname for performance. -# * Added try/except to support lack of getpid() in Jython (#5496). -def make_msgid(idstring=None): - """Returns a string suitable for RFC 2822 compliant Message-ID, e.g: - - <20020201195627.33539.96671@nightshade.la.mastaler.com> - - Optional idstring if given is a string used to strengthen the - uniqueness of the message id. - """ - timeval = time.time() - utcdate = time.strftime('%Y%m%d%H%M%S', time.gmtime(timeval)) - try: - pid = os.getpid() - except AttributeError: - # No getpid() in Jython, for example. - pid = 1 - randint = random.randrange(100000) - if idstring is None: - idstring = '' - else: - idstring = '.' + idstring - idhost = DNS_NAME - 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.""" - val = force_unicode(val) - if '\n' in val or '\r' in val: - raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name)) - try: - val = val.encode('ascii') - except UnicodeEncodeError: - if name.lower() in ('to', 'from', 'cc'): - result = [] - for item in val.split(', '): - nm, addr = parseaddr(item) - nm = str(Header(nm, settings.DEFAULT_CHARSET)) - result.append(formataddr((nm, str(addr)))) - val = ', '.join(result) - else: - val = Header(val, settings.DEFAULT_CHARSET) - else: - if name.lower() == 'subject': - 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): - """ - A container for email information. - """ - content_subtype = 'plain' - mixed_subtype = 'mixed' - encoding = None # None => use settings default - - def __init__(self, subject='', body='', from_email=None, to=None, bcc=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. - """ - if to: - assert not isinstance(to, basestring), '"to" argument must be a list or tuple' - self.to = list(to) - else: - self.to = [] - if bcc: - assert not isinstance(bcc, basestring), '"bcc" argument must be a list or tuple' - self.bcc = list(bcc) - else: - self.bcc = [] - self.from_email = from_email or settings.DEFAULT_FROM_EMAIL - self.subject = subject - self.body = body - self.attachments = attachments or [] - self.extra_headers = headers or {} - self.connection = connection - - def get_connection(self, fail_silently=False): - if not self.connection: - self.connection = SMTPConnection(fail_silently=fail_silently) - return self.connection - - def message(self): - encoding = self.encoding or settings.DEFAULT_CHARSET - msg = SafeMIMEText(smart_str(self.body, settings.DEFAULT_CHARSET), - self.content_subtype, encoding) - msg = self._create_message(msg) - msg['Subject'] = self.subject - msg['From'] = self.extra_headers.pop('From', self.from_email) - msg['To'] = ', '.join(self.to) - - # Email header names are case-insensitive (RFC 2045), so we have to - # accommodate that when doing comparisons. - header_names = [key.lower() for key in self.extra_headers] - if 'date' not in header_names: - msg['Date'] = formatdate() - if 'message-id' not in header_names: - msg['Message-ID'] = make_msgid() - for name, value in self.extra_headers.items(): - msg[name] = value - return msg - - def recipients(self): - """ - Returns a list of all recipients of the email (includes direct - addressees as well as Bcc entries). - """ - return self.to + self.bcc - - def send(self, fail_silently=False): - """Sends the email message.""" - if not self.recipients(): - # Don't bother creating the network connection if there's nobody to - # send to. - return 0 - return self.get_connection(fail_silently).send_messages([self]) - - def attach(self, filename=None, content=None, mimetype=None): - """ - Attaches a file with the given filename and content. The filename can - be omitted and the mimetype is guessed, if not provided. - - If the first parameter is a MIMEBase subclass it is inserted directly - into the resulting message attachments. - """ - if isinstance(filename, MIMEBase): - assert content == mimetype == None - self.attachments.append(filename) - else: - assert content is not None - self.attachments.append((filename, content, mimetype)) - - def attach_file(self, path, mimetype=None): - """Attaches a file from the filesystem.""" - filename = os.path.basename(path) - content = open(path, 'rb').read() - self.attach(filename, content, mimetype) - - def _create_message(self, msg): - return self._create_attachments(msg) - - def _create_attachments(self, msg): - if self.attachments: - body_msg = msg - msg = SafeMIMEMultipart(_subtype=self.mixed_subtype) - if self.body: - msg.attach(body_msg) - for attachment in self.attachments: - if isinstance(attachment, MIMEBase): - msg.attach(attachment) - else: - msg.attach(self._create_attachment(*attachment)) - return msg - - def _create_mime_attachment(self, content, mimetype): - """ - Converts the content, mimetype pair into a MIME attachment object. - """ - basetype, subtype = mimetype.split('/', 1) - if basetype == 'text': - attachment = SafeMIMEText(smart_str(content, - settings.DEFAULT_CHARSET), subtype, settings.DEFAULT_CHARSET) - else: - # Encode non-text attachments with base64. - attachment = MIMEBase(basetype, subtype) - attachment.set_payload(content) - Encoders.encode_base64(attachment) - return attachment - - def _create_attachment(self, filename, content, mimetype=None): - """ - Converts the filename, content, mimetype triple into a MIME attachment - object. - """ - if mimetype is None: - mimetype, _ = mimetypes.guess_type(filename) - if mimetype is None: - mimetype = DEFAULT_ATTACHMENT_MIME_TYPE - attachment = self._create_mime_attachment(content, mimetype) - if filename: - attachment.add_header('Content-Disposition', 'attachment', - filename=filename) - return attachment - -class EmailMultiAlternatives(EmailMessage): - """ - A version of EmailMessage that makes it easy to send multipart/alternative - messages. For example, including text and HTML versions of the text is - made easier. - """ - alternative_subtype = 'alternative' - - def __init__(self, subject='', body='', from_email=None, to=None, bcc=None, - connection=None, attachments=None, headers=None, alternatives=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. - """ - super(EmailMultiAlternatives, self).__init__(subject, body, from_email, to, bcc, connection, attachments, headers) - self.alternatives=alternatives or [] - - def attach_alternative(self, content, mimetype): - """Attach an alternative content representation.""" - assert content is not None - assert mimetype is not None - self.alternatives.append((content, mimetype)) - - def _create_message(self, msg): - return self._create_attachments(self._create_alternatives(msg)) - - def _create_alternatives(self, msg): - if self.alternatives: - body_msg = msg - msg = SafeMIMEMultipart(_subtype=self.alternative_subtype) - if self.body: - msg.attach(body_msg) - 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/__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/message.py b/django/core/mail/message.py new file mode 100644 index 0000000000..7252a5a620 --- /dev/null +++ b/django/core/mail/message.py @@ -0,0 +1,274 @@ +import mimetypes +import os +import random +import time +from email import Charset, Encoders +from email.MIMEText import MIMEText +from email.MIMEMultipart import MIMEMultipart +from email.MIMEBase import MIMEBase +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 +# some spam filters. +Charset.add_charset('utf-8', Charset.SHORTEST, Charset.QP, 'utf-8') + +# Default MIME type to use on attachments (if it is not explicitly given +# and cannot be guessed). +DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream' + + +class BadHeaderError(ValueError): + pass + + +# Copied from Python standard library, with the following modifications: +# * Used cached hostname for performance. +# * Added try/except to support lack of getpid() in Jython (#5496). +def make_msgid(idstring=None): + """Returns a string suitable for RFC 2822 compliant Message-ID, e.g: + + <20020201195627.33539.96671@nightshade.la.mastaler.com> + + Optional idstring if given is a string used to strengthen the + uniqueness of the message id. + """ + timeval = time.time() + utcdate = time.strftime('%Y%m%d%H%M%S', time.gmtime(timeval)) + try: + pid = os.getpid() + except AttributeError: + # No getpid() in Jython, for example. + pid = 1 + randint = random.randrange(100000) + if idstring is None: + idstring = '' + else: + idstring = '.' + idstring + idhost = DNS_NAME + msgid = '<%s.%s.%s%s@%s>' % (utcdate, pid, randint, idstring, idhost) + return msgid + + +def forbid_multi_line_headers(name, val): + """Forbids multi-line headers, to prevent header injection.""" + val = force_unicode(val) + if '\n' in val or '\r' in val: + raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (val, name)) + try: + val = val.encode('ascii') + except UnicodeEncodeError: + if name.lower() in ('to', 'from', 'cc'): + result = [] + for item in val.split(', '): + nm, addr = parseaddr(item) + nm = str(Header(nm, settings.DEFAULT_CHARSET)) + result.append(formataddr((nm, str(addr)))) + val = ', '.join(result) + else: + val = Header(val, settings.DEFAULT_CHARSET) + else: + if name.lower() == 'subject': + 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 EmailMessage(object): + """ + A container for email information. + """ + content_subtype = 'plain' + mixed_subtype = 'mixed' + encoding = None # None => use settings default + + def __init__(self, subject='', body='', from_email=None, to=None, bcc=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. + """ + if to: + assert not isinstance(to, basestring), '"to" argument must be a list or tuple' + self.to = list(to) + else: + self.to = [] + if bcc: + assert not isinstance(bcc, basestring), '"bcc" argument must be a list or tuple' + self.bcc = list(bcc) + else: + self.bcc = [] + self.from_email = from_email or settings.DEFAULT_FROM_EMAIL + self.subject = subject + self.body = body + self.attachments = attachments or [] + self.extra_headers = headers or {} + self.connection = connection + + def get_connection(self, fail_silently=False): + from django.core.mail import get_connection + if not self.connection: + self.connection = get_connection(fail_silently=fail_silently) + return self.connection + + def message(self): + encoding = self.encoding or settings.DEFAULT_CHARSET + msg = SafeMIMEText(smart_str(self.body, settings.DEFAULT_CHARSET), + self.content_subtype, encoding) + msg = self._create_message(msg) + msg['Subject'] = self.subject + msg['From'] = self.extra_headers.pop('From', self.from_email) + msg['To'] = ', '.join(self.to) + + # Email header names are case-insensitive (RFC 2045), so we have to + # accommodate that when doing comparisons. + header_names = [key.lower() for key in self.extra_headers] + if 'date' not in header_names: + msg['Date'] = formatdate() + if 'message-id' not in header_names: + msg['Message-ID'] = make_msgid() + for name, value in self.extra_headers.items(): + msg[name] = value + return msg + + def recipients(self): + """ + Returns a list of all recipients of the email (includes direct + addressees as well as Bcc entries). + """ + return self.to + self.bcc + + def send(self, fail_silently=False): + """Sends the email message.""" + if not self.recipients(): + # Don't bother creating the network connection if there's nobody to + # send to. + return 0 + return self.get_connection(fail_silently).send_messages([self]) + + def attach(self, filename=None, content=None, mimetype=None): + """ + Attaches a file with the given filename and content. The filename can + be omitted and the mimetype is guessed, if not provided. + + If the first parameter is a MIMEBase subclass it is inserted directly + into the resulting message attachments. + """ + if isinstance(filename, MIMEBase): + assert content == mimetype == None + self.attachments.append(filename) + else: + assert content is not None + self.attachments.append((filename, content, mimetype)) + + def attach_file(self, path, mimetype=None): + """Attaches a file from the filesystem.""" + filename = os.path.basename(path) + content = open(path, 'rb').read() + self.attach(filename, content, mimetype) + + def _create_message(self, msg): + return self._create_attachments(msg) + + def _create_attachments(self, msg): + if self.attachments: + body_msg = msg + msg = SafeMIMEMultipart(_subtype=self.mixed_subtype) + if self.body: + msg.attach(body_msg) + for attachment in self.attachments: + if isinstance(attachment, MIMEBase): + msg.attach(attachment) + else: + msg.attach(self._create_attachment(*attachment)) + return msg + + def _create_mime_attachment(self, content, mimetype): + """ + Converts the content, mimetype pair into a MIME attachment object. + """ + basetype, subtype = mimetype.split('/', 1) + if basetype == 'text': + attachment = SafeMIMEText(smart_str(content, + settings.DEFAULT_CHARSET), subtype, settings.DEFAULT_CHARSET) + else: + # Encode non-text attachments with base64. + attachment = MIMEBase(basetype, subtype) + attachment.set_payload(content) + Encoders.encode_base64(attachment) + return attachment + + def _create_attachment(self, filename, content, mimetype=None): + """ + Converts the filename, content, mimetype triple into a MIME attachment + object. + """ + if mimetype is None: + mimetype, _ = mimetypes.guess_type(filename) + if mimetype is None: + mimetype = DEFAULT_ATTACHMENT_MIME_TYPE + attachment = self._create_mime_attachment(content, mimetype) + if filename: + attachment.add_header('Content-Disposition', 'attachment', + filename=filename) + return attachment + + +class EmailMultiAlternatives(EmailMessage): + """ + A version of EmailMessage that makes it easy to send multipart/alternative + messages. For example, including text and HTML versions of the text is + made easier. + """ + alternative_subtype = 'alternative' + + def __init__(self, subject='', body='', from_email=None, to=None, bcc=None, + connection=None, attachments=None, headers=None, alternatives=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. + """ + super(EmailMultiAlternatives, self).__init__(subject, body, from_email, to, bcc, connection, attachments, headers) + self.alternatives=alternatives or [] + + def attach_alternative(self, content, mimetype): + """Attach an alternative content representation.""" + assert content is not None + assert mimetype is not None + self.alternatives.append((content, mimetype)) + + def _create_message(self, msg): + return self._create_attachments(self._create_alternatives(msg)) + + def _create_alternatives(self, msg): + if self.alternatives: + body_msg = msg + msg = SafeMIMEMultipart(_subtype=self.alternative_subtype) + if self.body: + msg.attach(body_msg) + for alternative in self.alternatives: + msg.attach(self._create_mime_attachment(*alternative)) + return msg 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() -- cgit v1.3