|
1 #!/usr/bin/env python |
|
2 # |
|
3 # Copyright 2007 Google Inc. |
|
4 # |
|
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 |
|
8 # |
|
9 # http://www.apache.org/licenses/LICENSE-2.0 |
|
10 # |
|
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. |
|
16 # |
|
17 |
|
18 """Sends email on behalf of application. |
|
19 |
|
20 Provides functions for application developers to provide email services |
|
21 for their applications. Also provides a few utility methods. |
|
22 """ |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 from email import MIMEBase |
|
29 from email import MIMEMultipart |
|
30 from email import MIMEText |
|
31 import mimetypes |
|
32 import types |
|
33 |
|
34 from google.appengine.api import api_base_pb |
|
35 from google.appengine.api import apiproxy_stub_map |
|
36 from google.appengine.api import mail_service_pb |
|
37 from google.appengine.api import users |
|
38 from google.appengine.api.mail_errors import * |
|
39 from google.appengine.runtime import apiproxy_errors |
|
40 |
|
41 |
|
42 ERROR_MAP = { |
|
43 mail_service_pb.MailServiceError.BAD_REQUEST: |
|
44 BadRequestError, |
|
45 |
|
46 mail_service_pb.MailServiceError.UNAUTHORIZED_SENDER: |
|
47 InvalidSenderError, |
|
48 |
|
49 mail_service_pb.MailServiceError.INVALID_ATTACHMENT_TYPE: |
|
50 InvalidAttachmentTypeError, |
|
51 } |
|
52 |
|
53 |
|
54 EXTENSION_WHITELIST = set([ |
|
55 'bmp', |
|
56 'css', |
|
57 'csv', |
|
58 'gif', |
|
59 'html', 'htm', |
|
60 'jpeg', 'jpg', 'jpe', |
|
61 'pdf', |
|
62 'png', |
|
63 'rss', |
|
64 'text', 'txt', 'asc', 'diff', 'pot', |
|
65 'tiff', 'tif', |
|
66 'wbmp', |
|
67 ]) |
|
68 |
|
69 |
|
70 def invalid_email_reason(email_address, field): |
|
71 """Determine reason why email is invalid |
|
72 |
|
73 Args: |
|
74 email_address: Email to check. |
|
75 |
|
76 Returns: |
|
77 String indicating invalid email reason if there is one, |
|
78 else None. |
|
79 """ |
|
80 if email_address is None: |
|
81 return 'None email address for %s.' % field |
|
82 |
|
83 if isinstance(email_address, users.User): |
|
84 email_address = email_address.email() |
|
85 if not isinstance(email_address, types.StringTypes): |
|
86 return 'Invalid email address type for %s.' % field |
|
87 stripped_address = email_address.strip() |
|
88 if not stripped_address: |
|
89 return 'Empty email address for %s.' % field |
|
90 return None |
|
91 |
|
92 InvalidEmailReason = invalid_email_reason |
|
93 |
|
94 |
|
95 def is_email_valid(email_address): |
|
96 """Determine if email is invalid. |
|
97 |
|
98 Args: |
|
99 email_address: Email to check. |
|
100 |
|
101 Returns: |
|
102 True if email is valid, else False. |
|
103 """ |
|
104 return invalid_email_reason(email_address, '') is None |
|
105 |
|
106 IsEmailValid = is_email_valid |
|
107 |
|
108 |
|
109 def check_email_valid(email_address, field): |
|
110 """Check that email is valid |
|
111 |
|
112 Args: |
|
113 email_address: Email to check. |
|
114 |
|
115 Raises: |
|
116 InvalidEmailError if email_address is invalid. |
|
117 """ |
|
118 reason = invalid_email_reason(email_address, field) |
|
119 if reason is not None: |
|
120 raise InvalidEmailError(reason) |
|
121 |
|
122 CheckEmailValid = check_email_valid |
|
123 |
|
124 |
|
125 def _email_check_and_list(emails, field): |
|
126 """Generate a list of emails. |
|
127 |
|
128 Args: |
|
129 emails: Single email or list of emails. |
|
130 |
|
131 Returns: |
|
132 Sequence of email addresses. |
|
133 |
|
134 Raises: |
|
135 InvalidEmailError if any email addresses are invalid. |
|
136 """ |
|
137 if isinstance(emails, types.StringTypes): |
|
138 check_email_valid(value) |
|
139 else: |
|
140 for address in iter(emails): |
|
141 check_email_valid(address, field) |
|
142 |
|
143 |
|
144 def _email_sequence(emails): |
|
145 """Forces email to be sequenceable type. |
|
146 |
|
147 Iterable values are returned as is. This function really just wraps the case |
|
148 where there is a single email string. |
|
149 |
|
150 Args: |
|
151 emails: Emails (or email) to coerce to sequence. |
|
152 |
|
153 Returns: |
|
154 Single tuple with email in it if only one email string provided, |
|
155 else returns emails as is. |
|
156 """ |
|
157 if isinstance(emails, types.StringTypes): |
|
158 return emails, |
|
159 return emails |
|
160 |
|
161 |
|
162 def _attachment_sequence(attachments): |
|
163 """Forces attachments to be sequenceable type. |
|
164 |
|
165 Iterable values are returned as is. This function really just wraps the case |
|
166 where there is a single attachment. |
|
167 |
|
168 Args: |
|
169 attachments: Attachments (or attachment) to coerce to sequence. |
|
170 |
|
171 Returns: |
|
172 Single tuple with attachment tuple in it if only one attachment provided, |
|
173 else returns attachments as is. |
|
174 """ |
|
175 if len(attachments) == 2 and isinstance(attachments[0], types.StringTypes): |
|
176 return attachments, |
|
177 return attachments |
|
178 |
|
179 |
|
180 def send_mail(sender, |
|
181 to, |
|
182 subject, |
|
183 body, |
|
184 make_sync_call=apiproxy_stub_map.MakeSyncCall, |
|
185 **kw): |
|
186 """Sends mail on behalf of application. |
|
187 |
|
188 Args: |
|
189 sender: Sender email address as appears in the 'from' email line. |
|
190 to: List of 'to' addresses or a single address. |
|
191 subject: Message subject string. |
|
192 body: Body of type text/plain. |
|
193 make_sync_call: Function used to make sync call to API proxy. |
|
194 kw: Keyword arguments compatible with EmailMessage keyword based |
|
195 constructor. |
|
196 |
|
197 Raises: |
|
198 InvalidEmailError when invalid email address provided. |
|
199 """ |
|
200 kw['sender'] = sender |
|
201 kw['to'] = to |
|
202 kw['subject'] = subject |
|
203 kw['body'] = body |
|
204 message = EmailMessage(**kw) |
|
205 message.send(make_sync_call) |
|
206 |
|
207 SendMail = send_mail |
|
208 |
|
209 |
|
210 def send_mail_to_admins(sender, |
|
211 subject, |
|
212 body, |
|
213 make_sync_call=apiproxy_stub_map.MakeSyncCall, |
|
214 **kw): |
|
215 """Sends mail to admins on behalf of application. |
|
216 |
|
217 Args: |
|
218 sender: Sender email address as appears in the 'from' email line. |
|
219 subject: Message subject string. |
|
220 body: Body of type text/plain. |
|
221 make_sync_call: Function used to make sync call to API proxy. |
|
222 kw: Keyword arguments compatible with EmailMessage keyword based |
|
223 constructor. |
|
224 |
|
225 Raises: |
|
226 InvalidEmailError when invalid email address provided. |
|
227 """ |
|
228 kw['sender'] = sender |
|
229 kw['subject'] = subject |
|
230 kw['body'] = body |
|
231 message = AdminEmailMessage(**kw) |
|
232 message.send(make_sync_call) |
|
233 |
|
234 SendMailToAdmins = send_mail_to_admins |
|
235 |
|
236 |
|
237 def mail_message_to_mime_message(protocol_message): |
|
238 """Generate a MIMEMultitype message from protocol buffer. |
|
239 |
|
240 Generates a complete MIME multi-part email object from a MailMessage |
|
241 protocol buffer. The body fields are sent as individual alternatives |
|
242 if they are both present, otherwise, only one body part is sent. |
|
243 |
|
244 Multiple entry email fields such as 'To', 'Cc' and 'Bcc' are converted |
|
245 to a list of comma separated email addresses. |
|
246 |
|
247 Args: |
|
248 message: Message PB to convert to MIMEMultitype. |
|
249 |
|
250 Returns: |
|
251 MIMEMultitype representing the provided MailMessage. |
|
252 """ |
|
253 parts = [] |
|
254 if protocol_message.has_textbody(): |
|
255 parts.append(MIMEText.MIMEText(protocol_message.textbody())) |
|
256 if protocol_message.has_htmlbody(): |
|
257 parts.append(MIMEText.MIMEText(protocol_message.htmlbody(), |
|
258 _subtype='html')) |
|
259 |
|
260 if len(parts) == 1: |
|
261 payload = parts |
|
262 else: |
|
263 payload = [MIMEMultipart.MIMEMultipart('alternative', _subparts=parts)] |
|
264 |
|
265 result = MIMEMultipart.MIMEMultipart(_subparts=payload) |
|
266 for attachment in protocol_message.attachment_list(): |
|
267 mime_type, encoding = mimetypes.guess_type(attachment.filename()) |
|
268 assert mime_type is not None |
|
269 maintype, subtype = mime_type.split('/') |
|
270 mime_attachment = MIMEBase.MIMEBase(maintype, subtype) |
|
271 mime_attachment.add_header('Content-Disposition', |
|
272 'attachment', |
|
273 filename=attachment.filename()) |
|
274 mime_attachment.set_charset(encoding) |
|
275 mime_attachment.set_payload(attachment.data()) |
|
276 result.attach(mime_attachment) |
|
277 |
|
278 if protocol_message.to_size(): |
|
279 result['To'] = ', '.join(protocol_message.to_list()) |
|
280 if protocol_message.cc_size(): |
|
281 result['Cc'] = ', '.join(protocol_message.cc_list()) |
|
282 if protocol_message.bcc_size(): |
|
283 result['Bcc'] = ', '.join(protocol_message.bcc_list()) |
|
284 |
|
285 result['From'] = protocol_message.sender() |
|
286 result['ReplyTo'] = protocol_message.replyto() |
|
287 result['Subject'] = protocol_message.subject() |
|
288 |
|
289 return result |
|
290 |
|
291 MailMessageToMIMEMessage = mail_message_to_mime_message |
|
292 |
|
293 |
|
294 class _EmailMessageBase(object): |
|
295 """Base class for email API service objects. |
|
296 |
|
297 Subclasses must define a class variable called _API_CALL with the name |
|
298 of its underlying mail sending API call. |
|
299 """ |
|
300 |
|
301 PROPERTIES = set([ |
|
302 'sender', |
|
303 'reply_to', |
|
304 'subject', |
|
305 'body', |
|
306 'html', |
|
307 'attachments', |
|
308 ]) |
|
309 |
|
310 def __init__(self, **kw): |
|
311 """Initialize Email message. |
|
312 |
|
313 Creates new MailMessage protocol buffer and initializes it with any |
|
314 keyword arguments. |
|
315 |
|
316 Args: |
|
317 kw: List of keyword properties as defined by PROPERTIES. |
|
318 """ |
|
319 self.initialize(**kw) |
|
320 |
|
321 def initialize(self, **kw): |
|
322 """Keyword initialization. |
|
323 |
|
324 Used to set all fields of the email message using keyword arguments. |
|
325 |
|
326 Args: |
|
327 kw: List of keyword properties as defined by PROPERTIES. |
|
328 """ |
|
329 for name, value in kw.iteritems(): |
|
330 setattr(self, name, value) |
|
331 |
|
332 def Initialize(self, **kw): |
|
333 self.initialize(**kw) |
|
334 |
|
335 def check_initialized(self): |
|
336 """Check if EmailMessage is properly initialized. |
|
337 |
|
338 Test used to determine if EmailMessage meets basic requirements |
|
339 for being used with the mail API. This means that the following |
|
340 fields must be set or have at least one value in the case of |
|
341 multi value fields: |
|
342 |
|
343 - Subject must be set. |
|
344 - A recipient must be specified. |
|
345 - Must contain a body. |
|
346 |
|
347 This check does not include determining if the sender is actually |
|
348 authorized to send email for the application. |
|
349 |
|
350 Raises: |
|
351 Appropriate exception for initialization failure. |
|
352 |
|
353 InvalidAttachmentTypeError: Use of incorrect attachment type. |
|
354 MissingRecipientsError: No recipients specified in to, cc or bcc. |
|
355 MissingSenderError: No sender specified. |
|
356 MissingSubjectError: Subject is not specified. |
|
357 MissingBodyError: No body specified. |
|
358 """ |
|
359 if not hasattr(self, 'sender'): |
|
360 raise MissingSenderError() |
|
361 if not hasattr(self, 'subject'): |
|
362 raise MissingSubjectError() |
|
363 if not hasattr(self, 'body') and not hasattr(self, 'html'): |
|
364 raise MissingBodyError() |
|
365 if hasattr(self, 'attachments'): |
|
366 for filename, data in _attachment_sequence(self.attachments): |
|
367 split_filename = filename.split('.') |
|
368 if len(split_filename) < 2: |
|
369 raise InvalidAttachmentTypeError() |
|
370 if split_filename[-1] not in EXTENSION_WHITELIST: |
|
371 raise InvalidAttachmentTypeError() |
|
372 mime_type, encoding = mimetypes.guess_type(filename) |
|
373 if mime_type is None: |
|
374 raise InvalidAttachmentTypeError() |
|
375 |
|
376 def CheckInitialized(self): |
|
377 self.check_initialized() |
|
378 |
|
379 def is_initialized(self): |
|
380 """Determine if EmailMessage is properly initialized. |
|
381 |
|
382 Returns: |
|
383 True if message is properly initializes, otherwise False. |
|
384 """ |
|
385 try: |
|
386 self.check_initialized() |
|
387 return True |
|
388 except Error: |
|
389 return False |
|
390 |
|
391 def IsInitialized(self): |
|
392 return self.is_initialized() |
|
393 |
|
394 def ToProto(self): |
|
395 self.check_initialized() |
|
396 message = mail_service_pb.MailMessage() |
|
397 message.set_sender(self.sender) |
|
398 |
|
399 if hasattr(self, 'reply_to'): |
|
400 message.set_replyto(self.reply_to) |
|
401 message.set_subject(self.subject) |
|
402 if hasattr(self, 'body'): |
|
403 message.set_textbody(self.body) |
|
404 if hasattr(self, 'html'): |
|
405 message.set_htmlbody(self.html) |
|
406 |
|
407 if hasattr(self, 'attachments'): |
|
408 for file_name, data in _attachment_sequence(self.attachments): |
|
409 attachment = message.add_attachment() |
|
410 attachment.set_filename(file_name) |
|
411 attachment.set_data(data) |
|
412 return message |
|
413 |
|
414 def to_mime_message(self): |
|
415 """Generate a MIMEMultitype message from EmailMessage. |
|
416 |
|
417 Calls MailMessageToMessage after converting self to protocol |
|
418 buffer. Protocol buffer is better at handing corner cases |
|
419 than EmailMessage class. |
|
420 |
|
421 Returns: |
|
422 MIMEMultitype representing the provided MailMessage. |
|
423 |
|
424 Raises: |
|
425 Appropriate exception for initialization failure. |
|
426 |
|
427 InvalidAttachmentTypeError: Use of incorrect attachment type. |
|
428 MissingSenderError: No sender specified. |
|
429 MissingSubjectError: Subject is not specified. |
|
430 MissingBodyError: No body specified. |
|
431 """ |
|
432 return mail_message_to_mime_message(self.ToProto()) |
|
433 |
|
434 def ToMIMEMessage(self): |
|
435 return self.to_mime_message() |
|
436 |
|
437 def send(self, make_sync_call=apiproxy_stub_map.MakeSyncCall): |
|
438 """Send email message. |
|
439 |
|
440 Send properly initialized email message via email API. |
|
441 |
|
442 Args: |
|
443 make_sync_call: Method which will make synchronous call to api proxy. |
|
444 |
|
445 Raises: |
|
446 Errors defined in this file above. |
|
447 """ |
|
448 message = self.ToProto() |
|
449 response = api_base_pb.VoidProto() |
|
450 |
|
451 try: |
|
452 make_sync_call('mail', self._API_CALL, message, response) |
|
453 except apiproxy_errors.ApplicationError, e: |
|
454 if e.application_error in ERROR_MAP: |
|
455 raise ERROR_MAP[e.application_error]() |
|
456 raise e |
|
457 |
|
458 def Send(self, *args, **kwds): |
|
459 self.send(*args, **kwds) |
|
460 |
|
461 def _check_attachment(self, attachment): |
|
462 file_name, data = attachment |
|
463 if not (isinstance(file_name, types.StringTypes) or |
|
464 isinstance(data, types.StringTypes)): |
|
465 raise TypeError() |
|
466 |
|
467 def _check_attachments(self, attachments): |
|
468 """Checks values going to attachment field. |
|
469 |
|
470 Mainly used to check type safety of the values. Each value of the list |
|
471 must be a pair of the form (file_name, data), and both values a string |
|
472 type. |
|
473 |
|
474 Args: |
|
475 attachments: Collection of attachment tuples. |
|
476 |
|
477 Raises: |
|
478 TypeError if values are not string type. |
|
479 """ |
|
480 if len(attachments) == 2 and isinstance(attachments[0], types.StringTypes): |
|
481 self._check_attachment(attachments) |
|
482 else: |
|
483 for attachment in attachments: |
|
484 self._check_attachment(attachment) |
|
485 |
|
486 def __setattr__(self, attr, value): |
|
487 """Property setting access control. |
|
488 |
|
489 Controls write access to email fields. |
|
490 |
|
491 Args: |
|
492 attr: Attribute to access. |
|
493 value: New value for field. |
|
494 """ |
|
495 if attr in ['sender', 'reply_to']: |
|
496 check_email_valid(value, attr) |
|
497 |
|
498 if not value: |
|
499 raise ValueError('May not set empty value for \'%s\'' % attr) |
|
500 |
|
501 if attr not in self.PROPERTIES: |
|
502 raise AttributeError('\'EmailMessage\' has no attribute \'%s\'' % attr) |
|
503 |
|
504 if attr == 'attachments': |
|
505 self._check_attachments(value) |
|
506 |
|
507 super(_EmailMessageBase, self).__setattr__(attr, value) |
|
508 |
|
509 |
|
510 class EmailMessage(_EmailMessageBase): |
|
511 """Main interface to email API service. |
|
512 |
|
513 This class is used to programmatically build an email message to send via |
|
514 the Mail API. The usage is to construct an instance, populate its fields |
|
515 and call Send(). |
|
516 |
|
517 Example Usage: |
|
518 An EmailMessage can be built completely by the constructor. |
|
519 |
|
520 EmailMessage(sender='sender@nowhere.com', |
|
521 to='recipient@nowhere.com', |
|
522 subject='a subject', |
|
523 body='This is an email to you').Send() |
|
524 |
|
525 It might be desirable for an application to build an email in different |
|
526 places throughout the code. For this, EmailMessage is mutable. |
|
527 |
|
528 message = EmailMessage() |
|
529 message.sender = 'sender@nowhere.com' |
|
530 message.to = ['recipient1@nowhere.com', 'recipient2@nowhere.com'] |
|
531 message.subject = 'a subject' |
|
532 message.body = 'This is an email to you') |
|
533 message.check_initialized() |
|
534 message.send() |
|
535 """ |
|
536 |
|
537 _API_CALL = 'Send' |
|
538 PROPERTIES = _EmailMessageBase.PROPERTIES |
|
539 PROPERTIES.update(('to', 'cc', 'bcc')) |
|
540 |
|
541 def check_initialized(self): |
|
542 """Provide additional checks to ensure recipients have been specified. |
|
543 |
|
544 Raises: |
|
545 MissingRecipientError when no recipients specified in to, cc or bcc. |
|
546 """ |
|
547 if (not hasattr(self, 'to') and |
|
548 not hasattr(self, 'cc') and |
|
549 not hasattr(self, 'bcc')): |
|
550 raise MissingRecipientsError() |
|
551 super(EmailMessage, self).check_initialized() |
|
552 |
|
553 def CheckInitialized(self): |
|
554 self.check_initialized() |
|
555 |
|
556 def ToProto(self): |
|
557 """Does addition conversion of recipient fields to protocol buffer. |
|
558 """ |
|
559 message = super(EmailMessage, self).ToProto() |
|
560 |
|
561 for attribute, adder in (('to', message.add_to), |
|
562 ('cc', message.add_cc), |
|
563 ('bcc', message.add_bcc)): |
|
564 if hasattr(self, attribute): |
|
565 for address in _email_sequence(getattr(self, attribute)): |
|
566 adder(address) |
|
567 return message |
|
568 |
|
569 def __setattr__(self, attr, value): |
|
570 """Provides additional checks on recipient fields.""" |
|
571 if attr in ['to', 'cc', 'bcc']: |
|
572 if isinstance(value, types.StringTypes): |
|
573 check_email_valid(value, attr) |
|
574 else: |
|
575 _email_check_and_list(value, attr) |
|
576 |
|
577 super(EmailMessage, self).__setattr__(attr, value) |
|
578 |
|
579 |
|
580 class AdminEmailMessage(_EmailMessageBase): |
|
581 """Interface to sending email messages to all admins via the amil API. |
|
582 |
|
583 This class is used to programmatically build an admin email message to send |
|
584 via the Mail API. The usage is to construct an instance, populate its fields |
|
585 and call Send(). |
|
586 |
|
587 Unlike the normal email message, addresses in the recipient fields are |
|
588 ignored and not used for sending. |
|
589 |
|
590 Example Usage: |
|
591 An AdminEmailMessage can be built completely by the constructor. |
|
592 |
|
593 AdminEmailMessage(sender='sender@nowhere.com', |
|
594 subject='a subject', |
|
595 body='This is an email to you').Send() |
|
596 |
|
597 It might be desirable for an application to build an admin email in |
|
598 different places throughout the code. For this, AdminEmailMessage is |
|
599 mutable. |
|
600 |
|
601 message = AdminEmailMessage() |
|
602 message.sender = 'sender@nowhere.com' |
|
603 message.subject = 'a subject' |
|
604 message.body = 'This is an email to you') |
|
605 message.check_initialized() |
|
606 message.send() |
|
607 """ |
|
608 |
|
609 _API_CALL = 'SendToAdmins' |