diff -r 27971a13089f -r 2e0b0af889be thirdparty/google_appengine/google/appengine/api/mail.py --- a/thirdparty/google_appengine/google/appengine/api/mail.py Sat Sep 05 14:04:24 2009 +0200 +++ b/thirdparty/google_appengine/google/appengine/api/mail.py Sun Sep 06 23:31:53 2009 +0200 @@ -25,10 +25,12 @@ + +import email from email import MIMEBase from email import MIMEMultipart from email import MIMEText -import types +import logging from google.appengine.api import api_base_pb from google.appengine.api import apiproxy_stub_map @@ -38,51 +40,53 @@ from google.appengine.runtime import apiproxy_errors + ERROR_MAP = { - mail_service_pb.MailServiceError.BAD_REQUEST: - BadRequestError, + mail_service_pb.MailServiceError.BAD_REQUEST: + BadRequestError, - mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER: - InvalidSenderError, + mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER: + InvalidSenderError, - mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE: - InvalidAttachmentTypeError, + mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE: + InvalidAttachmentTypeError, } EXTENSION_MIME_MAP = { - 'asc': 'text/plain', - 'bmp': 'image/x-ms-bmp', - 'css': 'text/css', - 'csv': 'text/csv', - 'diff': 'text/plain', - 'gif': 'image/gif', - 'htm': 'text/html', - 'html': 'text/html', - 'ics': 'text/calendar', - 'jpe': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'jpg': 'image/jpeg', - 'pdf': 'application/pdf', - 'png': 'image/png', - 'pot': 'text/plain', - 'rss': 'text/rss+xml', - 'text': 'text/plain', - 'tif': 'image/tiff', - 'tiff': 'image/tiff', - 'txt': 'text/plain', - 'vcf': 'text/directory', - 'wbmp': 'image/vnd.wap.wbmp', -} + 'asc': 'text/plain', + 'bmp': 'image/x-ms-bmp', + 'css': 'text/css', + 'csv': 'text/csv', + 'diff': 'text/plain', + 'gif': 'image/gif', + 'htm': 'text/html', + 'html': 'text/html', + 'ics': 'text/calendar', + 'jpe': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'jpg': 'image/jpeg', + 'pdf': 'application/pdf', + 'png': 'image/png', + 'pot': 'text/plain', + 'rss': 'text/rss+xml', + 'text': 'text/plain', + 'tif': 'image/tiff', + 'tiff': 'image/tiff', + 'txt': 'text/plain', + 'vcf': 'text/directory', + 'wbmp': 'image/vnd.wap.wbmp', + } EXTENSION_WHITELIST = frozenset(EXTENSION_MIME_MAP.iterkeys()) def invalid_email_reason(email_address, field): - """Determine reason why email is invalid + """Determine reason why email is invalid. Args: email_address: Email to check. + field: Field that is invalid. Returns: String indicating invalid email reason if there is one, @@ -93,7 +97,7 @@ if isinstance(email_address, users.User): email_address = email_address.email() - if not isinstance(email_address, types.StringTypes): + if not isinstance(email_address, basestring): return 'Invalid email address type for %s.' % field stripped_address = email_address.strip() if not stripped_address: @@ -118,10 +122,11 @@ def check_email_valid(email_address, field): - """Check that email is valid + """Check that email is valid. Args: email_address: Email to check. + field: Field to check. Raises: InvalidEmailError if email_address is invalid. @@ -165,7 +170,7 @@ Single tuple with email in it if only one email string provided, else returns emails as is. """ - if isinstance(emails, types.StringTypes): + if isinstance(emails, basestring): return emails, return emails @@ -183,11 +188,29 @@ Single tuple with attachment tuple in it if only one attachment provided, else returns attachments as is. """ - if len(attachments) == 2 and isinstance(attachments[0], types.StringTypes): + if len(attachments) == 2 and isinstance(attachments[0], basestring): return attachments, return attachments +def _parse_mime_message(mime_message): + """Helper function converts a mime_message in to email.Message.Message. + + Args: + mime_message: MIME Message, string or file containing mime message. + + Returns: + Instance of email.Message.Message. Will return mime_message if already + an instance. + """ + if isinstance(mime_message, email.Message.Message): + return mime_message + elif isinstance(mime_message, basestring): + return email.message_from_string(mime_message) + else: + return email.message_from_file(mime_message) + + def send_mail(sender, to, subject, @@ -285,7 +308,7 @@ to a list of comma separated email addresses. Args: - message: Message PB to convert to MIMEMultitype. + protocol_message: Message PB to convert to MIMEMultitype. Returns: MIMEMultitype representing the provided MailMessage. @@ -334,7 +357,7 @@ def _to_str(value): - """Helper function to make sure unicode values converted to utf-8 + """Helper function to make sure unicode values converted to utf-8. Args: value: str or unicode to convert to utf-8. @@ -346,6 +369,129 @@ return value.encode('utf-8') return value + +class EncodedPayload(object): + """Wrapper for a payload that contains encoding information. + + When an email is recieved, it is usually encoded using a certain + character set, and then possibly further encoded using a transfer + encoding in that character set. Most of the times, it is possible + to decode the encoded payload as is, however, in the case where it + is not, the encoded payload and the original encoding information + must be preserved. + + Attributes: + payload: The original encoded payload. + charset: The character set of the encoded payload. None means use + default character set. + encoding: The transfer encoding of the encoded payload. None means + content not encoded. + """ + + def __init__(self, payload, charset=None, encoding=None): + """Constructor. + + Args: + payload: Maps to attribute of the same name. + charset: Maps to attribute of the same name. + encoding: Maps to attribute of the same name. + """ + self.payload = payload + self.charset = charset + self.encoding = encoding + + def decode(self): + """Attempt to decode the encoded data. + + Attempt to use pythons codec library to decode the payload. All + exceptions are passed back to the caller. + + Returns: + Binary or unicode version of payload content. + """ + payload = self.payload + + if self.encoding and self.encoding.lower() != '7bit': + try: + payload = payload.decode(self.encoding).lower() + except LookupError: + raise UnknownEncodingError('Unknown decoding %s.' % self.encoding) + except (Exception, Error), e: + raise PayloadEncodingError('Could not decode payload: %s' % e) + + if self.charset and str(self.charset).lower() != '7bit': + try: + payload = payload.decode(str(self.charset)).lower() + except LookupError: + raise UnknownCharsetError('Unknown charset %s.' % self.charset) + except (Exception, Error), e: + raise PayloadEncodingError('Could read characters: %s' % e) + + return payload + + def __eq__(self, other): + """Equality operator. + + Args: + other: The other EncodedPayload object to compare with. Comparison + with other object types are not implemented. + + Returns: + True of payload and encodings are equal, else false. + """ + if isinstance(other, EncodedPayload): + return (self.payload == other.payload and + self.charset == other.charset and + self.encoding == other.encoding) + else: + return NotImplemented + + def copy_to(self, mime_message): + """Copy contents to MIME message payload. + + If no content transfer encoding is specified, and the character set does + not equal the over-all message encoding, the payload will be base64 + encoded. + + Args: + mime_message: Message instance to receive new payload. + """ + if self.encoding: + mime_message['content-transfer-encoding'] = self.encoding + mime_message.set_payload(self.payload, self.charset) + + def to_mime_message(self): + """Convert to MIME message. + + Returns: + MIME message instance of payload. + """ + mime_message = email.Message.Message() + self.copy_to(mime_message) + return mime_message + + def __str__(self): + """String representation of encoded message. + + Returns: + MIME encoded representation of encoded payload as an independent message. + """ + return str(self.to_mime_message()) + + def __repr__(self): + """Basic representation of encoded payload. + + Returns: + Payload itself is represented by its hash value. + """ + result = '' + + class _EmailMessageBase(object): """Base class for email API service objects. @@ -354,25 +500,39 @@ """ PROPERTIES = set([ - 'sender', - 'reply_to', - 'subject', - 'body', - 'html', - 'attachments', + 'sender', + 'reply_to', + 'subject', + 'body', + 'html', + 'attachments', ]) - def __init__(self, **kw): + PROPERTIES.update(('to', 'cc', 'bcc')) + + def __init__(self, mime_message=None, **kw): """Initialize Email message. Creates new MailMessage protocol buffer and initializes it with any keyword arguments. Args: + mime_message: MIME message to initialize from. If instance of + email.Message.Message will take ownership as original message. kw: List of keyword properties as defined by PROPERTIES. """ + if mime_message: + mime_message = _parse_mime_message(mime_message) + self.update_from_mime_message(mime_message) + self.__original = mime_message + self.initialize(**kw) + @property + def original(self): + """Get original MIME message from which values were set.""" + return self.__original + def initialize(self, **kw): """Keyword initialization. @@ -398,6 +558,7 @@ - Subject must be set. - A recipient must be specified. - Must contain a body. + - All bodies and attachments must decode properly. This check does not include determining if the sender is actually authorized to send email for the application. @@ -410,17 +571,45 @@ MissingSenderError: No sender specified. MissingSubjectError: Subject is not specified. MissingBodyError: No body specified. + PayloadEncodingError: Payload is not properly encoded. + UnknownEncodingError: Payload has unknown encoding. + UnknownCharsetError: Payload has unknown character set. """ if not hasattr(self, 'sender'): raise MissingSenderError() if not hasattr(self, 'subject'): raise MissingSubjectError() - if not hasattr(self, 'body') and not hasattr(self, 'html'): + + found_body = False + + try: + body = self.body + except AttributeError: + pass + else: + if isinstance(body, EncodedPayload): + body.decode() + found_body = True + + try: + html = self.html + except AttributeError: + pass + else: + if isinstance(html, EncodedPayload): + html.decode() + found_body = True + + if not found_body: raise MissingBodyError() + if hasattr(self, 'attachments'): for file_name, data in _attachment_sequence(self.attachments): _GetMimeType(file_name) + if isinstance(data, EncodedPayload): + data.decode() + def CheckInitialized(self): self.check_initialized() @@ -448,6 +637,10 @@ Returns: MailMessage protocol version of mail message. + + Raises: + Passes through decoding errors that occur when using when decoding + EncodedPayload objects. """ self.check_initialized() message = mail_service_pb.MailMessage() @@ -456,13 +649,22 @@ if hasattr(self, 'reply_to'): message.set_replyto(_to_str(self.reply_to)) message.set_subject(_to_str(self.subject)) + if hasattr(self, 'body'): - message.set_textbody(_to_str(self.body)) + body = self.body + if isinstance(body, EncodedPayload): + body = body.decode() + message.set_textbody(_to_str(body)) if hasattr(self, 'html'): - message.set_htmlbody(_to_str(self.html)) + html = self.html + if isinstance(html, EncodedPayload): + html = html.decode() + message.set_htmlbody(_to_str(html)) if hasattr(self, 'attachments'): for file_name, data in _attachment_sequence(self.attachments): + if isinstance(data, EncodedPayload): + data = data.decode() attachment = message.add_attachment() attachment.set_filename(_to_str(file_name)) attachment.set_data(_to_str(data)) @@ -485,7 +687,7 @@ MissingSenderError: No sender specified. MissingSubjectError: Subject is not specified. MissingBodyError: No body specified. - """ + """ return mail_message_to_mime_message(self.ToProto()) def ToMIMEMessage(self): @@ -517,8 +719,8 @@ def _check_attachment(self, attachment): file_name, data = attachment - if not (isinstance(file_name, types.StringTypes) or - isinstance(data, types.StringTypes)): + if not (isinstance(file_name, basestring) or + isinstance(data, basestring)): raise TypeError() def _check_attachments(self, attachments): @@ -534,7 +736,7 @@ Raises: TypeError if values are not string type. """ - if len(attachments) == 2 and isinstance(attachments[0], types.StringTypes): + if len(attachments) == 2 and isinstance(attachments[0], basestring): self._check_attachment(attachments) else: for attachment in attachments: @@ -548,21 +750,134 @@ Args: attr: Attribute to access. value: New value for field. + + Raises: + ValueError: If provided with an empty field. + AttributeError: If not an allowed assignment field. """ - if attr in ['sender', 'reply_to']: - check_email_valid(value, attr) + if not attr.startswith('_EmailMessageBase'): + if attr in ['sender', 'reply_to']: + check_email_valid(value, attr) - if not value: - raise ValueError('May not set empty value for \'%s\'' % attr) + if not value: + raise ValueError('May not set empty value for \'%s\'' % attr) - if attr not in self.PROPERTIES: - raise AttributeError('\'EmailMessage\' has no attribute \'%s\'' % attr) + if attr not in self.PROPERTIES: + raise AttributeError('\'EmailMessage\' has no attribute \'%s\'' % attr) - if attr == 'attachments': - self._check_attachments(value) + if attr == 'attachments': + self._check_attachments(value) super(_EmailMessageBase, self).__setattr__(attr, value) + def _add_body(self, content_type, payload): + """Add body to email from payload. + + Will overwrite any existing default plain or html body. + + Args: + content_type: Content-type of body. + payload: Payload to store body as. + """ + if content_type == 'text/plain': + self.body = payload + elif content_type == 'text/html': + self.html = payload + + def _update_payload(self, mime_message): + """Update payload of mail message from mime_message. + + This function works recusively when it receives a multipart body. + If it receives a non-multi mime object, it will determine whether or + not it is an attachment by whether it has a filename or not. Attachments + and bodies are then wrapped in EncodedPayload with the correct charsets and + encodings. + + Args: + mime_message: A Message MIME email object. + """ + payload = mime_message.get_payload() + + if payload: + if mime_message.get_content_maintype() == 'multipart': + for alternative in payload: + self._update_payload(alternative) + else: + filename = mime_message.get_param('filename', + header='content-disposition') + if not filename: + filename = mime_message.get_param('name') + + payload = EncodedPayload(payload, + mime_message.get_charset(), + mime_message['content-transfer-encoding']) + + if filename: + try: + attachments = self.attachments + except AttributeError: + self.attachments = (filename, payload) + else: + if isinstance(attachments[0], basestring): + self.attachments = [attachments] + attachments = self.attachments + attachments.append((filename, payload)) + else: + self._add_body(mime_message.get_content_type(), payload) + + def update_from_mime_message(self, mime_message): + """Copy information from a mime message. + + Set information of instance to values of mime message. This method + will only copy values that it finds. Any missing values will not + be copied, nor will they overwrite old values with blank values. + + This object is not guaranteed to be initialized after this call. + + Args: + mime_message: email.Message instance to copy information from. + + Returns: + MIME Message instance of mime_message argument. + """ + mime_message = _parse_mime_message(mime_message) + + sender = mime_message['from'] + if sender: + self.sender = sender + + reply_to = mime_message['reply-to'] + if reply_to: + self.reply_to = reply_to + + subject = mime_message['subject'] + if subject: + self.subject = subject + + self._update_payload(mime_message) + + def bodies(self, content_type=None): + """Iterate over all bodies. + + Yields: + Tuple (content_type, payload) for html and body in that order. + """ + if (not content_type or + content_type == 'text' or + content_type == 'text/html'): + try: + yield 'text/html', self.html + except AttributeError: + pass + + if (not content_type or + content_type == 'text' or + content_type == 'text/plain'): + try: + yield 'text/plain', self.body + except AttributeError: + pass + class EmailMessage(_EmailMessageBase): """Main interface to email API service. @@ -592,8 +907,7 @@ """ _API_CALL = 'Send' - PROPERTIES = _EmailMessageBase.PROPERTIES - PROPERTIES.update(('to', 'cc', 'bcc')) + PROPERTIES = set(_EmailMessageBase.PROPERTIES) def check_initialized(self): """Provide additional checks to ensure recipients have been specified. @@ -629,13 +943,46 @@ def __setattr__(self, attr, value): """Provides additional checks on recipient fields.""" if attr in ['to', 'cc', 'bcc']: - if isinstance(value, types.StringTypes): + if isinstance(value, basestring): check_email_valid(value, attr) else: - _email_check_and_list(value, attr) + for address in value: + check_email_valid(address, attr) super(EmailMessage, self).__setattr__(attr, value) + def update_from_mime_message(self, mime_message): + """Copy information from a mime message. + + Update fields for recipients. + + Args: + mime_message: email.Message instance to copy information from. + """ + mime_message = _parse_mime_message(mime_message) + super(EmailMessage, self).update_from_mime_message(mime_message) + + to = mime_message.get_all('to') + if to: + if len(to) == 1: + self.to = to[0] + else: + self.to = to + + cc = mime_message.get_all('cc') + if cc: + if len(cc) == 1: + self.cc = cc[0] + else: + self.cc = cc + + bcc = mime_message.get_all('bcc') + if bcc: + if len(bcc) == 1: + self.bcc = bcc[0] + else: + self.bcc = bcc + class AdminEmailMessage(_EmailMessageBase): """Interface to sending email messages to all admins via the amil API. @@ -667,3 +1014,114 @@ """ _API_CALL = 'SendToAdmins' + __UNUSED_PROPERTIES = set(('to', 'cc', 'bcc')) + + def __setattr__(self, attr, value): + if attr in self.__UNUSED_PROPERTIES: + logging.warning('\'%s\' is not a valid property to set ' + 'for AdminEmailMessage. It is unused.', attr) + super(AdminEmailMessage, self).__setattr__(attr, value) + + +class InboundEmailMessage(EmailMessage): + """Parsed email object as recevied from external source. + + Has a date field and can store any number of additional bodies. These + additional attributes make the email more flexible as required for + incoming mail, where the developer has less control over the content. + + Example Usage: + + # Read mail message from CGI input. + message = InboundEmailMessage(sys.stdin.read()) + logging.info('Received email message from %s at %s', + message.sender, + message.date) + enriched_body = list(message.bodies('text/enriched'))[0] + ... Do something with body ... + """ + + __HEADER_PROPERTIES = {'date': 'date', + 'message_id': 'message-id', + } + + PROPERTIES = frozenset(_EmailMessageBase.PROPERTIES | + set(('alternate_bodies',)) | + set(__HEADER_PROPERTIES.iterkeys())) + + def update_from_mime_message(self, mime_message): + """Update values from MIME message. + + Copies over date values. + + Args: + mime_message: email.Message instance to copy information from. + """ + mime_message = _parse_mime_message(mime_message) + super(InboundEmailMessage, self).update_from_mime_message(mime_message) + + for property, header in InboundEmailMessage.__HEADER_PROPERTIES.iteritems(): + value = mime_message[header] + if value: + setattr(self, property, value) + + def _add_body(self, content_type, payload): + """Add body to inbound message. + + Method is overidden to handle incoming messages that have more than one + plain or html bodies or has any unidentified bodies. + + This method will not overwrite existing html and body values. This means + that when updating, the text and html bodies that are first in the MIME + document order are assigned to the body and html properties. + + Args: + content_type: Content-type of additional body. + payload: Content of additional body. + """ + if (content_type == 'text/plain' and not hasattr(self, 'body') or + content_type == 'text/html' and not hasattr(self, 'html')): + super(InboundEmailMessage, self)._add_body(content_type, payload) + else: + try: + alternate_bodies = self.alternate_bodies + except AttributeError: + alternate_bodies = self.alternate_bodies = [(content_type, payload)] + else: + alternate_bodies.append((content_type, payload)) + + def bodies(self, content_type=None): + """Iterate over all bodies. + + Args: + content_type: Content type to filter on. Allows selection of only + specific types of content. Can be just the base type of the content + type. For example: + content_type = 'text/html' # Matches only HTML content. + content_type = 'text' # Matches text of any kind. + + Yields: + Tuple (content_type, payload) for all bodies of message, including body, + html and all alternate_bodies in that order. + """ + main_bodies = super(InboundEmailMessage, self).bodies(content_type) + for payload_type, payload in main_bodies: + yield payload_type, payload + + partial_type = bool(content_type and content_type.find('/') < 0) + + try: + for payload_type, payload in self.alternate_bodies: + if content_type: + if partial_type: + match_type = payload_type.split('/')[0] + else: + match_type = payload_type + match = match_type == content_type + else: + match = True + + if match: + yield payload_type, payload + except AttributeError: + pass