You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
371 lines
14 KiB
371 lines
14 KiB
""" |
|
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' |
|
multipart_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) |
|
if self.attachments: |
|
body_msg = msg |
|
msg = SafeMIMEMultipart(_subtype=self.multipart_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)) |
|
msg['Subject'] = self.subject |
|
msg['From'] = self.from_email |
|
msg['To'] = ', '.join(self.to) |
|
msg['Date'] = formatdate() |
|
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.""" |
|
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 (useful for multipart/alternative messages) 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_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 |
|
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) |
|
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. |
|
""" |
|
multipart_subtype = 'alternative' |
|
|
|
def attach_alternative(self, content, mimetype=None): |
|
"""Attach an alternative content representation.""" |
|
self.attach(content=content, mimetype=mimetype) |
|
|
|
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.""" |
|
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.""" |
|
EmailMessage(settings.EMAIL_SUBJECT_PREFIX + subject, message, |
|
settings.SERVER_EMAIL, [a[1] for a in settings.MANAGERS] |
|
).send(fail_silently=fail_silently)
|
|
|