| #!/usr/bin/env python |
| # |
| # Copyright 2007 Google Inc. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| |
| |
| |
| """Sends email on behalf of application. |
| |
| Provides functions for application developers to provide email services |
| for their applications. Also provides a few utility methods. |
| """ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import email |
| from email import MIMEBase |
| from email import MIMEMultipart |
| from email import MIMEText |
| from email import Parser |
| import email.header |
| import logging |
| |
| from google.appengine.api import api_base_pb |
| from google.appengine.api import apiproxy_stub_map |
| from google.appengine.api import mail_service_pb |
| from google.appengine.api import users |
| from google.appengine.api.mail_errors import * |
| from google.appengine.runtime import apiproxy_errors |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ERROR_MAP = { |
| mail_service_pb.MailServiceError.BAD_REQUEST: |
| BadRequestError, |
| |
| mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER: |
| InvalidSenderError, |
| |
| mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE: |
| InvalidAttachmentTypeError, |
| |
| mail_service_pb.MailServiceError.INVALID_HEADER_NAME: |
| InvalidHeaderNameError, |
| } |
| |
| |
| |
| |
| |
| |
| |
| EXTENSION_MIME_MAP = { |
| 'aif': 'audio/x-aiff', |
| 'aifc': 'audio/x-aiff', |
| 'aiff': 'audio/x-aiff', |
| 'asc': 'text/plain', |
| 'au': 'audio/basic', |
| 'avi': 'video/x-msvideo', |
| 'bmp': 'image/x-ms-bmp', |
| 'css': 'text/css', |
| 'csv': 'text/csv', |
| 'doc': 'application/msword', |
| 'docx': 'application/msword', |
| 'diff': 'text/plain', |
| 'flac': 'audio/flac', |
| 'gif': 'image/gif', |
| 'gzip': 'application/x-gzip', |
| 'htm': 'text/html', |
| 'html': 'text/html', |
| 'ics': 'text/calendar', |
| 'jpe': 'image/jpeg', |
| 'jpeg': 'image/jpeg', |
| 'jpg': 'image/jpeg', |
| 'kml': 'application/vnd.google-earth.kml+xml', |
| 'kmz': 'application/vnd.google-earth.kmz', |
| 'm4a': 'audio/mp4', |
| 'mid': 'audio/mid', |
| 'mov': 'video/quicktime', |
| 'mp3': 'audio/mpeg', |
| 'mp4': 'video/mp4', |
| 'mpe': 'video/mpeg', |
| 'mpeg': 'video/mpeg', |
| 'mpg': 'video/mpeg', |
| 'odp': 'application/vnd.oasis.opendocument.presentation', |
| 'ods': 'application/vnd.oasis.opendocument.spreadsheet', |
| 'odt': 'application/vnd.oasis.opendocument.text', |
| 'oga': 'audio/ogg', |
| 'ogg': 'audio/ogg', |
| 'ogv': 'video/ogg', |
| 'pdf': 'application/pdf', |
| 'png': 'image/png', |
| 'pot': 'text/plain', |
| 'pps': 'application/vnd.ms-powerpoint', |
| 'ppt': 'application/vnd.ms-powerpoint', |
| 'pptx': 'application/vnd.ms-powerpoint', |
| 'qt': 'video/quicktime', |
| 'rmi': 'audio/mid', |
| 'rss': 'text/rss+xml', |
| 'snd': 'audio/basic', |
| 'sxc': 'application/vnd.sun.xml.calc', |
| 'sxw': 'application/vnd.sun.xml.writer', |
| 'text': 'text/plain', |
| 'tif': 'image/tiff', |
| 'tiff': 'image/tiff', |
| 'txt': 'text/plain', |
| 'vcf': 'text/directory', |
| 'wav': 'audio/x-wav', |
| 'wbmp': 'image/vnd.wap.wbmp', |
| 'webm': 'video/webm', |
| 'webp': 'image/webp', |
| 'xls': 'application/vnd.ms-excel', |
| 'xlsx': 'application/vnd.ms-excel', |
| 'zip': 'application/zip' |
| } |
| |
| |
| |
| |
| |
| EXTENSION_BLACKLIST = [ |
| 'ade', |
| 'adp', |
| 'bat', |
| 'chm', |
| 'cmd', |
| 'com', |
| 'cpl', |
| 'exe', |
| 'hta', |
| 'ins', |
| 'isp', |
| 'jse', |
| 'lib', |
| 'mde', |
| 'msc', |
| 'msp', |
| 'mst', |
| 'pif', |
| 'scr', |
| 'sct', |
| 'shb', |
| 'sys', |
| 'vb', |
| 'vbe', |
| 'vbs', |
| 'vxd', |
| 'wsc', |
| 'wsf', |
| 'wsh', |
| ] |
| |
| |
| HEADER_WHITELIST = frozenset([ |
| 'Auto-Submitted', |
| 'In-Reply-To', |
| 'List-Id', |
| 'List-Unsubscribe', |
| 'On-Behalf-Of', |
| 'References', |
| 'Resent-Date', |
| 'Resent-From', |
| 'Resent-To', |
| ]) |
| |
| |
| def invalid_email_reason(email_address, field): |
| """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, |
| else None. |
| """ |
| if email_address is None: |
| return 'None email address for %s.' % field |
| |
| if isinstance(email_address, users.User): |
| email_address = email_address.email() |
| if not isinstance(email_address, basestring): |
| return 'Invalid email address type for %s.' % field |
| stripped_address = email_address.strip() |
| if not stripped_address: |
| return 'Empty email address for %s.' % field |
| return None |
| |
| |
| InvalidEmailReason = invalid_email_reason |
| |
| |
| def is_email_valid(email_address): |
| """Determine if email is invalid. |
| |
| Args: |
| email_address: Email to check. |
| |
| Returns: |
| True if email is valid, else False. |
| """ |
| return invalid_email_reason(email_address, '') is None |
| |
| |
| IsEmailValid = is_email_valid |
| |
| |
| def check_email_valid(email_address, field): |
| """Check that email is valid. |
| |
| Args: |
| email_address: Email to check. |
| field: Field to check. |
| |
| Raises: |
| InvalidEmailError if email_address is invalid. |
| """ |
| reason = invalid_email_reason(email_address, field) |
| if reason is not None: |
| raise InvalidEmailError(reason) |
| |
| |
| CheckEmailValid = check_email_valid |
| |
| |
| def is_ascii(string): |
| """Return whether a string is in ascii.""" |
| return all(ord(c) < 128 for c in string) |
| |
| |
| def invalid_headers_reason(headers): |
| """Determine reason why headers is invalid. |
| |
| Args: |
| headers: headers value to check. |
| |
| Returns: |
| String indicating invalid headers reason if there is one, |
| else None. |
| """ |
| if headers is None: |
| return 'Headers dictionary was None.' |
| if not isinstance(headers, dict): |
| return 'Invalid type for headers. Should be a dictionary.' |
| for k, v in headers.iteritems(): |
| if not isinstance(k, basestring): |
| return 'Header names should be strings.' |
| if not isinstance(v, basestring): |
| return 'Header values should be strings.' |
| if not is_ascii(k): |
| return 'Header name should be an ASCII string.' |
| |
| if k.strip() not in HEADER_WHITELIST: |
| return 'Header "%s" is not allowed.' % k.strip() |
| |
| |
| def check_headers_valid(headers): |
| """Check that headers is a valid dictionary for headers. |
| |
| Args: |
| headers: the value to check for the headers. |
| |
| Raises: |
| InvalidEmailError if headers is invalid. |
| """ |
| reason = invalid_headers_reason(headers) |
| if reason is not None: |
| raise InvalidEmailError(reason) |
| |
| |
| |
| def _email_check_and_list(emails, field): |
| """Generate a list of emails. |
| |
| Args: |
| emails: Single email or list of emails. |
| |
| Returns: |
| Sequence of email addresses. |
| |
| Raises: |
| InvalidEmailError if any email addresses are invalid. |
| """ |
| if isinstance(emails, types.StringTypes): |
| check_email_valid(value) |
| else: |
| for address in iter(emails): |
| check_email_valid(address, field) |
| |
| |
| def _email_sequence(emails): |
| """Forces email to be sequenceable type. |
| |
| Iterable values are returned as is. This function really just wraps the case |
| where there is a single email string. |
| |
| Args: |
| emails: Emails (or email) to coerce to sequence. |
| |
| Returns: |
| Single tuple with email in it if only one email string provided, |
| else returns emails as is. |
| """ |
| if isinstance(emails, basestring): |
| return emails, |
| return emails |
| |
| |
| def _attachment_sequence(attachments): |
| """Forces attachments to be sequenceable type. |
| |
| Iterable values are returned as is. This function really just wraps the case |
| where there is a single attachment. |
| |
| Args: |
| attachments: Attachments (or attachment) to coerce to sequence. |
| |
| Returns: |
| 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], basestring): |
| attachments = attachments, |
| for attachment in attachments: |
| if isinstance(attachment, Attachment): |
| yield attachment |
| else: |
| yield Attachment(*attachment) |
| |
| 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, |
| body, |
| make_sync_call=apiproxy_stub_map.MakeSyncCall, |
| **kw): |
| """Sends mail on behalf of application. |
| |
| Args: |
| sender: Sender email address as appears in the 'from' email line. |
| to: List of 'to' addresses or a single address. |
| subject: Message subject string. |
| body: Body of type text/plain. |
| make_sync_call: Function used to make sync call to API proxy. |
| kw: Keyword arguments compatible with EmailMessage keyword based |
| constructor. |
| |
| Raises: |
| InvalidEmailError when invalid email address provided. |
| """ |
| kw['sender'] = sender |
| kw['to'] = to |
| kw['subject'] = subject |
| kw['body'] = body |
| message = EmailMessage(**kw) |
| message.send(make_sync_call) |
| |
| |
| SendMail = send_mail |
| |
| |
| def send_mail_to_admins(sender, |
| subject, |
| body, |
| make_sync_call=apiproxy_stub_map.MakeSyncCall, |
| **kw): |
| """Sends mail to admins on behalf of application. |
| |
| Args: |
| sender: Sender email address as appears in the 'from' email line. |
| subject: Message subject string. |
| body: Body of type text/plain. |
| make_sync_call: Function used to make sync call to API proxy. |
| kw: Keyword arguments compatible with EmailMessage keyword based |
| constructor. |
| |
| Raises: |
| InvalidEmailError when invalid email address provided. |
| """ |
| kw['sender'] = sender |
| kw['subject'] = subject |
| kw['body'] = body |
| message = AdminEmailMessage(**kw) |
| message.send(make_sync_call) |
| |
| |
| SendMailToAdmins = send_mail_to_admins |
| |
| |
| def _GetMimeType(file_name): |
| """Determine mime-type from file name. |
| |
| Parses file name and determines mime-type based on extension map. |
| |
| This method is not part of the public API and should not be used by |
| applications. |
| |
| Args: |
| file_name: File to determine extension for. |
| |
| Returns: |
| Mime-type associated with file extension. |
| |
| Raises: |
| InvalidAttachmentTypeError when the file name of an attachment. |
| """ |
| extension_index = file_name.rfind('.') |
| if extension_index == -1: |
| extension = '' |
| else: |
| extension = file_name[extension_index + 1:].lower() |
| if extension in EXTENSION_BLACKLIST: |
| raise InvalidAttachmentTypeError( |
| 'Extension %s is not supported.' % extension) |
| mime_type = EXTENSION_MIME_MAP.get(extension, None) |
| if mime_type is None: |
| mime_type = 'application/octet-stream' |
| return mime_type |
| |
| |
| def _GuessCharset(text): |
| """Guess the charset of a text. |
| |
| Args: |
| text: a string (str) that is either a us-ascii string or a unicode that was |
| encoded in utf-8. |
| Returns: |
| Charset needed by the string, either 'us-ascii' or 'utf-8'. |
| """ |
| try: |
| text.decode('us-ascii') |
| return 'us-ascii' |
| except UnicodeDecodeError: |
| return 'utf-8' |
| |
| |
| def _I18nHeader(text): |
| """Creates a header properly encoded even with unicode content. |
| |
| Args: |
| text: a string (str) that is either a us-ascii string or a unicode that was |
| encoded in utf-8. |
| Returns: |
| email.header.Header |
| """ |
| charset = _GuessCharset(text) |
| return email.header.Header(text, charset, maxlinelen=1e3000) |
| |
| |
| def mail_message_to_mime_message(protocol_message): |
| """Generate a MIMEMultitype message from protocol buffer. |
| |
| Generates a complete MIME multi-part email object from a MailMessage |
| protocol buffer. The body fields are sent as individual alternatives |
| if they are both present, otherwise, only one body part is sent. |
| |
| Multiple entry email fields such as 'To', 'Cc' and 'Bcc' are converted |
| to a list of comma separated email addresses. |
| |
| Args: |
| protocol_message: Message PB to convert to MIMEMultitype. |
| |
| Returns: |
| MIMEMultitype representing the provided MailMessage. |
| |
| Raises: |
| InvalidAttachmentTypeError when the file name of an attachment |
| """ |
| parts = [] |
| if protocol_message.has_textbody(): |
| parts.append(MIMEText.MIMEText( |
| protocol_message.textbody(), |
| _charset=_GuessCharset(protocol_message.textbody()))) |
| if protocol_message.has_htmlbody(): |
| parts.append(MIMEText.MIMEText( |
| protocol_message.htmlbody(), _subtype='html', |
| _charset=_GuessCharset(protocol_message.htmlbody()))) |
| |
| if len(parts) == 1: |
| |
| payload = parts |
| else: |
| |
| payload = [MIMEMultipart.MIMEMultipart('alternative', _subparts=parts)] |
| |
| result = MIMEMultipart.MIMEMultipart(_subparts=payload) |
| |
| for attachment in protocol_message.attachment_list(): |
| file_name = attachment.filename() |
| mime_type = _GetMimeType(file_name) |
| maintype, subtype = mime_type.split('/') |
| mime_attachment = MIMEBase.MIMEBase(maintype, subtype) |
| mime_attachment.add_header('Content-Disposition', |
| 'attachment', |
| filename=attachment.filename()) |
| mime_attachment.set_payload(attachment.data()) |
| if attachment.has_contentid(): |
| mime_attachment['content-id'] = attachment.contentid() |
| result.attach(mime_attachment) |
| |
| |
| if protocol_message.to_size(): |
| result['To'] = _I18nHeader(', '.join(protocol_message.to_list())) |
| if protocol_message.cc_size(): |
| result['Cc'] = _I18nHeader(', '.join(protocol_message.cc_list())) |
| if protocol_message.bcc_size(): |
| result['Bcc'] = _I18nHeader(', '.join(protocol_message.bcc_list())) |
| |
| result['From'] = _I18nHeader(protocol_message.sender()) |
| result['Reply-To'] = _I18nHeader(protocol_message.replyto()) |
| result['Subject'] = _I18nHeader(protocol_message.subject()) |
| |
| for header in protocol_message.header_list(): |
| result[header.name()] = _I18nHeader(header.value()) |
| |
| return result |
| |
| |
| MailMessageToMIMEMessage = mail_message_to_mime_message |
| |
| |
| def _to_str(value): |
| """Helper function to make sure unicode values converted to utf-8. |
| |
| Args: |
| value: str or unicode to convert to utf-8. |
| |
| Returns: |
| UTF-8 encoded str of value, otherwise value unchanged. |
| """ |
| if isinstance(value, unicode): |
| return value.encode('utf-8') |
| return value |
| |
| |
| def _decode_and_join_header(header, separator=u' '): |
| """Helper function to decode RFC2047 encoded headers. |
| |
| Args: |
| header: RFC2047 encoded str (or just a plain old str) to convert to unicode. |
| separator: The separator to use when joining separately encoded pieces of |
| the header. |
| |
| Returns: |
| unicode of decoded header or just header if it was None or ''. |
| """ |
| if not header: |
| |
| return header |
| return separator.join(unicode(s, c or 'us-ascii') |
| for s, c in email.header.decode_header(header)) |
| |
| |
| def _decode_address_list_field(address_list): |
| """Helper function to decode (potentially RFC2047 encoded) address lists. |
| |
| Args: |
| address_list: a single str header, or list of str headers. |
| |
| Returns: |
| unicode of decoded header or list of str headers. |
| """ |
| if not address_list: |
| return None |
| |
| if len(address_list) == 1: |
| return _decode_and_join_header(address_list[0]) |
| else: |
| return map(_decode_and_join_header, address_list) |
| |
| |
| |
| def wrapping(wrapped): |
| |
| |
| |
| |
| def wrapping_wrapper(wrapper): |
| try: |
| wrapper.__wrapped__ = wrapped |
| wrapper.__name__ = wrapped.__name__ |
| wrapper.__doc__ = wrapped.__doc__ |
| wrapper.__dict__.update(wrapped.__dict__) |
| except Exception: |
| pass |
| return wrapper |
| return wrapping_wrapper |
| |
| |
| |
| def _positional(max_pos_args): |
| """A decorator to declare that only the first N arguments may be positional. |
| |
| Note that for methods, n includes 'self'. |
| """ |
| def positional_decorator(wrapped): |
| @wrapping(wrapped) |
| def positional_wrapper(*args, **kwds): |
| if len(args) > max_pos_args: |
| plural_s = '' |
| if max_pos_args != 1: |
| plural_s = 's' |
| raise TypeError( |
| '%s() takes at most %d positional argument%s (%d given)' % |
| (wrapped.__name__, max_pos_args, plural_s, len(args))) |
| return wrapped(*args, **kwds) |
| return positional_wrapper |
| return positional_decorator |
| |
| |
| class Attachment(object): |
| """Attachment object. |
| |
| Subclasses tuple to retain compatibility with existing code. An Attachment |
| object is largely interchangeable with a (filename, payload) tuple. |
| |
| Note that the behavior is a bit asymmetric with respect to unpacking and |
| equality comparison. An Attachment object without a content ID will be |
| equivalent to a (filename, payload) tuple. An Attachment with a content ID |
| will unpack to a (filename, payload) tuple, but will compare unequally to |
| that tuple. |
| |
| Thus, the following comparison will succeed: |
| |
| attachment = mail.Attachment('foo.jpg', 'data') |
| filename, payload = attachment |
| attachment == filename, payload |
| |
| ...while the following will fail: |
| |
| attachment = mail.Attachment('foo.jpg', 'data', content_id='<foo>') |
| filename, payload = attachment |
| attachment == filename, payload |
| |
| The following comparison will pass though: |
| |
| attachment = mail.Attachment('foo.jpg', 'data', content_id='<foo>') |
| attachment == (attachment.filename, |
| attachment.payload, |
| attachment.content_id) |
| |
| Attributes: |
| filename: The name of the attachment. |
| payload: The attachment data. |
| content_id: Optional. The content-id for this attachment. Keyword-only. |
| """ |
| |
| @_positional(3) |
| def __init__(self, filename, payload, content_id=None): |
| """Constructor. |
| |
| Arguments: |
| filename: The name of the attachment |
| payload: The attachment data. |
| content_id: Optional. The content-id for this attachment. |
| """ |
| self.filename = filename |
| self.payload = payload |
| self.content_id = content_id |
| |
| def __eq__(self, other): |
| self_tuple = (self.filename, self.payload, self.content_id) |
| if isinstance(other, Attachment): |
| other_tuple = (other.filename, other.payload, other.content_id) |
| |
| |
| elif not hasattr(other, '__len__'): |
| return NotImplemented |
| elif len(other) == 2: |
| other_tuple = other + (None,) |
| elif len(other) == 3: |
| other_tuple = other |
| else: |
| return NotImplemented |
| return self_tuple == other_tuple |
| |
| def __hash__(self): |
| if self.content_id: |
| return hash((self.filename, self.payload, self.content_id)) |
| else: |
| return hash((self.filename, self.payload)) |
| |
| def __ne__(self, other): |
| return not self == other |
| |
| def __iter__(self): |
| return iter((self.filename, self.payload)) |
| |
| |
| class EncodedPayload(object): |
| """Wrapper for a payload that contains encoding information. |
| |
| When an email is received, 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) |
| 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)) |
| 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 __hash__(self): |
| """Hash an EncodedPayload.""" |
| return hash((self.payload, self.charset, self.encoding)) |
| |
| 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. |
| |
| Subclasses must define a class variable called _API_CALL with the name |
| of its underlying mail sending API call. |
| """ |
| |
| |
| PROPERTIES = set([ |
| 'sender', |
| 'reply_to', |
| 'subject', |
| 'body', |
| 'html', |
| 'attachments', |
| ]) |
| |
| ALLOWED_EMPTY_PROPERTIES = set([ |
| 'subject', |
| 'body' |
| ]) |
| |
| |
| |
| 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. |
| |
| Used to set all fields of the email message using keyword arguments. |
| |
| Args: |
| kw: List of keyword properties as defined by PROPERTIES. |
| """ |
| for name, value in kw.iteritems(): |
| setattr(self, name, value) |
| |
| |
| def Initialize(self, **kw): |
| self.initialize(**kw) |
| |
| def check_initialized(self): |
| """Check if EmailMessage is properly initialized. |
| |
| Test used to determine if EmailMessage meets basic requirements |
| for being used with the mail API. This means that the following |
| fields must be set or have at least one value in the case of |
| multi value fields: |
| |
| - 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. |
| |
| Raises: |
| Appropriate exception for initialization failure. |
| |
| InvalidAttachmentTypeError: Use of incorrect attachment type. |
| MissingRecipientsError: No recipients specified in to, cc or bcc. |
| 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() |
| |
| |
| 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 hasattr(self, 'attachments'): |
| for attachment in _attachment_sequence(self.attachments): |
| |
| |
| _GetMimeType(attachment.filename) |
| |
| |
| |
| |
| if isinstance(attachment.payload, EncodedPayload): |
| attachment.payload.decode() |
| |
| |
| def CheckInitialized(self): |
| self.check_initialized() |
| |
| def is_initialized(self): |
| """Determine if EmailMessage is properly initialized. |
| |
| Returns: |
| True if message is properly initializes, otherwise False. |
| """ |
| try: |
| self.check_initialized() |
| return True |
| except Error: |
| return False |
| |
| |
| def IsInitialized(self): |
| return self.is_initialized() |
| |
| def ToProto(self): |
| """Convert mail message to protocol message. |
| |
| Unicode strings are converted to UTF-8 for all fields. |
| |
| This method is overriden by EmailMessage to support the sender fields. |
| |
| 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() |
| message.set_sender(_to_str(self.sender)) |
| |
| if hasattr(self, 'reply_to'): |
| message.set_replyto(_to_str(self.reply_to)) |
| if hasattr(self, 'subject'): |
| message.set_subject(_to_str(self.subject)) |
| else: |
| message.set_subject('') |
| |
| if hasattr(self, 'body'): |
| body = self.body |
| if isinstance(body, EncodedPayload): |
| body = body.decode() |
| message.set_textbody(_to_str(body)) |
| |
| if hasattr(self, 'html'): |
| html = self.html |
| if isinstance(html, EncodedPayload): |
| html = html.decode() |
| message.set_htmlbody(_to_str(html)) |
| |
| if hasattr(self, 'attachments'): |
| for attachment in _attachment_sequence(self.attachments): |
| if isinstance(attachment.payload, EncodedPayload): |
| data = data.decode() |
| protoattachment = message.add_attachment() |
| protoattachment.set_filename(_to_str(attachment.filename)) |
| protoattachment.set_data(_to_str(attachment.payload)) |
| if attachment.content_id: |
| protoattachment.set_contentid(attachment.content_id) |
| return message |
| |
| def to_mime_message(self): |
| """Generate a MIMEMultitype message from EmailMessage. |
| |
| Calls MailMessageToMessage after converting self to protocol |
| buffer. Protocol buffer is better at handing corner cases |
| than EmailMessage class. |
| |
| Returns: |
| MIMEMultitype representing the provided MailMessage. |
| |
| Raises: |
| Appropriate exception for initialization failure. |
| |
| InvalidAttachmentTypeError: Use of incorrect attachment type. |
| MissingSenderError: No sender specified. |
| MissingSubjectError: Subject is not specified. |
| MissingBodyError: No body specified. |
| """ |
| return mail_message_to_mime_message(self.ToProto()) |
| |
| |
| def ToMIMEMessage(self): |
| return self.to_mime_message() |
| |
| def send(self, make_sync_call=apiproxy_stub_map.MakeSyncCall): |
| """Send email message. |
| |
| Send properly initialized email message via email API. |
| |
| Args: |
| make_sync_call: Method which will make synchronous call to api proxy. |
| |
| Raises: |
| Errors defined in this file above. |
| """ |
| message = self.ToProto() |
| response = api_base_pb.VoidProto() |
| |
| try: |
| make_sync_call('mail', self._API_CALL, message, response) |
| except apiproxy_errors.ApplicationError, e: |
| if e.application_error in ERROR_MAP: |
| raise ERROR_MAP[e.application_error](e.error_detail) |
| raise e |
| |
| |
| def Send(self, *args, **kwds): |
| self.send(*args, **kwds) |
| |
| def _check_attachment(self, attachment): |
| |
| if not (isinstance(attachment.filename, basestring) or |
| isinstance(attachment.payload, basestring)): |
| raise TypeError() |
| |
| def _check_attachments(self, attachments): |
| """Checks values going to attachment field. |
| |
| Mainly used to check type safety of the values. Each value of the list |
| must be a pair of the form (file_name, data), and both values a string |
| type. |
| |
| Args: |
| attachments: Collection of attachment tuples. |
| |
| Raises: |
| TypeError if values are not string type. |
| """ |
| attachments = _attachment_sequence(attachments) |
| for attachment in attachments: |
| self._check_attachment(attachment) |
| |
| def __setattr__(self, attr, value): |
| """Property setting access control. |
| |
| Controls write access to email fields. |
| |
| 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 not attr.startswith('_EmailMessageBase'): |
| if attr in ['sender', 'reply_to']: |
| check_email_valid(value, attr) |
| |
| if not value and not attr in self.ALLOWED_EMPTY_PROPERTIES: |
| 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 == '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 filename: |
| |
| filename = email.utils.collapse_rfc2231_value(filename) |
| if not filename: |
| filename = mime_message.get_param('name') |
| |
| |
| payload = EncodedPayload(payload, |
| (mime_message.get_content_charset() or |
| mime_message.get_charset()), |
| mime_message['content-transfer-encoding']) |
| |
| if 'content-id' in mime_message: |
| attachment = Attachment(filename, |
| payload, |
| content_id=mime_message['content-id']) |
| else: |
| attachment = Attachment(filename, payload) |
| |
| if filename: |
| |
| try: |
| attachments = self.attachments |
| except AttributeError: |
| self.attachments = [attachment] |
| else: |
| if isinstance(attachments[0], basestring): |
| self.attachments = [attachments] |
| attachments = self.attachments |
| attachments.append(attachment) |
| 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 = _decode_and_join_header(mime_message['from']) |
| if sender: |
| self.sender = sender |
| |
| reply_to = _decode_and_join_header(mime_message['reply-to']) |
| if reply_to: |
| self.reply_to = reply_to |
| |
| subject = _decode_and_join_header(mime_message['subject'], separator=u'') |
| 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. |
| |
| This class is used to programmatically build an email message to send via |
| the Mail API. The usage is to construct an instance, populate its fields |
| and call Send(). |
| |
| Example Usage: |
| An EmailMessage can be built completely by the constructor. |
| |
| EmailMessage(sender='sender@nowhere.com', |
| to='recipient@nowhere.com', |
| subject='a subject', |
| body='This is an email to you').Send() |
| |
| It might be desirable for an application to build an email in different |
| places throughout the code. For this, EmailMessage is mutable. |
| |
| message = EmailMessage() |
| message.sender = 'sender@nowhere.com' |
| message.to = ['recipient1@nowhere.com', 'recipient2@nowhere.com'] |
| message.subject = 'a subject' |
| message.body = 'This is an email to you') |
| message.check_initialized() |
| message.send() |
| """ |
| |
| _API_CALL = 'Send' |
| PROPERTIES = set(_EmailMessageBase.PROPERTIES | set(('headers',))) |
| |
| def check_initialized(self): |
| """Provide additional checks to ensure recipients have been specified. |
| |
| Raises: |
| MissingRecipientError when no recipients specified in to, cc or bcc. |
| """ |
| if (not hasattr(self, 'to') and |
| not hasattr(self, 'cc') and |
| not hasattr(self, 'bcc')): |
| raise MissingRecipientsError() |
| super(EmailMessage, self).check_initialized() |
| |
| |
| def CheckInitialized(self): |
| self.check_initialized() |
| |
| def ToProto(self): |
| """Does addition conversion of recipient fields to protocol buffer. |
| |
| Returns: |
| MailMessage protocol version of mail message including sender fields. |
| """ |
| message = super(EmailMessage, self).ToProto() |
| |
| for attribute, adder in (('to', message.add_to), |
| ('cc', message.add_cc), |
| ('bcc', message.add_bcc)): |
| if hasattr(self, attribute): |
| for address in _email_sequence(getattr(self, attribute)): |
| adder(_to_str(address)) |
| for name, value in getattr(self, 'headers', {}).iteritems(): |
| header = message.add_header() |
| header.set_name(name) |
| header.set_value(_to_str(value)) |
| return message |
| |
| def __setattr__(self, attr, value): |
| """Provides additional checks on recipient fields.""" |
| |
| if attr in ['to', 'cc', 'bcc']: |
| if isinstance(value, basestring): |
| if value == '' and getattr(self, 'ALLOW_BLANK_EMAIL', False): |
| return |
| check_email_valid(value, attr) |
| else: |
| for address in value: |
| check_email_valid(address, attr) |
| elif attr == 'headers': |
| check_headers_valid(value) |
| |
| 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 = _decode_address_list_field(mime_message.get_all('to')) |
| if to: |
| self.to = to |
| |
| cc = _decode_address_list_field(mime_message.get_all('cc')) |
| if cc: |
| self.cc = cc |
| |
| bcc = _decode_address_list_field(mime_message.get_all('bcc')) |
| if bcc: |
| self.bcc = bcc |
| |
| |
| class AdminEmailMessage(_EmailMessageBase): |
| """Interface to sending email messages to all admins via the amil API. |
| |
| This class is used to programmatically build an admin email message to send |
| via the Mail API. The usage is to construct an instance, populate its fields |
| and call Send(). |
| |
| Unlike the normal email message, addresses in the recipient fields are |
| ignored and not used for sending. |
| |
| Example Usage: |
| An AdminEmailMessage can be built completely by the constructor. |
| |
| AdminEmailMessage(sender='sender@nowhere.com', |
| subject='a subject', |
| body='This is an email to you').Send() |
| |
| It might be desirable for an application to build an admin email in |
| different places throughout the code. For this, AdminEmailMessage is |
| mutable. |
| |
| message = AdminEmailMessage() |
| message.sender = 'sender@nowhere.com' |
| message.subject = 'a subject' |
| message.body = 'This is an email to you') |
| message.check_initialized() |
| message.send() |
| """ |
| |
| _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())) |
| |
| ALLOW_BLANK_EMAIL = True |
| |
| 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 |
| |
| def to_mime_message(self): |
| """Convert to MIME message. |
| |
| Adds additional headers from inbound email. |
| |
| Returns: |
| MIME message instance of payload. |
| """ |
| mime_message = super(InboundEmailMessage, self).to_mime_message() |
| |
| for property, header in InboundEmailMessage.__HEADER_PROPERTIES.iteritems(): |
| try: |
| mime_message[header] = getattr(self, property) |
| except AttributeError: |
| pass |
| |
| return mime_message |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| Parser.Parser |
| |