1.9.30 sync.
[gae.git] / python / google / appengine / api / mail.py
blob3191ee7e15fcfc4af69747a0f0cef64b091d0875
1 #!/usr/bin/env python
3 # Copyright 2007 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
21 """Sends email on behalf of application.
23 Provides functions for application developers to provide email services
24 for their applications. Also provides a few utility methods.
25 """
37 import email
38 from email import MIMEBase
39 from email import MIMEMultipart
40 from email import MIMEText
41 from email import Parser
42 import email.header
43 import logging
45 from google.appengine.api import api_base_pb
46 from google.appengine.api import apiproxy_stub_map
47 from google.appengine.api import mail_service_pb
48 from google.appengine.api import users
49 from google.appengine.api.mail_errors import *
50 from google.appengine.runtime import apiproxy_errors
60 ERROR_MAP = {
61 mail_service_pb.MailServiceError.BAD_REQUEST:
62 BadRequestError,
64 mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER:
65 InvalidSenderError,
67 mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE:
68 InvalidAttachmentTypeError,
70 mail_service_pb.MailServiceError.INVALID_HEADER_NAME:
71 InvalidHeaderNameError,
80 EXTENSION_MIME_MAP = {
81 'aif': 'audio/x-aiff',
82 'aifc': 'audio/x-aiff',
83 'aiff': 'audio/x-aiff',
84 'asc': 'text/plain',
85 'au': 'audio/basic',
86 'avi': 'video/x-msvideo',
87 'bmp': 'image/x-ms-bmp',
88 'css': 'text/css',
89 'csv': 'text/csv',
90 'doc': 'application/msword',
91 'docx': 'application/msword',
92 'diff': 'text/plain',
93 'flac': 'audio/flac',
94 'gif': 'image/gif',
95 'gzip': 'application/x-gzip',
96 'htm': 'text/html',
97 'html': 'text/html',
98 'ics': 'text/calendar',
99 'jpe': 'image/jpeg',
100 'jpeg': 'image/jpeg',
101 'jpg': 'image/jpeg',
102 'kml': 'application/vnd.google-earth.kml+xml',
103 'kmz': 'application/vnd.google-earth.kmz',
104 'm4a': 'audio/mp4',
105 'mid': 'audio/mid',
106 'mov': 'video/quicktime',
107 'mp3': 'audio/mpeg',
108 'mp4': 'video/mp4',
109 'mpe': 'video/mpeg',
110 'mpeg': 'video/mpeg',
111 'mpg': 'video/mpeg',
112 'odp': 'application/vnd.oasis.opendocument.presentation',
113 'ods': 'application/vnd.oasis.opendocument.spreadsheet',
114 'odt': 'application/vnd.oasis.opendocument.text',
115 'oga': 'audio/ogg',
116 'ogg': 'audio/ogg',
117 'ogv': 'video/ogg',
118 'pdf': 'application/pdf',
119 'png': 'image/png',
120 'pot': 'text/plain',
121 'pps': 'application/vnd.ms-powerpoint',
122 'ppt': 'application/vnd.ms-powerpoint',
123 'pptx': 'application/vnd.ms-powerpoint',
124 'qt': 'video/quicktime',
125 'rmi': 'audio/mid',
126 'rss': 'text/rss+xml',
127 'snd': 'audio/basic',
128 'sxc': 'application/vnd.sun.xml.calc',
129 'sxw': 'application/vnd.sun.xml.writer',
130 'text': 'text/plain',
131 'tif': 'image/tiff',
132 'tiff': 'image/tiff',
133 'txt': 'text/plain',
134 'vcf': 'text/directory',
135 'wav': 'audio/x-wav',
136 'wbmp': 'image/vnd.wap.wbmp',
137 'webm': 'video/webm',
138 'webp': 'image/webp',
139 'xls': 'application/vnd.ms-excel',
140 'xlsx': 'application/vnd.ms-excel',
141 'zip': 'application/zip'
148 EXTENSION_BLACKLIST = [
149 'ade',
150 'adp',
151 'bat',
152 'chm',
153 'cmd',
154 'com',
155 'cpl',
156 'exe',
157 'hta',
158 'ins',
159 'isp',
160 'jse',
161 'lib',
162 'mde',
163 'msc',
164 'msp',
165 'mst',
166 'pif',
167 'scr',
168 'sct',
169 'shb',
170 'sys',
171 'vb',
172 'vbe',
173 'vbs',
174 'vxd',
175 'wsc',
176 'wsf',
177 'wsh',
181 HEADER_WHITELIST = frozenset([
182 'Auto-Submitted',
183 'In-Reply-To',
184 'List-Id',
185 'List-Unsubscribe',
186 'On-Behalf-Of',
187 'References',
188 'Resent-Date',
189 'Resent-From',
190 'Resent-To',
194 def invalid_email_reason(email_address, field):
195 """Determine reason why email is invalid.
197 Args:
198 email_address: Email to check.
199 field: Field that is invalid.
201 Returns:
202 String indicating invalid email reason if there is one,
203 else None.
205 if email_address is None:
206 return 'None email address for %s.' % field
208 if isinstance(email_address, users.User):
209 email_address = email_address.email()
210 if not isinstance(email_address, basestring):
211 return 'Invalid email address type for %s.' % field
212 stripped_address = email_address.strip()
213 if not stripped_address:
214 return 'Empty email address for %s.' % field
215 return None
218 InvalidEmailReason = invalid_email_reason
221 def is_email_valid(email_address):
222 """Determine if email is invalid.
224 Args:
225 email_address: Email to check.
227 Returns:
228 True if email is valid, else False.
230 return invalid_email_reason(email_address, '') is None
233 IsEmailValid = is_email_valid
236 def check_email_valid(email_address, field):
237 """Check that email is valid.
239 Args:
240 email_address: Email to check.
241 field: Field to check.
243 Raises:
244 InvalidEmailError if email_address is invalid.
246 reason = invalid_email_reason(email_address, field)
247 if reason is not None:
248 raise InvalidEmailError(reason)
251 CheckEmailValid = check_email_valid
254 def is_ascii(string):
255 """Return whether a string is in ascii."""
256 return all(ord(c) < 128 for c in string)
259 def invalid_headers_reason(headers):
260 """Determine reason why headers is invalid.
262 Args:
263 headers: headers value to check.
265 Returns:
266 String indicating invalid headers reason if there is one,
267 else None.
269 if headers is None:
270 return 'Headers dictionary was None.'
271 if not isinstance(headers, dict):
272 return 'Invalid type for headers. Should be a dictionary.'
273 for k, v in headers.iteritems():
274 if not isinstance(k, basestring):
275 return 'Header names should be strings.'
276 if not isinstance(v, basestring):
277 return 'Header values should be strings.'
278 if not is_ascii(k):
279 return 'Header name should be an ASCII string.'
281 if k.strip() not in HEADER_WHITELIST:
282 return 'Header "%s" is not allowed.' % k.strip()
285 def check_headers_valid(headers):
286 """Check that headers is a valid dictionary for headers.
288 Args:
289 headers: the value to check for the headers.
291 Raises:
292 InvalidEmailError if headers is invalid.
294 reason = invalid_headers_reason(headers)
295 if reason is not None:
296 raise InvalidEmailError(reason)
300 def _email_check_and_list(emails, field):
301 """Generate a list of emails.
303 Args:
304 emails: Single email or list of emails.
306 Returns:
307 Sequence of email addresses.
309 Raises:
310 InvalidEmailError if any email addresses are invalid.
312 if isinstance(emails, types.StringTypes):
313 check_email_valid(value)
314 else:
315 for address in iter(emails):
316 check_email_valid(address, field)
319 def _email_sequence(emails):
320 """Forces email to be sequenceable type.
322 Iterable values are returned as is. This function really just wraps the case
323 where there is a single email string.
325 Args:
326 emails: Emails (or email) to coerce to sequence.
328 Returns:
329 Single tuple with email in it if only one email string provided,
330 else returns emails as is.
332 if isinstance(emails, basestring):
333 return emails,
334 return emails
337 def _attachment_sequence(attachments):
338 """Forces attachments to be sequenceable type.
340 Iterable values are returned as is. This function really just wraps the case
341 where there is a single attachment.
343 Args:
344 attachments: Attachments (or attachment) to coerce to sequence.
346 Returns:
347 Single tuple with attachment tuple in it if only one attachment provided,
348 else returns attachments as is.
350 if len(attachments) == 2 and isinstance(attachments[0], basestring):
351 attachments = attachments,
352 for attachment in attachments:
353 if isinstance(attachment, Attachment):
354 yield attachment
355 else:
356 yield Attachment(*attachment)
358 def _parse_mime_message(mime_message):
359 """Helper function converts a mime_message in to email.Message.Message.
361 Args:
362 mime_message: MIME Message, string or file containing mime message.
364 Returns:
365 Instance of email.Message.Message. Will return mime_message if already
366 an instance.
368 if isinstance(mime_message, email.Message.Message):
369 return mime_message
370 elif isinstance(mime_message, basestring):
371 return email.message_from_string(mime_message)
372 else:
374 return email.message_from_file(mime_message)
377 def send_mail(sender,
379 subject,
380 body,
381 make_sync_call=apiproxy_stub_map.MakeSyncCall,
382 **kw):
383 """Sends mail on behalf of application.
385 Args:
386 sender: Sender email address as appears in the 'from' email line.
387 to: List of 'to' addresses or a single address.
388 subject: Message subject string.
389 body: Body of type text/plain.
390 make_sync_call: Function used to make sync call to API proxy.
391 kw: Keyword arguments compatible with EmailMessage keyword based
392 constructor.
394 Raises:
395 InvalidEmailError when invalid email address provided.
397 kw['sender'] = sender
398 kw['to'] = to
399 kw['subject'] = subject
400 kw['body'] = body
401 message = EmailMessage(**kw)
402 message.send(make_sync_call)
405 SendMail = send_mail
408 def send_mail_to_admins(sender,
409 subject,
410 body,
411 make_sync_call=apiproxy_stub_map.MakeSyncCall,
412 **kw):
413 """Sends mail to admins on behalf of application.
415 Args:
416 sender: Sender email address as appears in the 'from' email line.
417 subject: Message subject string.
418 body: Body of type text/plain.
419 make_sync_call: Function used to make sync call to API proxy.
420 kw: Keyword arguments compatible with EmailMessage keyword based
421 constructor.
423 Raises:
424 InvalidEmailError when invalid email address provided.
426 kw['sender'] = sender
427 kw['subject'] = subject
428 kw['body'] = body
429 message = AdminEmailMessage(**kw)
430 message.send(make_sync_call)
433 SendMailToAdmins = send_mail_to_admins
436 def _GetMimeType(file_name):
437 """Determine mime-type from file name.
439 Parses file name and determines mime-type based on extension map.
441 This method is not part of the public API and should not be used by
442 applications.
444 Args:
445 file_name: File to determine extension for.
447 Returns:
448 Mime-type associated with file extension.
450 Raises:
451 InvalidAttachmentTypeError when the file name of an attachment.
453 extension_index = file_name.rfind('.')
454 if extension_index == -1:
455 extension = ''
456 else:
457 extension = file_name[extension_index + 1:].lower()
458 if extension in EXTENSION_BLACKLIST:
459 raise InvalidAttachmentTypeError(
460 'Extension %s is not supported.' % extension)
461 mime_type = EXTENSION_MIME_MAP.get(extension, None)
462 if mime_type is None:
463 mime_type = 'application/octet-stream'
464 return mime_type
467 def _GuessCharset(text):
468 """Guess the charset of a text.
470 Args:
471 text: a string (str) that is either a us-ascii string or a unicode that was
472 encoded in utf-8.
473 Returns:
474 Charset needed by the string, either 'us-ascii' or 'utf-8'.
476 try:
477 text.decode('us-ascii')
478 return 'us-ascii'
479 except UnicodeDecodeError:
480 return 'utf-8'
483 def _I18nHeader(text):
484 """Creates a header properly encoded even with unicode content.
486 Args:
487 text: a string (str) that is either a us-ascii string or a unicode that was
488 encoded in utf-8.
489 Returns:
490 email.header.Header
492 charset = _GuessCharset(text)
493 return email.header.Header(text, charset, maxlinelen=1e3000)
496 def mail_message_to_mime_message(protocol_message):
497 """Generate a MIMEMultitype message from protocol buffer.
499 Generates a complete MIME multi-part email object from a MailMessage
500 protocol buffer. The body fields are sent as individual alternatives
501 if they are both present, otherwise, only one body part is sent.
503 Multiple entry email fields such as 'To', 'Cc' and 'Bcc' are converted
504 to a list of comma separated email addresses.
506 Args:
507 protocol_message: Message PB to convert to MIMEMultitype.
509 Returns:
510 MIMEMultitype representing the provided MailMessage.
512 Raises:
513 InvalidAttachmentTypeError when the file name of an attachment
515 parts = []
516 if protocol_message.has_textbody():
517 parts.append(MIMEText.MIMEText(
518 protocol_message.textbody(),
519 _charset=_GuessCharset(protocol_message.textbody())))
520 if protocol_message.has_htmlbody():
521 parts.append(MIMEText.MIMEText(
522 protocol_message.htmlbody(), _subtype='html',
523 _charset=_GuessCharset(protocol_message.htmlbody())))
525 if len(parts) == 1:
527 payload = parts
528 else:
530 payload = [MIMEMultipart.MIMEMultipart('alternative', _subparts=parts)]
532 result = MIMEMultipart.MIMEMultipart(_subparts=payload)
534 for attachment in protocol_message.attachment_list():
535 file_name = attachment.filename()
536 mime_type = _GetMimeType(file_name)
537 maintype, subtype = mime_type.split('/')
538 mime_attachment = MIMEBase.MIMEBase(maintype, subtype)
539 mime_attachment.add_header('Content-Disposition',
540 'attachment',
541 filename=attachment.filename())
542 mime_attachment.set_payload(attachment.data())
543 if attachment.has_contentid():
544 mime_attachment['content-id'] = attachment.contentid()
545 result.attach(mime_attachment)
548 if protocol_message.to_size():
549 result['To'] = _I18nHeader(', '.join(protocol_message.to_list()))
550 if protocol_message.cc_size():
551 result['Cc'] = _I18nHeader(', '.join(protocol_message.cc_list()))
552 if protocol_message.bcc_size():
553 result['Bcc'] = _I18nHeader(', '.join(protocol_message.bcc_list()))
555 result['From'] = _I18nHeader(protocol_message.sender())
556 result['Reply-To'] = _I18nHeader(protocol_message.replyto())
557 result['Subject'] = _I18nHeader(protocol_message.subject())
559 for header in protocol_message.header_list():
560 result[header.name()] = _I18nHeader(header.value())
562 return result
565 MailMessageToMIMEMessage = mail_message_to_mime_message
568 def _to_str(value):
569 """Helper function to make sure unicode values converted to utf-8.
571 Args:
572 value: str or unicode to convert to utf-8.
574 Returns:
575 UTF-8 encoded str of value, otherwise value unchanged.
577 if isinstance(value, unicode):
578 return value.encode('utf-8')
579 return value
582 def _decode_and_join_header(header, separator=u' '):
583 """Helper function to decode RFC2047 encoded headers.
585 Args:
586 header: RFC2047 encoded str (or just a plain old str) to convert to unicode.
587 separator: The separator to use when joining separately encoded pieces of
588 the header.
590 Returns:
591 unicode of decoded header or just header if it was None or ''.
593 if not header:
595 return header
596 return separator.join(unicode(s, c or 'us-ascii')
597 for s, c in email.header.decode_header(header))
600 def _decode_address_list_field(address_list):
601 """Helper function to decode (potentially RFC2047 encoded) address lists.
603 Args:
604 address_list: a single str header, or list of str headers.
606 Returns:
607 unicode of decoded header or list of str headers.
609 if not address_list:
610 return None
612 if len(address_list) == 1:
613 return _decode_and_join_header(address_list[0])
614 else:
615 return map(_decode_and_join_header, address_list)
619 def wrapping(wrapped):
624 def wrapping_wrapper(wrapper):
625 try:
626 wrapper.__wrapped__ = wrapped
627 wrapper.__name__ = wrapped.__name__
628 wrapper.__doc__ = wrapped.__doc__
629 wrapper.__dict__.update(wrapped.__dict__)
630 except Exception:
631 pass
632 return wrapper
633 return wrapping_wrapper
637 def _positional(max_pos_args):
638 """A decorator to declare that only the first N arguments may be positional.
640 Note that for methods, n includes 'self'.
642 def positional_decorator(wrapped):
643 @wrapping(wrapped)
644 def positional_wrapper(*args, **kwds):
645 if len(args) > max_pos_args:
646 plural_s = ''
647 if max_pos_args != 1:
648 plural_s = 's'
649 raise TypeError(
650 '%s() takes at most %d positional argument%s (%d given)' %
651 (wrapped.__name__, max_pos_args, plural_s, len(args)))
652 return wrapped(*args, **kwds)
653 return positional_wrapper
654 return positional_decorator
657 class Attachment(object):
658 """Attachment object.
660 An Attachment object is largely interchangeable with a (filename, payload)
661 tuple.
663 Note that the behavior is a bit asymmetric with respect to unpacking and
664 equality comparison. An Attachment object without a content ID will be
665 equivalent to a (filename, payload) tuple. An Attachment with a content ID
666 will unpack to a (filename, payload) tuple, but will compare unequally to
667 that tuple.
669 Thus, the following comparison will succeed:
671 attachment = mail.Attachment('foo.jpg', 'data')
672 filename, payload = attachment
673 attachment == filename, payload
675 ...while the following will fail:
677 attachment = mail.Attachment('foo.jpg', 'data', content_id='<foo>')
678 filename, payload = attachment
679 attachment == filename, payload
681 The following comparison will pass though:
683 attachment = mail.Attachment('foo.jpg', 'data', content_id='<foo>')
684 attachment == (attachment.filename,
685 attachment.payload,
686 attachment.content_id)
688 Attributes:
689 filename: The name of the attachment.
690 payload: The attachment data.
691 content_id: Optional. The content-id for this attachment. Keyword-only.
694 @_positional(3)
695 def __init__(self, filename, payload, content_id=None):
696 """Constructor.
698 Arguments:
699 filename: The name of the attachment
700 payload: The attachment data.
701 content_id: Optional. The content-id for this attachment.
703 self.filename = filename
704 self.payload = payload
705 self.content_id = content_id
707 def __eq__(self, other):
708 self_tuple = (self.filename, self.payload, self.content_id)
709 if isinstance(other, Attachment):
710 other_tuple = (other.filename, other.payload, other.content_id)
713 elif not hasattr(other, '__len__'):
714 return NotImplemented
715 elif len(other) == 2:
716 other_tuple = other + (None,)
717 elif len(other) == 3:
718 other_tuple = other
719 else:
720 return NotImplemented
721 return self_tuple == other_tuple
723 def __hash__(self):
724 if self.content_id:
725 return hash((self.filename, self.payload, self.content_id))
726 else:
727 return hash((self.filename, self.payload))
729 def __ne__(self, other):
730 return not self == other
732 def __iter__(self):
733 return iter((self.filename, self.payload))
735 def __getitem__(self, i):
736 return tuple(iter(self))[i]
738 def __contains__(self, val):
739 return val in (self.filename, self.payload)
741 def __len__(self):
742 return 2
745 class EncodedPayload(object):
746 """Wrapper for a payload that contains encoding information.
748 When an email is received, it is usually encoded using a certain
749 character set, and then possibly further encoded using a transfer
750 encoding in that character set. Most of the times, it is possible
751 to decode the encoded payload as is, however, in the case where it
752 is not, the encoded payload and the original encoding information
753 must be preserved.
755 Attributes:
756 payload: The original encoded payload.
757 charset: The character set of the encoded payload. None means use
758 default character set.
759 encoding: The transfer encoding of the encoded payload. None means
760 content not encoded.
763 def __init__(self, payload, charset=None, encoding=None):
764 """Constructor.
766 Args:
767 payload: Maps to attribute of the same name.
768 charset: Maps to attribute of the same name.
769 encoding: Maps to attribute of the same name.
771 self.payload = payload
772 self.charset = charset
773 self.encoding = encoding
775 def decode(self):
776 """Attempt to decode the encoded data.
778 Attempt to use pythons codec library to decode the payload. All
779 exceptions are passed back to the caller.
781 Returns:
782 Binary or unicode version of payload content.
784 payload = self.payload
787 if self.encoding and self.encoding.lower() not in ('7bit', '8bit'):
788 try:
789 payload = payload.decode(self.encoding)
790 except LookupError:
791 raise UnknownEncodingError('Unknown decoding %s.' % self.encoding)
792 except (Exception, Error), e:
793 raise PayloadEncodingError('Could not decode payload: %s' % e)
796 if self.charset and str(self.charset).lower() != '7bit':
797 try:
798 payload = payload.decode(str(self.charset))
799 except LookupError:
800 raise UnknownCharsetError('Unknown charset %s.' % self.charset)
801 except (Exception, Error), e:
802 raise PayloadEncodingError('Could read characters: %s' % e)
804 return payload
806 def __eq__(self, other):
807 """Equality operator.
809 Args:
810 other: The other EncodedPayload object to compare with. Comparison
811 with other object types are not implemented.
813 Returns:
814 True of payload and encodings are equal, else false.
816 if isinstance(other, EncodedPayload):
817 return (self.payload == other.payload and
818 self.charset == other.charset and
819 self.encoding == other.encoding)
820 else:
821 return NotImplemented
823 def __hash__(self):
824 """Hash an EncodedPayload."""
825 return hash((self.payload, self.charset, self.encoding))
827 def copy_to(self, mime_message):
828 """Copy contents to MIME message payload.
830 If no content transfer encoding is specified, and the character set does
831 not equal the over-all message encoding, the payload will be base64
832 encoded.
834 Args:
835 mime_message: Message instance to receive new payload.
837 if self.encoding:
838 mime_message['content-transfer-encoding'] = self.encoding
839 mime_message.set_payload(self.payload, self.charset)
841 def to_mime_message(self):
842 """Convert to MIME message.
844 Returns:
845 MIME message instance of payload.
847 mime_message = email.Message.Message()
848 self.copy_to(mime_message)
849 return mime_message
851 def __str__(self):
852 """String representation of encoded message.
854 Returns:
855 MIME encoded representation of encoded payload as an independent message.
857 return str(self.to_mime_message())
859 def __repr__(self):
860 """Basic representation of encoded payload.
862 Returns:
863 Payload itself is represented by its hash value.
865 result = '<EncodedPayload payload=#%d' % hash(self.payload)
866 if self.charset:
867 result += ' charset=%s' % self.charset
868 if self.encoding:
869 result += ' encoding=%s' % self.encoding
870 return result + '>'
873 class _EmailMessageBase(object):
874 """Base class for email API service objects.
876 Subclasses must define a class variable called _API_CALL with the name
877 of its underlying mail sending API call.
881 PROPERTIES = set([
882 'sender',
883 'reply_to',
884 'subject',
885 'body',
886 'html',
887 'attachments',
890 ALLOWED_EMPTY_PROPERTIES = set([
891 'subject',
892 'body'
897 PROPERTIES.update(('to', 'cc', 'bcc'))
899 def __init__(self, mime_message=None, **kw):
900 """Initialize Email message.
902 Creates new MailMessage protocol buffer and initializes it with any
903 keyword arguments.
905 Args:
906 mime_message: MIME message to initialize from. If instance of
907 email.Message.Message will take ownership as original message.
908 kw: List of keyword properties as defined by PROPERTIES.
910 if mime_message:
911 mime_message = _parse_mime_message(mime_message)
912 self.update_from_mime_message(mime_message)
913 self.__original = mime_message
917 self.initialize(**kw)
919 @property
920 def original(self):
921 """Get original MIME message from which values were set."""
922 return self.__original
924 def initialize(self, **kw):
925 """Keyword initialization.
927 Used to set all fields of the email message using keyword arguments.
929 Args:
930 kw: List of keyword properties as defined by PROPERTIES.
932 for name, value in kw.iteritems():
933 setattr(self, name, value)
936 def Initialize(self, **kw):
937 self.initialize(**kw)
939 def check_initialized(self):
940 """Check if EmailMessage is properly initialized.
942 Test used to determine if EmailMessage meets basic requirements
943 for being used with the mail API. This means that the following
944 fields must be set or have at least one value in the case of
945 multi value fields:
947 - Subject must be set.
948 - A recipient must be specified.
949 - Must contain a body.
950 - All bodies and attachments must decode properly.
952 This check does not include determining if the sender is actually
953 authorized to send email for the application.
955 Raises:
956 Appropriate exception for initialization failure.
958 InvalidAttachmentTypeError: Use of incorrect attachment type.
959 MissingRecipientsError: No recipients specified in to, cc or bcc.
960 MissingSenderError: No sender specified.
961 MissingSubjectError: Subject is not specified.
962 MissingBodyError: No body specified.
963 PayloadEncodingError: Payload is not properly encoded.
964 UnknownEncodingError: Payload has unknown encoding.
965 UnknownCharsetError: Payload has unknown character set.
967 if not hasattr(self, 'sender'):
968 raise MissingSenderError()
971 found_body = False
973 try:
974 body = self.body
975 except AttributeError:
976 pass
977 else:
978 if isinstance(body, EncodedPayload):
980 body.decode()
981 found_body = True
983 try:
984 html = self.html
985 except AttributeError:
986 pass
987 else:
988 if isinstance(html, EncodedPayload):
990 html.decode()
991 found_body = True
993 if hasattr(self, 'attachments'):
994 for attachment in _attachment_sequence(self.attachments):
997 _GetMimeType(attachment.filename)
1002 if isinstance(attachment.payload, EncodedPayload):
1003 attachment.payload.decode()
1006 def CheckInitialized(self):
1007 self.check_initialized()
1009 def is_initialized(self):
1010 """Determine if EmailMessage is properly initialized.
1012 Returns:
1013 True if message is properly initializes, otherwise False.
1015 try:
1016 self.check_initialized()
1017 return True
1018 except Error:
1019 return False
1022 def IsInitialized(self):
1023 return self.is_initialized()
1025 def ToProto(self):
1026 """Convert mail message to protocol message.
1028 Unicode strings are converted to UTF-8 for all fields.
1030 This method is overriden by EmailMessage to support the sender fields.
1032 Returns:
1033 MailMessage protocol version of mail message.
1035 Raises:
1036 Passes through decoding errors that occur when using when decoding
1037 EncodedPayload objects.
1039 self.check_initialized()
1040 message = mail_service_pb.MailMessage()
1041 message.set_sender(_to_str(self.sender))
1043 if hasattr(self, 'reply_to'):
1044 message.set_replyto(_to_str(self.reply_to))
1045 if hasattr(self, 'subject'):
1046 message.set_subject(_to_str(self.subject))
1047 else:
1048 message.set_subject('')
1050 if hasattr(self, 'body'):
1051 body = self.body
1052 if isinstance(body, EncodedPayload):
1053 body = body.decode()
1054 message.set_textbody(_to_str(body))
1056 if hasattr(self, 'html'):
1057 html = self.html
1058 if isinstance(html, EncodedPayload):
1059 html = html.decode()
1060 message.set_htmlbody(_to_str(html))
1062 if hasattr(self, 'attachments'):
1063 for attachment in _attachment_sequence(self.attachments):
1064 if isinstance(attachment.payload, EncodedPayload):
1065 attachment.payload = attachment.payload.decode()
1066 protoattachment = message.add_attachment()
1067 protoattachment.set_filename(_to_str(attachment.filename))
1068 protoattachment.set_data(_to_str(attachment.payload))
1069 if attachment.content_id:
1070 protoattachment.set_contentid(attachment.content_id)
1071 return message
1073 def to_mime_message(self):
1074 """Generate a MIMEMultitype message from EmailMessage.
1076 Calls MailMessageToMessage after converting self to protocol
1077 buffer. Protocol buffer is better at handing corner cases
1078 than EmailMessage class.
1080 Returns:
1081 MIMEMultitype representing the provided MailMessage.
1083 Raises:
1084 Appropriate exception for initialization failure.
1086 InvalidAttachmentTypeError: Use of incorrect attachment type.
1087 MissingSenderError: No sender specified.
1088 MissingSubjectError: Subject is not specified.
1089 MissingBodyError: No body specified.
1091 return mail_message_to_mime_message(self.ToProto())
1094 def ToMIMEMessage(self):
1095 return self.to_mime_message()
1097 def send(self, make_sync_call=apiproxy_stub_map.MakeSyncCall):
1098 """Send email message.
1100 Send properly initialized email message via email API.
1102 Args:
1103 make_sync_call: Method which will make synchronous call to api proxy.
1105 Raises:
1106 Errors defined in this file above.
1108 message = self.ToProto()
1109 response = api_base_pb.VoidProto()
1111 try:
1112 make_sync_call('mail', self._API_CALL, message, response)
1113 except apiproxy_errors.ApplicationError, e:
1114 if e.application_error in ERROR_MAP:
1115 raise ERROR_MAP[e.application_error](e.error_detail)
1116 raise e
1119 def Send(self, *args, **kwds):
1120 self.send(*args, **kwds)
1122 def _check_attachment(self, attachment):
1124 if not (isinstance(attachment.filename, basestring) or
1125 isinstance(attachment.payload, basestring)):
1126 raise TypeError()
1128 def _check_attachments(self, attachments):
1129 """Checks values going to attachment field.
1131 Mainly used to check type safety of the values. Each value of the list
1132 must be a pair of the form (file_name, data), and both values a string
1133 type.
1135 Args:
1136 attachments: Collection of attachment tuples.
1138 Raises:
1139 TypeError if values are not string type.
1141 attachments = _attachment_sequence(attachments)
1142 for attachment in attachments:
1143 self._check_attachment(attachment)
1145 def __setattr__(self, attr, value):
1146 """Property setting access control.
1148 Controls write access to email fields.
1150 Args:
1151 attr: Attribute to access.
1152 value: New value for field.
1154 Raises:
1155 ValueError: If provided with an empty field.
1156 AttributeError: If not an allowed assignment field.
1158 if not attr.startswith('_EmailMessageBase'):
1159 if attr in ['sender', 'reply_to']:
1160 check_email_valid(value, attr)
1162 if not value and not attr in self.ALLOWED_EMPTY_PROPERTIES:
1163 raise ValueError('May not set empty value for \'%s\'' % attr)
1166 if attr not in self.PROPERTIES:
1167 raise AttributeError('\'EmailMessage\' has no attribute \'%s\'' % attr)
1170 if attr == 'attachments':
1171 self._check_attachments(value)
1173 super(_EmailMessageBase, self).__setattr__(attr, value)
1175 def _add_body(self, content_type, payload):
1176 """Add body to email from payload.
1178 Will overwrite any existing default plain or html body.
1180 Args:
1181 content_type: Content-type of body.
1182 payload: Payload to store body as.
1185 if content_type == 'text/plain':
1186 self.body = payload
1187 elif content_type == 'text/html':
1188 self.html = payload
1190 def _update_payload(self, mime_message):
1191 """Update payload of mail message from mime_message.
1193 This function works recusively when it receives a multipart body.
1194 If it receives a non-multi mime object, it will determine whether or
1195 not it is an attachment by whether it has a filename or not. Attachments
1196 and bodies are then wrapped in EncodedPayload with the correct charsets and
1197 encodings.
1199 Args:
1200 mime_message: A Message MIME email object.
1202 payload = mime_message.get_payload()
1204 if payload:
1205 if mime_message.get_content_maintype() == 'multipart':
1206 for alternative in payload:
1207 self._update_payload(alternative)
1208 else:
1209 filename = mime_message.get_param('filename',
1210 header='content-disposition')
1211 if filename:
1213 filename = email.utils.collapse_rfc2231_value(filename)
1214 if not filename:
1215 filename = mime_message.get_param('name')
1218 payload = EncodedPayload(payload,
1219 (mime_message.get_content_charset() or
1220 mime_message.get_charset()),
1221 mime_message['content-transfer-encoding'])
1223 if 'content-id' in mime_message:
1224 attachment = Attachment(filename,
1225 payload,
1226 content_id=mime_message['content-id'])
1227 else:
1228 attachment = Attachment(filename, payload)
1230 if filename:
1232 try:
1233 attachments = self.attachments
1234 except AttributeError:
1235 self.attachments = [attachment]
1236 else:
1237 if isinstance(attachments[0], basestring):
1238 self.attachments = [attachments]
1239 attachments = self.attachments
1240 attachments.append(attachment)
1241 else:
1242 self._add_body(mime_message.get_content_type(), payload)
1244 def update_from_mime_message(self, mime_message):
1245 """Copy information from a mime message.
1247 Set information of instance to values of mime message. This method
1248 will only copy values that it finds. Any missing values will not
1249 be copied, nor will they overwrite old values with blank values.
1251 This object is not guaranteed to be initialized after this call.
1253 Args:
1254 mime_message: email.Message instance to copy information from.
1256 Returns:
1257 MIME Message instance of mime_message argument.
1259 mime_message = _parse_mime_message(mime_message)
1261 sender = _decode_and_join_header(mime_message['from'])
1262 if sender:
1263 self.sender = sender
1265 reply_to = _decode_and_join_header(mime_message['reply-to'])
1266 if reply_to:
1267 self.reply_to = reply_to
1269 subject = _decode_and_join_header(mime_message['subject'], separator=u'')
1270 if subject:
1271 self.subject = subject
1273 self._update_payload(mime_message)
1275 def bodies(self, content_type=None):
1276 """Iterate over all bodies.
1278 Yields:
1279 Tuple (content_type, payload) for html and body in that order.
1281 if (not content_type or
1282 content_type == 'text' or
1283 content_type == 'text/html'):
1284 try:
1285 yield 'text/html', self.html
1286 except AttributeError:
1287 pass
1289 if (not content_type or
1290 content_type == 'text' or
1291 content_type == 'text/plain'):
1292 try:
1293 yield 'text/plain', self.body
1294 except AttributeError:
1295 pass
1298 class EmailMessage(_EmailMessageBase):
1299 """Main interface to email API service.
1301 This class is used to programmatically build an email message to send via
1302 the Mail API. The usage is to construct an instance, populate its fields
1303 and call Send().
1305 Example Usage:
1306 An EmailMessage can be built completely by the constructor.
1308 EmailMessage(sender='sender@nowhere.com',
1309 to='recipient@nowhere.com',
1310 subject='a subject',
1311 body='This is an email to you').Send()
1313 It might be desirable for an application to build an email in different
1314 places throughout the code. For this, EmailMessage is mutable.
1316 message = EmailMessage()
1317 message.sender = 'sender@nowhere.com'
1318 message.to = ['recipient1@nowhere.com', 'recipient2@nowhere.com']
1319 message.subject = 'a subject'
1320 message.body = 'This is an email to you')
1321 message.check_initialized()
1322 message.send()
1325 _API_CALL = 'Send'
1326 PROPERTIES = set(_EmailMessageBase.PROPERTIES | set(('headers',)))
1328 def check_initialized(self):
1329 """Provide additional checks to ensure recipients have been specified.
1331 Raises:
1332 MissingRecipientError when no recipients specified in to, cc or bcc.
1334 if (not hasattr(self, 'to') and
1335 not hasattr(self, 'cc') and
1336 not hasattr(self, 'bcc')):
1337 raise MissingRecipientsError()
1338 super(EmailMessage, self).check_initialized()
1341 def CheckInitialized(self):
1342 self.check_initialized()
1344 def ToProto(self):
1345 """Does addition conversion of recipient fields to protocol buffer.
1347 Returns:
1348 MailMessage protocol version of mail message including sender fields.
1350 message = super(EmailMessage, self).ToProto()
1352 for attribute, adder in (('to', message.add_to),
1353 ('cc', message.add_cc),
1354 ('bcc', message.add_bcc)):
1355 if hasattr(self, attribute):
1356 for address in _email_sequence(getattr(self, attribute)):
1357 adder(_to_str(address))
1358 for name, value in getattr(self, 'headers', {}).iteritems():
1359 header = message.add_header()
1360 header.set_name(name)
1361 header.set_value(_to_str(value))
1362 return message
1364 def __setattr__(self, attr, value):
1365 """Provides additional checks on recipient fields."""
1367 if attr in ['to', 'cc', 'bcc']:
1368 if isinstance(value, basestring):
1369 if value == '' and getattr(self, 'ALLOW_BLANK_EMAIL', False):
1370 return
1371 check_email_valid(value, attr)
1372 else:
1373 for address in value:
1374 check_email_valid(address, attr)
1375 elif attr == 'headers':
1376 check_headers_valid(value)
1378 super(EmailMessage, self).__setattr__(attr, value)
1380 def update_from_mime_message(self, mime_message):
1381 """Copy information from a mime message.
1383 Update fields for recipients.
1385 Args:
1386 mime_message: email.Message instance to copy information from.
1388 mime_message = _parse_mime_message(mime_message)
1389 super(EmailMessage, self).update_from_mime_message(mime_message)
1391 to = _decode_address_list_field(mime_message.get_all('to'))
1392 if to:
1393 self.to = to
1395 cc = _decode_address_list_field(mime_message.get_all('cc'))
1396 if cc:
1397 self.cc = cc
1399 bcc = _decode_address_list_field(mime_message.get_all('bcc'))
1400 if bcc:
1401 self.bcc = bcc
1404 class AdminEmailMessage(_EmailMessageBase):
1405 """Interface to sending email messages to all admins via the amil API.
1407 This class is used to programmatically build an admin email message to send
1408 via the Mail API. The usage is to construct an instance, populate its fields
1409 and call Send().
1411 Unlike the normal email message, addresses in the recipient fields are
1412 ignored and not used for sending.
1414 Example Usage:
1415 An AdminEmailMessage can be built completely by the constructor.
1417 AdminEmailMessage(sender='sender@nowhere.com',
1418 subject='a subject',
1419 body='This is an email to you').Send()
1421 It might be desirable for an application to build an admin email in
1422 different places throughout the code. For this, AdminEmailMessage is
1423 mutable.
1425 message = AdminEmailMessage()
1426 message.sender = 'sender@nowhere.com'
1427 message.subject = 'a subject'
1428 message.body = 'This is an email to you')
1429 message.check_initialized()
1430 message.send()
1433 _API_CALL = 'SendToAdmins'
1434 __UNUSED_PROPERTIES = set(('to', 'cc', 'bcc'))
1436 def __setattr__(self, attr, value):
1437 if attr in self.__UNUSED_PROPERTIES:
1438 logging.warning('\'%s\' is not a valid property to set '
1439 'for AdminEmailMessage. It is unused.', attr)
1440 super(AdminEmailMessage, self).__setattr__(attr, value)
1443 class InboundEmailMessage(EmailMessage):
1444 """Parsed email object as recevied from external source.
1446 Has a date field and can store any number of additional bodies. These
1447 additional attributes make the email more flexible as required for
1448 incoming mail, where the developer has less control over the content.
1450 Example Usage:
1452 # Read mail message from CGI input.
1453 message = InboundEmailMessage(sys.stdin.read())
1454 logging.info('Received email message from %s at %s',
1455 message.sender,
1456 message.date)
1457 enriched_body = list(message.bodies('text/enriched'))[0]
1458 ... Do something with body ...
1461 __HEADER_PROPERTIES = {'date': 'date',
1462 'message_id': 'message-id',
1465 PROPERTIES = frozenset(_EmailMessageBase.PROPERTIES |
1466 set(('alternate_bodies',)) |
1467 set(__HEADER_PROPERTIES.iterkeys()))
1469 ALLOW_BLANK_EMAIL = True
1471 def update_from_mime_message(self, mime_message):
1472 """Update values from MIME message.
1474 Copies over date values.
1476 Args:
1477 mime_message: email.Message instance to copy information from.
1479 mime_message = _parse_mime_message(mime_message)
1480 super(InboundEmailMessage, self).update_from_mime_message(mime_message)
1482 for property, header in InboundEmailMessage.__HEADER_PROPERTIES.iteritems():
1483 value = mime_message[header]
1484 if value:
1485 setattr(self, property, value)
1487 def _add_body(self, content_type, payload):
1488 """Add body to inbound message.
1490 Method is overidden to handle incoming messages that have more than one
1491 plain or html bodies or has any unidentified bodies.
1493 This method will not overwrite existing html and body values. This means
1494 that when updating, the text and html bodies that are first in the MIME
1495 document order are assigned to the body and html properties.
1497 Args:
1498 content_type: Content-type of additional body.
1499 payload: Content of additional body.
1501 if (content_type == 'text/plain' and not hasattr(self, 'body') or
1502 content_type == 'text/html' and not hasattr(self, 'html')):
1504 super(InboundEmailMessage, self)._add_body(content_type, payload)
1505 else:
1507 try:
1508 alternate_bodies = self.alternate_bodies
1509 except AttributeError:
1510 alternate_bodies = self.alternate_bodies = [(content_type, payload)]
1511 else:
1512 alternate_bodies.append((content_type, payload))
1514 def bodies(self, content_type=None):
1515 """Iterate over all bodies.
1517 Args:
1518 content_type: Content type to filter on. Allows selection of only
1519 specific types of content. Can be just the base type of the content
1520 type. For example:
1521 content_type = 'text/html' # Matches only HTML content.
1522 content_type = 'text' # Matches text of any kind.
1524 Yields:
1525 Tuple (content_type, payload) for all bodies of message, including body,
1526 html and all alternate_bodies in that order.
1528 main_bodies = super(InboundEmailMessage, self).bodies(content_type)
1529 for payload_type, payload in main_bodies:
1530 yield payload_type, payload
1532 partial_type = bool(content_type and content_type.find('/') < 0)
1534 try:
1535 for payload_type, payload in self.alternate_bodies:
1536 if content_type:
1537 if partial_type:
1538 match_type = payload_type.split('/')[0]
1539 else:
1540 match_type = payload_type
1541 match = match_type == content_type
1542 else:
1543 match = True
1545 if match:
1546 yield payload_type, payload
1547 except AttributeError:
1548 pass
1550 def to_mime_message(self):
1551 """Convert to MIME message.
1553 Adds additional headers from inbound email.
1555 Returns:
1556 MIME message instance of payload.
1558 mime_message = super(InboundEmailMessage, self).to_mime_message()
1560 for property, header in InboundEmailMessage.__HEADER_PROPERTIES.iteritems():
1561 try:
1562 mime_message[header] = getattr(self, property)
1563 except AttributeError:
1564 pass
1566 return mime_message
1577 Parser.Parser