PageRenderTime 380ms CodeModel.GetById 80ms app.highlight 234ms RepoModel.GetById 49ms app.codeStats 1ms

/kai/lib/mail.py

https://bitbucket.org/bbangert/kai/
Python | 328 lines | 301 code | 7 blank | 20 comment | 4 complexity | fbdd76be59a8e3afece0c1f40d963d7f MD5 | raw file
  1"""Tools for sending email."""
  2from email import Charset, Encoders
  3from email.MIMEText import MIMEText
  4from email.MIMEMultipart import MIMEMultipart
  5from email.MIMEBase import MIMEBase
  6from email.Header import Header
  7from email.Utils import formatdate, parseaddr, formataddr
  8import mimetypes
  9import os
 10import random
 11import smtplib
 12import socket
 13import time
 14import types
 15
 16from paste.deploy.converters import asbool
 17from pylons import config
 18
 19# Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
 20# some spam filters.
 21Charset.add_charset('utf-8', Charset.SHORTEST, Charset.QP, 'utf-8')
 22
 23# Default MIME type to use on attachments (if it is not explicitly given
 24# and cannot be guessed).
 25DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream'
 26
 27# Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of
 28# seconds, which slows down the restart of the server.
 29class CachedDnsName(object):
 30    def __str__(self):
 31        return self.get_fqdn()
 32
 33    def get_fqdn(self):
 34        if not hasattr(self, '_fqdn'):
 35            self._fqdn = socket.getfqdn()
 36        return self._fqdn
 37
 38DNS_NAME = CachedDnsName()
 39DEFAULT_CHARSET = 'utf-8'
 40
 41# Copied from Python standard library and modified to used the cached hostname
 42# for performance.
 43def make_msgid(idstring=None):
 44    """Returns a string suitable for RFC 2822 compliant Message-ID, e.g:
 45
 46    <20020201195627.33539.96671@nightshade.la.mastaler.com>
 47
 48    Optional idstring if given is a string used to strengthen the
 49    uniqueness of the message id.
 50    """
 51    timeval = time.time()
 52    utcdate = time.strftime('%Y%m%d%H%M%S', time.gmtime(timeval))
 53    pid = os.getpid()
 54    randint = random.randrange(100000)
 55    if idstring is None:
 56        idstring = ''
 57    else:
 58        idstring = '.' + idstring
 59    idhost = DNS_NAME
 60    msgid = '<%s.%s.%s%s@%s>' % (utcdate, pid, randint, idstring, idhost)
 61    return msgid
 62
 63def force_unicode(s, encoding='utf-8', strings_only=False, errors='strict'):
 64    """
 65    Similar to smart_unicode, except that lazy instances are resolved to
 66    strings, rather than kept as lazy objects.
 67
 68    If strings_only is True, don't convert (some) non-string-like objects.
 69    """
 70    if strings_only and isinstance(s, (types.NoneType, int)):
 71        return s
 72    if not isinstance(s, basestring,):
 73        if hasattr(s, '__unicode__'):
 74            s = unicode(s)
 75        else:
 76            s = unicode(str(s), encoding, errors)
 77    elif not isinstance(s, unicode):
 78        s = unicode(s, encoding, errors)
 79    return s
 80
 81class BadHeaderError(ValueError):
 82    pass
 83
 84class SafeMIMEText(MIMEText):
 85    def __setitem__(self, name, val):
 86        "Forbids multi-line headers, to prevent header injection."
 87        if '\n' in val or '\r' in val:
 88            raise BadHeaderError, "Header values can't contain newlines (got %r for header %r)" % (val, name)
 89        try:
 90            val = str(force_unicode(val))
 91        except UnicodeEncodeError:
 92            if name.lower() in ('to', 'from', 'cc'):
 93                result = []
 94                for item in val.split(', '):
 95                    nm, addr = parseaddr(item)
 96                    nm = str(Header(nm, DEFAULT_CHARSET))
 97                    result.append(formataddr((nm, str(addr))))
 98                val = ', '.join(result)
 99            else:
100                val = Header(force_unicode(val), DEFAULT_CHARSET)
101        MIMEText.__setitem__(self, name, val)
102
103class SafeMIMEMultipart(MIMEMultipart):
104    def __setitem__(self, name, val):
105        "Forbids multi-line headers, to prevent header injection."
106        if '\n' in val or '\r' in val:
107            raise BadHeaderError, "Header values can't contain newlines (got %r for header %r)" % (val, name)
108        try:
109            val = str(force_unicode(val))
110        except UnicodeEncodeError:
111            if name.lower() in ('to', 'from', 'cc'):
112                result = []
113                for item in val.split(', '):
114                    nm, addr = parseaddr(item)
115                    nm = str(Header(nm, DEFAULT_CHARSET))
116                    result.append(formataddr((nm, str(addr))))
117                val = ', '.join(result)
118            else:
119                val = Header(force_unicode(val), DEFAULT_CHARSET)
120        MIMEMultipart.__setitem__(self, name, val)
121
122class SMTPConnection(object):
123    """
124    A wrapper that manages the SMTP network connection.
125    """
126    def __init__(self, host=None, port=None, username=None, password=None,
127                 use_tls=None, fail_silently=False):
128        self.host = host or config['smtp_server']
129        self.port = port or config.get('smtp_port', 25)
130        self.username = username or config.get('smtp_username', '')
131        self.password = password or config.get('smtp_password', '')
132        self.use_tls = (use_tls is not None) and use_tls or \
133            asbool(config.get('smtp_use_tls', False))
134        self.fail_silently = fail_silently
135        self.connection = None
136
137    def open(self):
138        """
139        Ensure we have a connection to the email server. Returns whether or not
140        a new connection was required.
141        """
142        if self.connection:
143            # Nothing to do if the connection is already open.
144            return False
145        try:
146            self.connection = smtplib.SMTP(self.host, self.port)
147            if self.use_tls:
148                self.connection.ehlo()
149                self.connection.starttls()
150                self.connection.ehlo()
151            if self.username and self.password:
152                self.connection.login(self.username, self.password)
153            return True
154        except:
155            if not self.fail_silently:
156                raise
157
158    def close(self):
159        """Close the connection to the email server."""
160        try:
161            try:
162                self.connection.quit()
163            except socket.sslerror:
164                # This happens when calling quit() on a TLS connection
165                # sometimes.
166                self.connection.close()
167            except:
168                if self.fail_silently:
169                    return
170                raise
171        finally:
172            self.connection = None
173
174    def send_messages(self, email_messages):
175        """
176        Send one or more EmailMessage objects and return the number of email
177        messages sent.
178        """
179        if not email_messages:
180            return
181        new_conn_created = self.open()
182        if not self.connection:
183            # We failed silently on open(). Trying to send would be pointless.
184            return
185        num_sent = 0
186        for message in email_messages:
187            sent = self._send(message)
188            if sent:
189                num_sent += 1
190        if new_conn_created:
191            self.close()
192        return num_sent
193
194    def _send(self, email_message):
195        """A helper method that does the actual sending."""
196        if not email_message.to:
197            return False
198        try:
199            self.connection.sendmail(email_message.from_email,
200                    email_message.recipients(),
201                    email_message.message().as_string())
202        except:
203            if not self.fail_silently:
204                raise
205            return False
206        return True
207
208class EmailMessage(object):
209    """
210    A container for email information.
211    """
212    content_subtype = 'plain'
213    multipart_subtype = 'mixed'
214    encoding = None     # None => use settings default
215
216    def __init__(self, subject='', body='', from_email=None, to=None, bcc=None,
217            connection=None, attachments=None, headers=None):
218        """
219        Initialise a single email message (which can be sent to multiple
220        recipients).
221
222        All strings used to create the message can be unicode strings (or UTF-8
223        bytestrings). The SafeMIMEText class will handle any necessary encoding
224        conversions.
225        """
226        self.to = to or []
227        self.bcc = bcc or []
228        self.from_email = from_email or config.get('smtp_from', '')
229        self.subject = subject
230        self.body = body
231        self.attachments = attachments or []
232        self.extra_headers = headers or {}
233        self.connection = connection
234
235    def get_connection(self, fail_silently=False):
236        if not self.connection:
237            self.connection = SMTPConnection(fail_silently=fail_silently)
238        return self.connection
239
240    def message(self):
241        encoding = self.encoding or DEFAULT_CHARSET
242        msg = SafeMIMEText(force_unicode(self.body, DEFAULT_CHARSET), self.content_subtype, encoding)
243        if self.attachments:
244            body_msg = msg
245            msg = SafeMIMEMultipart(_subtype=self.multipart_subtype)
246            if self.body:
247                msg.attach(body_msg)
248            for attachment in self.attachments:
249                if isinstance(attachment, MIMEBase):
250                    msg.attach(attachment)
251                else:
252                    msg.attach(self._create_attachment(*attachment))
253        msg['Subject'] = self.subject
254        msg['From'] = self.from_email
255        msg['To'] = ', '.join(self.to)
256        msg['Date'] = formatdate()
257        msg['Message-ID'] = make_msgid()
258        if self.bcc:
259            msg['Bcc'] = ', '.join(self.bcc)
260        for name, value in self.extra_headers.items():
261            msg[name] = value
262        return msg
263
264    def recipients(self):
265        """
266        Returns a list of all recipients of the email (includes direct
267        addressees as well as Bcc entries).
268        """
269        return self.to + self.bcc
270
271    def send(self, fail_silently=False):
272        """Send the email message."""
273        return self.get_connection(fail_silently).send_messages([self])
274
275    def attach(self, filename=None, content=None, mimetype=None):
276        """
277        Attaches a file with the given filename and content. The filename can
278        be omitted (useful for multipart/alternative messages) and the mimetype
279        is guessed, if not provided.
280
281        If the first parameter is a MIMEBase subclass it is inserted directly
282        into the resulting message attachments.
283        """
284        if isinstance(filename, MIMEBase):
285            assert content == mimetype == None
286            self.attachments.append(filename)
287        else:
288            assert content is not None
289            self.attachments.append((filename, content, mimetype))
290
291    def attach_file(self, path, mimetype=None):
292        """Attaches a file from the filesystem."""
293        filename = os.path.basename(path)
294        content = open(path, 'rb').read()
295        self.attach(filename, content, mimetype)
296
297    def _create_attachment(self, filename, content, mimetype=None):
298        """
299        Convert the filename, content, mimetype triple into a MIME attachment
300        object.
301        """
302        if mimetype is None:
303            mimetype, _ = mimetypes.guess_type(filename)
304            if mimetype is None:
305                mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
306        basetype, subtype = mimetype.split('/', 1)
307        if basetype == 'text':
308            attachment = SafeMIMEText(content, subtype, DEFAULT_CHARSET)
309        else:
310            # Encode non-text attachments with base64.
311            attachment = MIMEBase(basetype, subtype)
312            attachment.set_payload(content)
313            Encoders.encode_base64(attachment)
314        if filename:
315            attachment.add_header('Content-Disposition', 'attachment', filename=filename)
316        return attachment
317
318class EmailMultiAlternatives(EmailMessage):
319    """
320    A version of EmailMessage that makes it easy to send multipart/alternative
321    messages. For example, including text and HTML versions of the text is
322    made easier.
323    """
324    multipart_subtype = 'alternative'
325
326    def attach_alternative(self, content, mimetype=None):
327        """Attach an alternative content representation."""
328        self.attach(content=content, mimetype=mimetype)