thirdparty/google_appengine/google/appengine/api/mail.py
changeset 2864 2e0b0af889be
parent 2273 e4cb9c53db3e
child 3031 7678f72140e6
equal deleted inserted replaced
2862:27971a13089f 2864:2e0b0af889be
    23 
    23 
    24 
    24 
    25 
    25 
    26 
    26 
    27 
    27 
       
    28 
       
    29 import email
    28 from email import MIMEBase
    30 from email import MIMEBase
    29 from email import MIMEMultipart
    31 from email import MIMEMultipart
    30 from email import MIMEText
    32 from email import MIMEText
    31 import types
    33 import logging
    32 
    34 
    33 from google.appengine.api import api_base_pb
    35 from google.appengine.api import api_base_pb
    34 from google.appengine.api import apiproxy_stub_map
    36 from google.appengine.api import apiproxy_stub_map
    35 from google.appengine.api import mail_service_pb
    37 from google.appengine.api import mail_service_pb
    36 from google.appengine.api import users
    38 from google.appengine.api import users
    37 from google.appengine.api.mail_errors import *
    39 from google.appengine.api.mail_errors import *
    38 from google.appengine.runtime import apiproxy_errors
    40 from google.appengine.runtime import apiproxy_errors
    39 
    41 
    40 
    42 
       
    43 
    41 ERROR_MAP = {
    44 ERROR_MAP = {
    42   mail_service_pb.MailServiceError.BAD_REQUEST:
    45     mail_service_pb.MailServiceError.BAD_REQUEST:
    43     BadRequestError,
    46       BadRequestError,
    44 
    47 
    45   mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER:
    48     mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER:
    46     InvalidSenderError,
    49       InvalidSenderError,
    47 
    50 
    48   mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE:
    51     mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE:
    49     InvalidAttachmentTypeError,
    52       InvalidAttachmentTypeError,
    50 }
    53 }
    51 
    54 
    52 
    55 
    53 EXTENSION_MIME_MAP = {
    56 EXTENSION_MIME_MAP = {
    54   'asc': 'text/plain',
    57     'asc': 'text/plain',
    55   'bmp': 'image/x-ms-bmp',
    58     'bmp': 'image/x-ms-bmp',
    56   'css': 'text/css',
    59     'css': 'text/css',
    57   'csv': 'text/csv',
    60     'csv': 'text/csv',
    58   'diff': 'text/plain',
    61     'diff': 'text/plain',
    59   'gif': 'image/gif',
    62     'gif': 'image/gif',
    60   'htm': 'text/html',
    63     'htm': 'text/html',
    61   'html': 'text/html',
    64     'html': 'text/html',
    62   'ics': 'text/calendar',
    65     'ics': 'text/calendar',
    63   'jpe': 'image/jpeg',
    66     'jpe': 'image/jpeg',
    64   'jpeg': 'image/jpeg',
    67     'jpeg': 'image/jpeg',
    65   'jpg': 'image/jpeg',
    68     'jpg': 'image/jpeg',
    66   'pdf': 'application/pdf',
    69     'pdf': 'application/pdf',
    67   'png': 'image/png',
    70     'png': 'image/png',
    68   'pot': 'text/plain',
    71     'pot': 'text/plain',
    69   'rss': 'text/rss+xml',
    72     'rss': 'text/rss+xml',
    70   'text': 'text/plain',
    73     'text': 'text/plain',
    71   'tif': 'image/tiff',
    74     'tif': 'image/tiff',
    72   'tiff': 'image/tiff',
    75     'tiff': 'image/tiff',
    73   'txt': 'text/plain',
    76     'txt': 'text/plain',
    74   'vcf': 'text/directory',
    77     'vcf': 'text/directory',
    75   'wbmp': 'image/vnd.wap.wbmp',
    78     'wbmp': 'image/vnd.wap.wbmp',
    76 }
    79     }
    77 
    80 
    78 EXTENSION_WHITELIST = frozenset(EXTENSION_MIME_MAP.iterkeys())
    81 EXTENSION_WHITELIST = frozenset(EXTENSION_MIME_MAP.iterkeys())
    79 
    82 
    80 
    83 
    81 def invalid_email_reason(email_address, field):
    84 def invalid_email_reason(email_address, field):
    82   """Determine reason why email is invalid
    85   """Determine reason why email is invalid.
    83 
    86 
    84   Args:
    87   Args:
    85     email_address: Email to check.
    88     email_address: Email to check.
       
    89     field: Field that is invalid.
    86 
    90 
    87   Returns:
    91   Returns:
    88     String indicating invalid email reason if there is one,
    92     String indicating invalid email reason if there is one,
    89     else None.
    93     else None.
    90   """
    94   """
    91   if email_address is None:
    95   if email_address is None:
    92     return 'None email address for %s.' % field
    96     return 'None email address for %s.' % field
    93 
    97 
    94   if isinstance(email_address, users.User):
    98   if isinstance(email_address, users.User):
    95     email_address = email_address.email()
    99     email_address = email_address.email()
    96   if not isinstance(email_address, types.StringTypes):
   100   if not isinstance(email_address, basestring):
    97     return 'Invalid email address type for %s.' % field
   101     return 'Invalid email address type for %s.' % field
    98   stripped_address = email_address.strip()
   102   stripped_address = email_address.strip()
    99   if not stripped_address:
   103   if not stripped_address:
   100     return 'Empty email address for %s.' % field
   104     return 'Empty email address for %s.' % field
   101   return None
   105   return None
   116 
   120 
   117 IsEmailValid = is_email_valid
   121 IsEmailValid = is_email_valid
   118 
   122 
   119 
   123 
   120 def check_email_valid(email_address, field):
   124 def check_email_valid(email_address, field):
   121   """Check that email is valid
   125   """Check that email is valid.
   122 
   126 
   123   Args:
   127   Args:
   124     email_address: Email to check.
   128     email_address: Email to check.
       
   129     field: Field to check.
   125 
   130 
   126   Raises:
   131   Raises:
   127     InvalidEmailError if email_address is invalid.
   132     InvalidEmailError if email_address is invalid.
   128   """
   133   """
   129   reason = invalid_email_reason(email_address, field)
   134   reason = invalid_email_reason(email_address, field)
   163 
   168 
   164   Returns:
   169   Returns:
   165     Single tuple with email in it if only one email string provided,
   170     Single tuple with email in it if only one email string provided,
   166     else returns emails as is.
   171     else returns emails as is.
   167   """
   172   """
   168   if isinstance(emails, types.StringTypes):
   173   if isinstance(emails, basestring):
   169     return emails,
   174     return emails,
   170   return emails
   175   return emails
   171 
   176 
   172 
   177 
   173 def _attachment_sequence(attachments):
   178 def _attachment_sequence(attachments):
   181 
   186 
   182   Returns:
   187   Returns:
   183     Single tuple with attachment tuple in it if only one attachment provided,
   188     Single tuple with attachment tuple in it if only one attachment provided,
   184     else returns attachments as is.
   189     else returns attachments as is.
   185   """
   190   """
   186   if len(attachments) == 2 and isinstance(attachments[0], types.StringTypes):
   191   if len(attachments) == 2 and isinstance(attachments[0], basestring):
   187     return attachments,
   192     return attachments,
   188   return attachments
   193   return attachments
       
   194 
       
   195 
       
   196 def _parse_mime_message(mime_message):
       
   197   """Helper function converts a mime_message in to email.Message.Message.
       
   198 
       
   199   Args:
       
   200     mime_message: MIME Message, string or file containing mime message.
       
   201 
       
   202   Returns:
       
   203     Instance of email.Message.Message.  Will return mime_message if already
       
   204     an instance.
       
   205   """
       
   206   if isinstance(mime_message, email.Message.Message):
       
   207     return mime_message
       
   208   elif isinstance(mime_message, basestring):
       
   209     return email.message_from_string(mime_message)
       
   210   else:
       
   211     return email.message_from_file(mime_message)
   189 
   212 
   190 
   213 
   191 def send_mail(sender,
   214 def send_mail(sender,
   192               to,
   215               to,
   193               subject,
   216               subject,
   283 
   306 
   284   Multiple entry email fields such as 'To', 'Cc' and 'Bcc' are converted
   307   Multiple entry email fields such as 'To', 'Cc' and 'Bcc' are converted
   285   to a list of comma separated email addresses.
   308   to a list of comma separated email addresses.
   286 
   309 
   287   Args:
   310   Args:
   288     message: Message PB to convert to MIMEMultitype.
   311     protocol_message: Message PB to convert to MIMEMultitype.
   289 
   312 
   290   Returns:
   313   Returns:
   291     MIMEMultitype representing the provided MailMessage.
   314     MIMEMultitype representing the provided MailMessage.
   292 
   315 
   293   Raises:
   316   Raises:
   332 
   355 
   333 MailMessageToMIMEMessage = mail_message_to_mime_message
   356 MailMessageToMIMEMessage = mail_message_to_mime_message
   334 
   357 
   335 
   358 
   336 def _to_str(value):
   359 def _to_str(value):
   337   """Helper function to make sure unicode values converted to utf-8
   360   """Helper function to make sure unicode values converted to utf-8.
   338 
   361 
   339   Args:
   362   Args:
   340     value: str or unicode to convert to utf-8.
   363     value: str or unicode to convert to utf-8.
   341 
   364 
   342   Returns:
   365   Returns:
   344   """
   367   """
   345   if isinstance(value, unicode):
   368   if isinstance(value, unicode):
   346     return value.encode('utf-8')
   369     return value.encode('utf-8')
   347   return value
   370   return value
   348 
   371 
       
   372 
       
   373 class EncodedPayload(object):
       
   374   """Wrapper for a payload that contains encoding information.
       
   375 
       
   376   When an email is recieved, it is usually encoded using a certain
       
   377   character set, and then possibly further encoded using a transfer
       
   378   encoding in that character set.  Most of the times, it is possible
       
   379   to decode the encoded payload as is, however, in the case where it
       
   380   is not, the encoded payload and the original encoding information
       
   381   must be preserved.
       
   382 
       
   383   Attributes:
       
   384     payload: The original encoded payload.
       
   385     charset: The character set of the encoded payload.  None means use
       
   386       default character set.
       
   387     encoding: The transfer encoding of the encoded payload.  None means
       
   388       content not encoded.
       
   389   """
       
   390 
       
   391   def __init__(self, payload, charset=None, encoding=None):
       
   392     """Constructor.
       
   393 
       
   394     Args:
       
   395       payload: Maps to attribute of the same name.
       
   396       charset: Maps to attribute of the same name.
       
   397       encoding: Maps to attribute of the same name.
       
   398     """
       
   399     self.payload = payload
       
   400     self.charset = charset
       
   401     self.encoding = encoding
       
   402 
       
   403   def decode(self):
       
   404     """Attempt to decode the encoded data.
       
   405 
       
   406     Attempt to use pythons codec library to decode the payload.  All
       
   407     exceptions are passed back to the caller.
       
   408 
       
   409     Returns:
       
   410       Binary or unicode version of payload content.
       
   411     """
       
   412     payload = self.payload
       
   413 
       
   414     if self.encoding and self.encoding.lower() != '7bit':
       
   415       try:
       
   416         payload = payload.decode(self.encoding).lower()
       
   417       except LookupError:
       
   418         raise UnknownEncodingError('Unknown decoding %s.' % self.encoding)
       
   419       except (Exception, Error), e:
       
   420         raise PayloadEncodingError('Could not decode payload: %s' % e)
       
   421 
       
   422     if self.charset and str(self.charset).lower() != '7bit':
       
   423       try:
       
   424         payload = payload.decode(str(self.charset)).lower()
       
   425       except LookupError:
       
   426         raise UnknownCharsetError('Unknown charset %s.' % self.charset)
       
   427       except (Exception, Error), e:
       
   428         raise PayloadEncodingError('Could read characters: %s' % e)
       
   429 
       
   430     return payload
       
   431 
       
   432   def __eq__(self, other):
       
   433     """Equality operator.
       
   434 
       
   435     Args:
       
   436       other: The other EncodedPayload object to compare with.  Comparison
       
   437         with other object types are not implemented.
       
   438 
       
   439     Returns:
       
   440       True of payload and encodings are equal, else false.
       
   441     """
       
   442     if isinstance(other, EncodedPayload):
       
   443       return (self.payload == other.payload and
       
   444               self.charset == other.charset and
       
   445               self.encoding == other.encoding)
       
   446     else:
       
   447       return NotImplemented
       
   448 
       
   449   def copy_to(self, mime_message):
       
   450     """Copy contents to MIME message payload.
       
   451 
       
   452     If no content transfer encoding is specified, and the character set does
       
   453     not equal the over-all message encoding, the payload will be base64
       
   454     encoded.
       
   455 
       
   456     Args:
       
   457       mime_message: Message instance to receive new payload.
       
   458     """
       
   459     if self.encoding:
       
   460       mime_message['content-transfer-encoding'] = self.encoding
       
   461     mime_message.set_payload(self.payload, self.charset)
       
   462 
       
   463   def to_mime_message(self):
       
   464     """Convert to MIME message.
       
   465 
       
   466     Returns:
       
   467       MIME message instance of payload.
       
   468     """
       
   469     mime_message = email.Message.Message()
       
   470     self.copy_to(mime_message)
       
   471     return mime_message
       
   472 
       
   473   def __str__(self):
       
   474     """String representation of encoded message.
       
   475 
       
   476     Returns:
       
   477       MIME encoded representation of encoded payload as an independent message.
       
   478     """
       
   479     return str(self.to_mime_message())
       
   480 
       
   481   def __repr__(self):
       
   482     """Basic representation of encoded payload.
       
   483 
       
   484     Returns:
       
   485       Payload itself is represented by its hash value.
       
   486     """
       
   487     result = '<EncodedPayload payload=#%d' % hash(self.payload)
       
   488     if self.charset:
       
   489       result += ' charset=%s' % self.charset
       
   490     if self.encoding:
       
   491       result += ' encoding=%s' % self.encoding
       
   492     return result + '>'
       
   493 
       
   494 
   349 class _EmailMessageBase(object):
   495 class _EmailMessageBase(object):
   350   """Base class for email API service objects.
   496   """Base class for email API service objects.
   351 
   497 
   352   Subclasses must define a class variable called _API_CALL with the name
   498   Subclasses must define a class variable called _API_CALL with the name
   353   of its underlying mail sending API call.
   499   of its underlying mail sending API call.
   354   """
   500   """
   355 
   501 
   356   PROPERTIES = set([
   502   PROPERTIES = set([
   357     'sender',
   503       'sender',
   358     'reply_to',
   504       'reply_to',
   359     'subject',
   505       'subject',
   360     'body',
   506       'body',
   361     'html',
   507       'html',
   362     'attachments',
   508       'attachments',
   363   ])
   509   ])
   364 
   510 
   365   def __init__(self, **kw):
   511   PROPERTIES.update(('to', 'cc', 'bcc'))
       
   512 
       
   513   def __init__(self, mime_message=None, **kw):
   366     """Initialize Email message.
   514     """Initialize Email message.
   367 
   515 
   368     Creates new MailMessage protocol buffer and initializes it with any
   516     Creates new MailMessage protocol buffer and initializes it with any
   369     keyword arguments.
   517     keyword arguments.
   370 
   518 
   371     Args:
   519     Args:
       
   520       mime_message: MIME message to initialize from.  If instance of
       
   521         email.Message.Message will take ownership as original message.
   372       kw: List of keyword properties as defined by PROPERTIES.
   522       kw: List of keyword properties as defined by PROPERTIES.
   373     """
   523     """
       
   524     if mime_message:
       
   525       mime_message = _parse_mime_message(mime_message)
       
   526       self.update_from_mime_message(mime_message)
       
   527       self.__original = mime_message
       
   528 
   374     self.initialize(**kw)
   529     self.initialize(**kw)
       
   530 
       
   531   @property
       
   532   def original(self):
       
   533     """Get original MIME message from which values were set."""
       
   534     return self.__original
   375 
   535 
   376   def initialize(self, **kw):
   536   def initialize(self, **kw):
   377     """Keyword initialization.
   537     """Keyword initialization.
   378 
   538 
   379     Used to set all fields of the email message using keyword arguments.
   539     Used to set all fields of the email message using keyword arguments.
   396     multi value fields:
   556     multi value fields:
   397 
   557 
   398       - Subject must be set.
   558       - Subject must be set.
   399       - A recipient must be specified.
   559       - A recipient must be specified.
   400       - Must contain a body.
   560       - Must contain a body.
       
   561       - All bodies and attachments must decode properly.
   401 
   562 
   402     This check does not include determining if the sender is actually
   563     This check does not include determining if the sender is actually
   403     authorized to send email for the application.
   564     authorized to send email for the application.
   404 
   565 
   405     Raises:
   566     Raises:
   408         InvalidAttachmentTypeError: Use of incorrect attachment type.
   569         InvalidAttachmentTypeError: Use of incorrect attachment type.
   409         MissingRecipientsError:     No recipients specified in to, cc or bcc.
   570         MissingRecipientsError:     No recipients specified in to, cc or bcc.
   410         MissingSenderError:         No sender specified.
   571         MissingSenderError:         No sender specified.
   411         MissingSubjectError:        Subject is not specified.
   572         MissingSubjectError:        Subject is not specified.
   412         MissingBodyError:           No body specified.
   573         MissingBodyError:           No body specified.
       
   574         PayloadEncodingError:       Payload is not properly encoded.
       
   575         UnknownEncodingError:       Payload has unknown encoding.
       
   576         UnknownCharsetError:        Payload has unknown character set.
   413     """
   577     """
   414     if not hasattr(self, 'sender'):
   578     if not hasattr(self, 'sender'):
   415       raise MissingSenderError()
   579       raise MissingSenderError()
   416     if not hasattr(self, 'subject'):
   580     if not hasattr(self, 'subject'):
   417       raise MissingSubjectError()
   581       raise MissingSubjectError()
   418     if not hasattr(self, 'body') and not hasattr(self, 'html'):
   582 
       
   583     found_body = False
       
   584 
       
   585     try:
       
   586       body = self.body
       
   587     except AttributeError:
       
   588       pass
       
   589     else:
       
   590       if isinstance(body, EncodedPayload):
       
   591         body.decode()
       
   592       found_body = True
       
   593 
       
   594     try:
       
   595       html = self.html
       
   596     except AttributeError:
       
   597       pass
       
   598     else:
       
   599       if isinstance(html, EncodedPayload):
       
   600         html.decode()
       
   601       found_body = True
       
   602 
       
   603     if not found_body:
   419       raise MissingBodyError()
   604       raise MissingBodyError()
       
   605 
   420     if hasattr(self, 'attachments'):
   606     if hasattr(self, 'attachments'):
   421       for file_name, data in _attachment_sequence(self.attachments):
   607       for file_name, data in _attachment_sequence(self.attachments):
   422         _GetMimeType(file_name)
   608         _GetMimeType(file_name)
       
   609 
       
   610         if isinstance(data, EncodedPayload):
       
   611           data.decode()
   423 
   612 
   424   def CheckInitialized(self):
   613   def CheckInitialized(self):
   425     self.check_initialized()
   614     self.check_initialized()
   426 
   615 
   427   def is_initialized(self):
   616   def is_initialized(self):
   446 
   635 
   447     This method is overriden by EmailMessage to support the sender fields.
   636     This method is overriden by EmailMessage to support the sender fields.
   448 
   637 
   449     Returns:
   638     Returns:
   450       MailMessage protocol version of mail message.
   639       MailMessage protocol version of mail message.
       
   640 
       
   641     Raises:
       
   642       Passes through decoding errors that occur when using when decoding
       
   643       EncodedPayload objects.
   451     """
   644     """
   452     self.check_initialized()
   645     self.check_initialized()
   453     message = mail_service_pb.MailMessage()
   646     message = mail_service_pb.MailMessage()
   454     message.set_sender(_to_str(self.sender))
   647     message.set_sender(_to_str(self.sender))
   455 
   648 
   456     if hasattr(self, 'reply_to'):
   649     if hasattr(self, 'reply_to'):
   457       message.set_replyto(_to_str(self.reply_to))
   650       message.set_replyto(_to_str(self.reply_to))
   458     message.set_subject(_to_str(self.subject))
   651     message.set_subject(_to_str(self.subject))
       
   652 
   459     if hasattr(self, 'body'):
   653     if hasattr(self, 'body'):
   460       message.set_textbody(_to_str(self.body))
   654       body = self.body
       
   655       if isinstance(body, EncodedPayload):
       
   656         body = body.decode()
       
   657       message.set_textbody(_to_str(body))
   461     if hasattr(self, 'html'):
   658     if hasattr(self, 'html'):
   462       message.set_htmlbody(_to_str(self.html))
   659       html = self.html
       
   660       if isinstance(html, EncodedPayload):
       
   661         html = html.decode()
       
   662       message.set_htmlbody(_to_str(html))
   463 
   663 
   464     if hasattr(self, 'attachments'):
   664     if hasattr(self, 'attachments'):
   465       for file_name, data in _attachment_sequence(self.attachments):
   665       for file_name, data in _attachment_sequence(self.attachments):
       
   666         if isinstance(data, EncodedPayload):
       
   667           data = data.decode()
   466         attachment = message.add_attachment()
   668         attachment = message.add_attachment()
   467         attachment.set_filename(_to_str(file_name))
   669         attachment.set_filename(_to_str(file_name))
   468         attachment.set_data(_to_str(data))
   670         attachment.set_data(_to_str(data))
   469     return message
   671     return message
   470 
   672 
   483 
   685 
   484       InvalidAttachmentTypeError: Use of incorrect attachment type.
   686       InvalidAttachmentTypeError: Use of incorrect attachment type.
   485       MissingSenderError:         No sender specified.
   687       MissingSenderError:         No sender specified.
   486       MissingSubjectError:        Subject is not specified.
   688       MissingSubjectError:        Subject is not specified.
   487       MissingBodyError:           No body specified.
   689       MissingBodyError:           No body specified.
   488   """
   690     """
   489     return mail_message_to_mime_message(self.ToProto())
   691     return mail_message_to_mime_message(self.ToProto())
   490 
   692 
   491   def ToMIMEMessage(self):
   693   def ToMIMEMessage(self):
   492     return self.to_mime_message()
   694     return self.to_mime_message()
   493 
   695 
   515   def Send(self, *args, **kwds):
   717   def Send(self, *args, **kwds):
   516     self.send(*args, **kwds)
   718     self.send(*args, **kwds)
   517 
   719 
   518   def _check_attachment(self, attachment):
   720   def _check_attachment(self, attachment):
   519     file_name, data = attachment
   721     file_name, data = attachment
   520     if not (isinstance(file_name, types.StringTypes) or
   722     if not (isinstance(file_name, basestring) or
   521             isinstance(data, types.StringTypes)):
   723             isinstance(data, basestring)):
   522       raise TypeError()
   724       raise TypeError()
   523 
   725 
   524   def _check_attachments(self, attachments):
   726   def _check_attachments(self, attachments):
   525     """Checks values going to attachment field.
   727     """Checks values going to attachment field.
   526 
   728 
   532       attachments: Collection of attachment tuples.
   734       attachments: Collection of attachment tuples.
   533 
   735 
   534     Raises:
   736     Raises:
   535       TypeError if values are not string type.
   737       TypeError if values are not string type.
   536     """
   738     """
   537     if len(attachments) == 2 and isinstance(attachments[0], types.StringTypes):
   739     if len(attachments) == 2 and isinstance(attachments[0], basestring):
   538       self._check_attachment(attachments)
   740       self._check_attachment(attachments)
   539     else:
   741     else:
   540       for attachment in attachments:
   742       for attachment in attachments:
   541         self._check_attachment(attachment)
   743         self._check_attachment(attachment)
   542 
   744 
   546     Controls write access to email fields.
   748     Controls write access to email fields.
   547 
   749 
   548     Args:
   750     Args:
   549       attr: Attribute to access.
   751       attr: Attribute to access.
   550       value: New value for field.
   752       value: New value for field.
   551     """
   753 
   552     if attr in ['sender', 'reply_to']:
   754     Raises:
   553       check_email_valid(value, attr)
   755       ValueError: If provided with an empty field.
   554 
   756       AttributeError: If not an allowed assignment field.
   555     if not value:
   757     """
   556       raise ValueError('May not set empty value for \'%s\'' % attr)
   758     if not attr.startswith('_EmailMessageBase'):
   557 
   759       if attr in ['sender', 'reply_to']:
   558     if attr not in self.PROPERTIES:
   760         check_email_valid(value, attr)
   559       raise AttributeError('\'EmailMessage\' has no attribute \'%s\'' % attr)
   761 
   560 
   762       if not value:
   561     if attr == 'attachments':
   763         raise ValueError('May not set empty value for \'%s\'' % attr)
   562       self._check_attachments(value)
   764 
       
   765       if attr not in self.PROPERTIES:
       
   766         raise AttributeError('\'EmailMessage\' has no attribute \'%s\'' % attr)
       
   767 
       
   768       if attr == 'attachments':
       
   769         self._check_attachments(value)
   563 
   770 
   564     super(_EmailMessageBase, self).__setattr__(attr, value)
   771     super(_EmailMessageBase, self).__setattr__(attr, value)
       
   772 
       
   773   def _add_body(self, content_type, payload):
       
   774     """Add body to email from payload.
       
   775 
       
   776     Will overwrite any existing default plain or html body.
       
   777 
       
   778     Args:
       
   779       content_type: Content-type of body.
       
   780       payload: Payload to store body as.
       
   781     """
       
   782     if content_type == 'text/plain':
       
   783       self.body = payload
       
   784     elif content_type == 'text/html':
       
   785       self.html = payload
       
   786 
       
   787   def _update_payload(self, mime_message):
       
   788     """Update payload of mail message from mime_message.
       
   789 
       
   790     This function works recusively when it receives a multipart body.
       
   791     If it receives a non-multi mime object, it will determine whether or
       
   792     not it is an attachment by whether it has a filename or not.  Attachments
       
   793     and bodies are then wrapped in EncodedPayload with the correct charsets and
       
   794     encodings.
       
   795 
       
   796     Args:
       
   797       mime_message: A Message MIME email object.
       
   798     """
       
   799     payload = mime_message.get_payload()
       
   800 
       
   801     if payload:
       
   802       if mime_message.get_content_maintype() == 'multipart':
       
   803         for alternative in payload:
       
   804           self._update_payload(alternative)
       
   805       else:
       
   806         filename = mime_message.get_param('filename',
       
   807                                           header='content-disposition')
       
   808         if not filename:
       
   809           filename = mime_message.get_param('name')
       
   810 
       
   811         payload = EncodedPayload(payload,
       
   812                                  mime_message.get_charset(),
       
   813                                  mime_message['content-transfer-encoding'])
       
   814 
       
   815         if filename:
       
   816           try:
       
   817             attachments = self.attachments
       
   818           except AttributeError:
       
   819             self.attachments = (filename, payload)
       
   820           else:
       
   821             if isinstance(attachments[0], basestring):
       
   822               self.attachments = [attachments]
       
   823               attachments = self.attachments
       
   824             attachments.append((filename, payload))
       
   825         else:
       
   826           self._add_body(mime_message.get_content_type(), payload)
       
   827 
       
   828   def update_from_mime_message(self, mime_message):
       
   829     """Copy information from a mime message.
       
   830 
       
   831     Set information of instance to values of mime message.  This method
       
   832     will only copy values that it finds.  Any missing values will not
       
   833     be copied, nor will they overwrite old values with blank values.
       
   834 
       
   835     This object is not guaranteed to be initialized after this call.
       
   836 
       
   837     Args:
       
   838       mime_message: email.Message instance to copy information from.
       
   839 
       
   840     Returns:
       
   841       MIME Message instance of mime_message argument.
       
   842     """
       
   843     mime_message = _parse_mime_message(mime_message)
       
   844 
       
   845     sender = mime_message['from']
       
   846     if sender:
       
   847       self.sender = sender
       
   848 
       
   849     reply_to = mime_message['reply-to']
       
   850     if reply_to:
       
   851       self.reply_to = reply_to
       
   852 
       
   853     subject = mime_message['subject']
       
   854     if subject:
       
   855       self.subject = subject
       
   856 
       
   857     self._update_payload(mime_message)
       
   858 
       
   859   def bodies(self, content_type=None):
       
   860     """Iterate over all bodies.
       
   861 
       
   862     Yields:
       
   863       Tuple (content_type, payload) for html and body in that order.
       
   864     """
       
   865     if (not content_type or
       
   866         content_type == 'text' or
       
   867         content_type == 'text/html'):
       
   868       try:
       
   869         yield 'text/html', self.html
       
   870       except AttributeError:
       
   871         pass
       
   872 
       
   873     if (not content_type or
       
   874         content_type == 'text' or
       
   875         content_type == 'text/plain'):
       
   876       try:
       
   877         yield 'text/plain', self.body
       
   878       except AttributeError:
       
   879         pass
   565 
   880 
   566 
   881 
   567 class EmailMessage(_EmailMessageBase):
   882 class EmailMessage(_EmailMessageBase):
   568   """Main interface to email API service.
   883   """Main interface to email API service.
   569 
   884 
   590       message.check_initialized()
   905       message.check_initialized()
   591       message.send()
   906       message.send()
   592   """
   907   """
   593 
   908 
   594   _API_CALL = 'Send'
   909   _API_CALL = 'Send'
   595   PROPERTIES = _EmailMessageBase.PROPERTIES
   910   PROPERTIES = set(_EmailMessageBase.PROPERTIES)
   596   PROPERTIES.update(('to', 'cc', 'bcc'))
       
   597 
   911 
   598   def check_initialized(self):
   912   def check_initialized(self):
   599     """Provide additional checks to ensure recipients have been specified.
   913     """Provide additional checks to ensure recipients have been specified.
   600 
   914 
   601     Raises:
   915     Raises:
   627     return message
   941     return message
   628 
   942 
   629   def __setattr__(self, attr, value):
   943   def __setattr__(self, attr, value):
   630     """Provides additional checks on recipient fields."""
   944     """Provides additional checks on recipient fields."""
   631     if attr in ['to', 'cc', 'bcc']:
   945     if attr in ['to', 'cc', 'bcc']:
   632       if isinstance(value, types.StringTypes):
   946       if isinstance(value, basestring):
   633         check_email_valid(value, attr)
   947         check_email_valid(value, attr)
   634       else:
   948       else:
   635         _email_check_and_list(value, attr)
   949         for address in value:
       
   950           check_email_valid(address, attr)
   636 
   951 
   637     super(EmailMessage, self).__setattr__(attr, value)
   952     super(EmailMessage, self).__setattr__(attr, value)
       
   953 
       
   954   def update_from_mime_message(self, mime_message):
       
   955     """Copy information from a mime message.
       
   956 
       
   957     Update fields for recipients.
       
   958 
       
   959     Args:
       
   960       mime_message: email.Message instance to copy information from.
       
   961     """
       
   962     mime_message = _parse_mime_message(mime_message)
       
   963     super(EmailMessage, self).update_from_mime_message(mime_message)
       
   964 
       
   965     to = mime_message.get_all('to')
       
   966     if to:
       
   967       if len(to) == 1:
       
   968         self.to = to[0]
       
   969       else:
       
   970         self.to = to
       
   971 
       
   972     cc = mime_message.get_all('cc')
       
   973     if cc:
       
   974       if len(cc) == 1:
       
   975         self.cc = cc[0]
       
   976       else:
       
   977         self.cc = cc
       
   978 
       
   979     bcc = mime_message.get_all('bcc')
       
   980     if bcc:
       
   981       if len(bcc) == 1:
       
   982         self.bcc = bcc[0]
       
   983       else:
       
   984         self.bcc = bcc
   638 
   985 
   639 
   986 
   640 class AdminEmailMessage(_EmailMessageBase):
   987 class AdminEmailMessage(_EmailMessageBase):
   641   """Interface to sending email messages to all admins via the amil API.
   988   """Interface to sending email messages to all admins via the amil API.
   642 
   989 
   665       message.check_initialized()
  1012       message.check_initialized()
   666       message.send()
  1013       message.send()
   667   """
  1014   """
   668 
  1015 
   669   _API_CALL = 'SendToAdmins'
  1016   _API_CALL = 'SendToAdmins'
       
  1017   __UNUSED_PROPERTIES = set(('to', 'cc', 'bcc'))
       
  1018 
       
  1019   def __setattr__(self, attr, value):
       
  1020     if attr in self.__UNUSED_PROPERTIES:
       
  1021       logging.warning('\'%s\' is not a valid property to set '
       
  1022                       'for AdminEmailMessage.  It is unused.', attr)
       
  1023     super(AdminEmailMessage, self).__setattr__(attr, value)
       
  1024 
       
  1025 
       
  1026 class InboundEmailMessage(EmailMessage):
       
  1027   """Parsed email object as recevied from external source.
       
  1028 
       
  1029   Has a date field and can store any number of additional bodies.  These
       
  1030   additional attributes make the email more flexible as required for
       
  1031   incoming mail, where the developer has less control over the content.
       
  1032 
       
  1033   Example Usage:
       
  1034 
       
  1035     # Read mail message from CGI input.
       
  1036     message = InboundEmailMessage(sys.stdin.read())
       
  1037     logging.info('Received email message from %s at %s',
       
  1038                  message.sender,
       
  1039                  message.date)
       
  1040     enriched_body = list(message.bodies('text/enriched'))[0]
       
  1041     ... Do something with body ...
       
  1042   """
       
  1043 
       
  1044   __HEADER_PROPERTIES = {'date': 'date',
       
  1045                          'message_id': 'message-id',
       
  1046                         }
       
  1047 
       
  1048   PROPERTIES = frozenset(_EmailMessageBase.PROPERTIES |
       
  1049                          set(('alternate_bodies',)) |
       
  1050                          set(__HEADER_PROPERTIES.iterkeys()))
       
  1051 
       
  1052   def update_from_mime_message(self, mime_message):
       
  1053     """Update values from MIME message.
       
  1054 
       
  1055     Copies over date values.
       
  1056 
       
  1057     Args:
       
  1058       mime_message: email.Message instance to copy information from.
       
  1059     """
       
  1060     mime_message = _parse_mime_message(mime_message)
       
  1061     super(InboundEmailMessage, self).update_from_mime_message(mime_message)
       
  1062 
       
  1063     for property, header in InboundEmailMessage.__HEADER_PROPERTIES.iteritems():
       
  1064       value = mime_message[header]
       
  1065       if value:
       
  1066         setattr(self, property, value)
       
  1067 
       
  1068   def _add_body(self, content_type, payload):
       
  1069     """Add body to inbound message.
       
  1070 
       
  1071     Method is overidden to handle incoming messages that have more than one
       
  1072     plain or html bodies or has any unidentified bodies.
       
  1073 
       
  1074     This method will not overwrite existing html and body values.  This means
       
  1075     that when updating, the text and html bodies that are first in the MIME
       
  1076     document order are assigned to the body and html properties.
       
  1077 
       
  1078     Args:
       
  1079       content_type: Content-type of additional body.
       
  1080       payload: Content of additional body.
       
  1081     """
       
  1082     if (content_type == 'text/plain' and not hasattr(self, 'body') or
       
  1083         content_type == 'text/html' and not hasattr(self, 'html')):
       
  1084       super(InboundEmailMessage, self)._add_body(content_type, payload)
       
  1085     else:
       
  1086       try:
       
  1087         alternate_bodies = self.alternate_bodies
       
  1088       except AttributeError:
       
  1089         alternate_bodies = self.alternate_bodies = [(content_type, payload)]
       
  1090       else:
       
  1091         alternate_bodies.append((content_type, payload))
       
  1092 
       
  1093   def bodies(self, content_type=None):
       
  1094     """Iterate over all bodies.
       
  1095 
       
  1096     Args:
       
  1097       content_type: Content type to filter on.  Allows selection of only
       
  1098         specific types of content.  Can be just the base type of the content
       
  1099         type.  For example:
       
  1100           content_type = 'text/html'  # Matches only HTML content.
       
  1101           content_type = 'text'       # Matches text of any kind.
       
  1102 
       
  1103     Yields:
       
  1104       Tuple (content_type, payload) for all bodies of message, including body,
       
  1105       html and all alternate_bodies in that order.
       
  1106     """
       
  1107     main_bodies = super(InboundEmailMessage, self).bodies(content_type)
       
  1108     for payload_type, payload in main_bodies:
       
  1109       yield payload_type, payload
       
  1110 
       
  1111     partial_type = bool(content_type and content_type.find('/') < 0)
       
  1112 
       
  1113     try:
       
  1114       for payload_type, payload in self.alternate_bodies:
       
  1115         if content_type:
       
  1116           if partial_type:
       
  1117             match_type = payload_type.split('/')[0]
       
  1118           else:
       
  1119             match_type = payload_type
       
  1120           match = match_type == content_type
       
  1121         else:
       
  1122           match = True
       
  1123 
       
  1124         if match:
       
  1125           yield payload_type, payload
       
  1126     except AttributeError:
       
  1127       pass