thirdparty/google_appengine/google/appengine/api/mail.py
changeset 2864 2e0b0af889be
parent 2273 e4cb9c53db3e
child 3031 7678f72140e6
--- 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 = '<EncodedPayload payload=#%d' % hash(self.payload)
+    if self.charset:
+      result += ' charset=%s' % self.charset
+    if self.encoding:
+      result += ' encoding=%s' % self.encoding
+    return 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