Mercurial > kallithea
diff rhodecode/lib/rcmail/response.py @ 2031:82a88013a3fd
merge 1.3 into stable
author | Marcin Kuzminski <marcin@python-works.com> |
---|---|
date | Sun, 26 Feb 2012 17:25:09 +0200 |
parents | b3a3890b7160 |
children | d95ef6587bca |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rhodecode/lib/rcmail/response.py Sun Feb 26 17:25:09 2012 +0200 @@ -0,0 +1,449 @@ +# The code in this module is entirely lifted from the Lamson project +# (http://lamsonproject.org/). Its copyright is: + +# Copyright (c) 2008, Zed A. Shaw +# All rights reserved. + +# It is provided under this license: + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: + +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. + +# * Neither the name of the Zed A. Shaw nor the names of its contributors may +# be used to endorse or promote products derived from this software without +# specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import os +import mimetypes +import string +from email import encoders +from email.charset import Charset +from email.utils import parseaddr +from email.mime.base import MIMEBase + +ADDRESS_HEADERS_WHITELIST = ['From', 'To', 'Delivered-To', 'Cc'] +DEFAULT_ENCODING = "utf-8" +VALUE_IS_EMAIL_ADDRESS = lambda v: '@' in v + + +def normalize_header(header): + return string.capwords(header.lower(), '-') + + +class EncodingError(Exception): + """Thrown when there is an encoding error.""" + pass + + +class MailBase(object): + """MailBase is used as the basis of lamson.mail and contains the basics of + encoding an email. You actually can do all your email processing with this + class, but it's more raw. + """ + def __init__(self, items=()): + self.headers = dict(items) + self.parts = [] + self.body = None + self.content_encoding = {'Content-Type': (None, {}), + 'Content-Disposition': (None, {}), + 'Content-Transfer-Encoding': (None, {})} + + def __getitem__(self, key): + return self.headers.get(normalize_header(key), None) + + def __len__(self): + return len(self.headers) + + def __iter__(self): + return iter(self.headers) + + def __contains__(self, key): + return normalize_header(key) in self.headers + + def __setitem__(self, key, value): + self.headers[normalize_header(key)] = value + + def __delitem__(self, key): + del self.headers[normalize_header(key)] + + def __nonzero__(self): + return self.body != None or len(self.headers) > 0 or len(self.parts) > 0 + + def keys(self): + """Returns the sorted keys.""" + return sorted(self.headers.keys()) + + def attach_file(self, filename, data, ctype, disposition): + """ + A file attachment is a raw attachment with a disposition that + indicates the file name. + """ + assert filename, "You can't attach a file without a filename." + ctype = ctype.lower() + + part = MailBase() + part.body = data + part.content_encoding['Content-Type'] = (ctype, {'name': filename}) + part.content_encoding['Content-Disposition'] = (disposition, + {'filename': filename}) + self.parts.append(part) + + def attach_text(self, data, ctype): + """ + This attaches a simpler text encoded part, which doesn't have a + filename. + """ + ctype = ctype.lower() + + part = MailBase() + part.body = data + part.content_encoding['Content-Type'] = (ctype, {}) + self.parts.append(part) + + def walk(self): + for p in self.parts: + yield p + for x in p.walk(): + yield x + + +class MailResponse(object): + """ + You are given MailResponse objects from the lamson.view methods, and + whenever you want to generate an email to send to someone. It has the + same basic functionality as MailRequest, but it is designed to be written + to, rather than read from (although you can do both). + + You can easily set a Body or Html during creation or after by passing it + as __init__ parameters, or by setting those attributes. + + You can initially set the From, To, and Subject, but they are headers so + use the dict notation to change them: msg['From'] = 'joe@test.com'. + + The message is not fully crafted until right when you convert it with + MailResponse.to_message. This lets you change it and work with it, then + send it out when it's ready. + """ + def __init__(self, To=None, From=None, Subject=None, Body=None, Html=None, + separator="; "): + self.Body = Body + self.Html = Html + self.base = MailBase([('To', To), ('From', From), ('Subject', Subject)]) + self.multipart = self.Body and self.Html + self.attachments = [] + self.separator = separator + + def __contains__(self, key): + return self.base.__contains__(key) + + def __getitem__(self, key): + return self.base.__getitem__(key) + + def __setitem__(self, key, val): + return self.base.__setitem__(key, val) + + def __delitem__(self, name): + del self.base[name] + + def attach(self, filename=None, content_type=None, data=None, + disposition=None): + """ + + Simplifies attaching files from disk or data as files. To attach + simple text simple give data and a content_type. To attach a file, + give the data/content_type/filename/disposition combination. + + For convenience, if you don't give data and only a filename, then it + will read that file's contents when you call to_message() later. If + you give data and filename then it will assume you've filled data + with what the file's contents are and filename is just the name to + use. + """ + + assert filename or data, ("You must give a filename or some data to " + "attach.") + assert data or os.path.exists(filename), ("File doesn't exist, and no " + "data given.") + + self.multipart = True + + if filename and not content_type: + content_type, encoding = mimetypes.guess_type(filename) + + assert content_type, ("No content type given, and couldn't guess " + "from the filename: %r" % filename) + + self.attachments.append({'filename': filename, + 'content_type': content_type, + 'data': data, + 'disposition': disposition,}) + + def attach_part(self, part): + """ + Attaches a raw MailBase part from a MailRequest (or anywhere) + so that you can copy it over. + """ + self.multipart = True + + self.attachments.append({'filename': None, + 'content_type': None, + 'data': None, + 'disposition': None, + 'part': part, + }) + + def attach_all_parts(self, mail_request): + """ + Used for copying the attachment parts of a mail.MailRequest + object for mailing lists that need to maintain attachments. + """ + for part in mail_request.all_parts(): + self.attach_part(part) + + self.base.content_encoding = mail_request.base.content_encoding.copy() + + def clear(self): + """ + Clears out the attachments so you can redo them. Use this to keep the + headers for a series of different messages with different attachments. + """ + del self.attachments[:] + del self.base.parts[:] + self.multipart = False + + def update(self, message): + """ + Used to easily set a bunch of heading from another dict + like object. + """ + for k in message.keys(): + self.base[k] = message[k] + + def __str__(self): + """ + Converts to a string. + """ + return self.to_message().as_string() + + def _encode_attachment(self, filename=None, content_type=None, data=None, + disposition=None, part=None): + """ + Used internally to take the attachments mentioned in self.attachments + and do the actual encoding in a lazy way when you call to_message. + """ + if part: + self.base.parts.append(part) + elif filename: + if not data: + data = open(filename).read() + + self.base.attach_file(filename, data, content_type, + disposition or 'attachment') + else: + self.base.attach_text(data, content_type) + + ctype = self.base.content_encoding['Content-Type'][0] + + if ctype and not ctype.startswith('multipart'): + self.base.content_encoding['Content-Type'] = ('multipart/mixed', {}) + + def to_message(self): + """ + Figures out all the required steps to finally craft the + message you need and return it. The resulting message + is also available as a self.base attribute. + + What is returned is a Python email API message you can + use with those APIs. The self.base attribute is the raw + lamson.encoding.MailBase. + """ + del self.base.parts[:] + + if self.Body and self.Html: + self.multipart = True + self.base.content_encoding['Content-Type'] = ( + 'multipart/alternative', {}) + + if self.multipart: + self.base.body = None + if self.Body: + self.base.attach_text(self.Body, 'text/plain') + + if self.Html: + self.base.attach_text(self.Html, 'text/html') + + for args in self.attachments: + self._encode_attachment(**args) + + elif self.Body: + self.base.body = self.Body + self.base.content_encoding['Content-Type'] = ('text/plain', {}) + + elif self.Html: + self.base.body = self.Html + self.base.content_encoding['Content-Type'] = ('text/html', {}) + + return to_message(self.base, separator=self.separator) + + def all_parts(self): + """ + Returns all the encoded parts. Only useful for debugging + or inspecting after calling to_message(). + """ + return self.base.parts + + def keys(self): + return self.base.keys() + + +def to_message(mail, separator="; "): + """ + Given a MailBase message, this will construct a MIMEPart + that is canonicalized for use with the Python email API. + """ + ctype, params = mail.content_encoding['Content-Type'] + + if not ctype: + if mail.parts: + ctype = 'multipart/mixed' + else: + ctype = 'text/plain' + else: + if mail.parts: + assert ctype.startswith(("multipart", "message")), \ + "Content type should be multipart or message, not %r" % ctype + + # adjust the content type according to what it should be now + mail.content_encoding['Content-Type'] = (ctype, params) + + try: + out = MIMEPart(ctype, **params) + except TypeError, exc: # pragma: no cover + raise EncodingError("Content-Type malformed, not allowed: %r; " + "%r (Python ERROR: %s" % + (ctype, params, exc.message)) + + for k in mail.keys(): + if k in ADDRESS_HEADERS_WHITELIST: + out[k.encode('ascii')] = header_to_mime_encoding( + mail[k], + not_email=False, + separator=separator + ) + else: + out[k.encode('ascii')] = header_to_mime_encoding( + mail[k], + not_email=True + ) + + out.extract_payload(mail) + + # go through the children + for part in mail.parts: + out.attach(to_message(part)) + + return out + +class MIMEPart(MIMEBase): + """ + A reimplementation of nearly everything in email.mime to be more useful + for actually attaching things. Rather than one class for every type of + thing you'd encode, there's just this one, and it figures out how to + encode what you ask it. + """ + def __init__(self, type, **params): + self.maintype, self.subtype = type.split('/') + MIMEBase.__init__(self, self.maintype, self.subtype, **params) + + def add_text(self, content): + # this is text, so encode it in canonical form + try: + encoded = content.encode('ascii') + charset = 'ascii' + except UnicodeError: + encoded = content.encode('utf-8') + charset = 'utf-8' + + self.set_payload(encoded, charset=charset) + + def extract_payload(self, mail): + if mail.body == None: return # only None, '' is still ok + + ctype, ctype_params = mail.content_encoding['Content-Type'] + cdisp, cdisp_params = mail.content_encoding['Content-Disposition'] + + assert ctype, ("Extract payload requires that mail.content_encoding " + "have a valid Content-Type.") + + if ctype.startswith("text/"): + self.add_text(mail.body) + else: + if cdisp: + # replicate the content-disposition settings + self.add_header('Content-Disposition', cdisp, **cdisp_params) + + self.set_payload(mail.body) + encoders.encode_base64(self) + + def __repr__(self): + return "<MIMEPart '%s/%s': %r, %r, multipart=%r>" % ( + self.subtype, + self.maintype, + self['Content-Type'], + self['Content-Disposition'], + self.is_multipart()) + + +def header_to_mime_encoding(value, not_email=False, separator=", "): + if not value: return "" + + encoder = Charset(DEFAULT_ENCODING) + if type(value) == list: + return separator.join(properly_encode_header( + v, encoder, not_email) for v in value) + else: + return properly_encode_header(value, encoder, not_email) + +def properly_encode_header(value, encoder, not_email): + """ + The only thing special (weird) about this function is that it tries + to do a fast check to see if the header value has an email address in + it. Since random headers could have an email address, and email addresses + have weird special formatting rules, we have to check for it. + + Normally this works fine, but in Librelist, we need to "obfuscate" email + addresses by changing the '@' to '-AT-'. This is where + VALUE_IS_EMAIL_ADDRESS exists. It's a simple lambda returning True/False + to check if a header value has an email address. If you need to make this + check different, then change this. + """ + try: + return value.encode("ascii") + except UnicodeEncodeError: + if not_email is False and VALUE_IS_EMAIL_ADDRESS(value): + # this could have an email address, make sure we don't screw it up + name, address = parseaddr(value) + return '"%s" <%s>' % ( + encoder.header_encode(name.encode("utf-8")), address) + + return encoder.header_encode(value.encode("utf-8"))