-
-
Save timrichardson/1154e29174926e462b7a to your computer and use it in GitHub Desktop.
smtp mail sending in cPython blocks the GIL. | |
This code is tested on python 2.7.8 and I'm using it with web2py | |
If you use Google Apps for your domain email and if you have admin access, you can easily use the gmail api. | |
Because you have admin access, you can create a "service account" in the Google Developer Console. | |
This makes authentication easy. | |
There are other authorisation methods when you don't have admin access, but they require interaction from the user via a browser. | |
To use this, you need to install these modules (From PyPI): | |
pyOpenSSL | |
pycrypto | |
google-api-python-client | |
I have not used the attachment function. It should be extended to work with multiple attachments. | |
Some of this code comes from Google docs. It didn't work on windows; the private key needs to be opened with 'rb' | |
The html-email code comes from the python docs, but for the gmail api the message string must be encoded with the urlsafe coding. | |
I hope the test cases make it clear how to use it. | |
__author__ = 'tim' | |
from oauth2client.client import SignedJwtAssertionCredentials | |
import httplib2 | |
from apiclient.discovery import build | |
#depends on pycrypto module and PyOpenSSL | |
# CreateMethodWithAttachment is untested | |
"""Send an email message from the user's account. | |
""" | |
import unittest | |
import base64 | |
from email.mime.audio import MIMEAudio | |
from email.mime.base import MIMEBase | |
from email.mime.image import MIMEImage | |
from email.mime.multipart import MIMEMultipart | |
from email.mime.text import MIMEText | |
import mimetypes | |
import os | |
from HTMLParser import HTMLParser | |
from apiclient import errors | |
class MLStripper(HTMLParser): | |
def __init__(self): | |
self.reset() | |
self.fed = [] | |
def handle_data(self, d): | |
self.fed.append(d) | |
def get_data(self): | |
return ''.join(self.fed) | |
def strip_tags(html): | |
s = MLStripper() | |
s.feed(html) | |
return s.get_data() | |
class Google_apps_mail(object): | |
""" | |
This class is for service accounts defined in the Google developer console. It assumes you have | |
admin rights at Google Apps to give permission to this Service account | |
""" | |
def __init__(self,client_email,use_as_email,privatekey_path): | |
""" | |
:param client_email: The registered client address from the service account, Google Developers Consolde | |
:param use_as_email: The email of the account to use | |
:param privatekey_path: | |
:return: | |
""" | |
self.client_email = client_email | |
with open(privatekey_path,'rb') as f: #'rb' is needed on Windows or else 'not enough data' | |
self.private_key = f.read() | |
# The oauth2client.client.SignedJwtAssertionCredentials class is only used with OAuth 2.0 Service Accounts. | |
# No end-user is involved for these server-to-server API calls, | |
# so you can create this object directly without using a Flow object. | |
self.credentials = SignedJwtAssertionCredentials(self.client_email, self.private_key, | |
'https://www.googleapis.com/auth/gmail.modify', #scope | |
sub=use_as_email) | |
http = httplib2.Http() | |
http = self.credentials.authorize(http) | |
self.service = build('gmail', 'v1', http=http) | |
def SendMessage(self, user_id, message): | |
"""Send an email message. | |
Args: | |
service: Authorized Gmail API service instance. | |
user_id: User's email address. The special value "me" | |
can be used to indicate the authenticated user. | |
message: Message to be sent. | |
Returns: | |
Sent Message. | |
""" | |
try: | |
message = (self.service.users().messages().send(userId=user_id, body=message).execute()) | |
print 'Message Id: %s' % message['id'] | |
return message | |
except errors.HttpError, error: | |
print 'An error occurred: %s' % error | |
def CreateMessage(self,sender, to, subject, message_text=None,message_html=None): | |
"""Create a message for an email. | |
Args: | |
sender: Email address of the sender. | |
to: Email address of the receiver. | |
subject: The subject of the email message. | |
message_text: The text of the email message. No markup allowed. If empty will strip tags from message_html | |
message_html: html tags allowed. If message_html is provided, both text and html are sent. | |
Returns: | |
An object containing a base64 encoded email object. | |
""" | |
if not message_text and not message_html: | |
raise ValueError("Both plain text and HTML message arguments are empty!") | |
if not message_html: #plain text only | |
message = MIMEText(message_text) | |
else: | |
message = MIMEMultipart('alternative') | |
if not message_text: | |
message_text = strip_tags(message_html) | |
part1_plain = MIMEText(message_text,'plain') | |
part2_html = MIMEText(message_html,'html') | |
message.attach(part1_plain) | |
message.attach(part2_html) | |
message['to'] = to | |
message['from'] = sender | |
message['subject'] = subject | |
return {'raw': base64.urlsafe_b64encode(message.as_string())} | |
def CreateMessageWithAttachment(self,sender, to, subject, message_text=None, message_html=None,file_dir=None, | |
filename=None): | |
"""Create a message for an email. | |
Args: | |
sender: Email address of the sender. | |
to: Email address of the receiver. | |
subject: The subject of the email message. | |
message_text: The text of the email message. | |
message_html: HTML body of email. Optional. | |
file_dir: The directory containing the file to be attached. | |
filename: The name of the file to be attached. | |
Returns: | |
An object containing a base64 encoded email object. | |
TODO this should accept a list of attachments, not just one | |
""" | |
if not message_text and not message_html: | |
raise ValueError("Plain text and HTML message arguments are both empty") | |
if not filename: | |
raise ValueError("Please provide a filename for the attachment") | |
if not file_dir: | |
raise ValueError("Please provide a file system directory for the attachment") | |
message = MIMEMultipart() | |
message['to'] = to | |
message['from'] = sender | |
message['subject'] = subject | |
if not message_text and message_html: | |
message_text = strip_tags(message_html) | |
else: | |
raise ValueError("Plain text and HTML message arguments are both empty") | |
msg_plain = MIMEText(message_text,'plain') | |
msg_html = MIMEText(message_html,'html') | |
message.attach(msg_plain) | |
message.attach(msg_html) | |
path = os.path.join(file_dir, filename) | |
content_type, encoding = mimetypes.guess_type(path) | |
if content_type is None or encoding is not None: | |
content_type = 'application/octet-stream' | |
main_type, sub_type = content_type.split('/', 1) | |
if main_type == 'text': | |
fp = open(path, 'rb') | |
msg = MIMEText(fp.read(), _subtype=sub_type) | |
fp.close() | |
elif main_type == 'image': | |
fp = open(path, 'rb') | |
msg = MIMEImage(fp.read(), _subtype=sub_type) | |
fp.close() | |
elif main_type == 'audio': | |
fp = open(path, 'rb') | |
msg = MIMEAudio(fp.read(), _subtype=sub_type) | |
fp.close() | |
else: | |
fp = open(path, 'rb') | |
msg = MIMEBase(main_type, sub_type) | |
msg.set_payload(fp.read()) | |
fp.close() | |
msg.add_header('Content-Disposition', 'attachment', filename=filename) | |
message.attach(msg) | |
return {'raw': base64.urlsafe_b64encode(message.as_string())} | |
def CreateDraft(self,sender,message): | |
""" | |
""" | |
try: | |
message_envelope = {'message': message} | |
draft = (self.service.users().drafts().create(userId=sender, body=message_envelope).execute()) | |
print 'Draft Id: %s' % draft['id'] | |
return draft | |
except errors.HttpError, error: | |
print 'An error occurred: %s' % error | |
class TestGoogle_meta_class(unittest.TestCase): | |
def setUp(self): | |
self.client_email = '[email protected]' | |
self.key_path = "e:/web2py/web2py_iis/web2py/applications/key.p12" | |
self.test_user = '[email protected]' | |
self.test_recipient = '[email protected]' | |
self.message_html = """\ | |
<html> | |
<head></head> | |
<body> | |
<h1>Hi!</h1><br> | |
How <b>are</b> you?<br> | |
Here is the <a href="http://www.python.org:80">link</a> <em>you</em> wanted. | |
</p> | |
</body> | |
</html> | |
""" | |
self.message_plain = "This is plain text" | |
class TestGoogle_create_message(TestGoogle_meta_class): | |
def test_create_object(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
self.assertTrue(gmail) | |
def test_create_message_html_only(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
message = gmail.CreateMessage(self.test_user, self.test_recipient,'subject is test v2, send HTML only',message_html=self.message_html) | |
self.assertTrue(message) | |
def test_create_message_plaintext_only(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
message = gmail.CreateMessage(self.test_user,self.test_recipient,'subject is test v2, send plain text only',message_text=self.message_plain) | |
self.assertTrue(message) | |
def test_create_message_bothtypes_only(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
message = gmail.CreateMessage(self.test_user,self.test_recipient,'subject is test v2, send both plain and html',message_text=self.message_plain,message_html=self.message_html) | |
self.assertTrue(message) | |
class TestGoogle_send_message(TestGoogle_meta_class): | |
def test_send_plain_text(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
message = gmail.CreateMessage(self.test_user,self.test_recipient,'subject is test v2, send plain text only',message_text=self.message_plain) | |
message_id=gmail.SendMessage(self.test_user,message) | |
self.assertTrue(message_id) | |
def test_send_html_only(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
message = gmail.CreateMessage(self.test_user,self.test_recipient,'subject is test v2, send HTML only',message_html=self.message_html) | |
message_id = gmail.SendMessage(self.test_user,message) | |
self.assertTrue(message_id) | |
def test_send_both_types(self): | |
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path) | |
message = gmail.CreateMessage(self.test_user,self.test_recipient,'subject is test v2, send both plain and html',message_text=self.message_plain,message_html=self.message_html) | |
message = gmail.SendMessage(self.test_user,message) | |
self.assertTrue(message) | |
class TestGoogle_drafts(TestGoogle_meta_class): | |
def test_create_draft_text(self): | |
gmail = Google_apps_mail(self.client_email, self.test_user, self.key_path) | |
message = gmail.CreateMessage(self.test_user, self.test_recipient, 'draft test , send plain text only', | |
message_text=self.message_plain) | |
draft = gmail.CreateDraft(self.test_user,message) | |
self.assertTrue(draft) | |
def test_create_draft_html_only(self): | |
gmail = Google_apps_mail(self.client_email, self.test_user, self.key_path) | |
message = gmail.CreateMessage(self.test_user, self.test_recipient, 'subject is test v2, send HTML only', | |
message_html=self.message_html) | |
draft = gmail.CreateDraft(self.test_user,message) | |
self.assertTrue(draft) | |
if __name__ == "__main__": | |
suite = unittest.TestLoader().loadTestsFromTestCase(TestGoogle_create_message) | |
unittest.TextTestRunner(verbosity=2).run(suite) | |
suite = unittest.TestLoader().loadTestsFromTestCase(TestGoogle_send_message) | |
unittest.TextTestRunner(verbosity=2).run(suite) | |
suite = unittest.TestLoader().loadTestsFromTestCase(TestGoogle_drafts) | |
unittest.TextTestRunner(verbosity=2).run(suite) | |
well done.
One issue though, I keep getting this http 500 error:
googleapiclient.errors.HttpError: <HttpError 500 when requesting https://www.goo
gleapis.com/gmail/v1/users/me/threads?alt=json&key=*************************************
returned "Backend Error">
Any ideas?
Also, cant u use Service Account without admin access? I can see that u can create a Client ID with the "service account" option even if not admin!
also got the error about
HttpError 500 when requesting https://www.googleapis.com/gmail/v1/users/me/messages/send?alt=json returned "Backend Error"
not have any idea yet...
Thanks for the Code!
Just a quick update for anyone wishing to use it, the "SignedJwtAssertionCredentials" method is no longer part of oauth2client, so you'll have to use a work around: [https://github.com/googleapis/oauth2client/issues/401]
Hey Tim, thanks a lot for the code. I was having similar problems with smtp mail sending in python, and want to try to use the gmail api instead. I downloaded your script, and replaced the client_mail, key_path variables with my those i created in google dev console. however, when i try to run the script, i get an
SSLHandshakeError
, and therefore, none of the test cases pass for me (I have pasted the full error message below). Did you have this issue at some point and am i missing some step of the process? Thanks!Traceback (most recent call last):
File "gmail_service_account_api.py", line 236, in test_create_message_bothtypes_only
gmail = Google_apps_mail(self.client_email,self.test_user,self.key_path)
File "gmail_service_account_api.py", line 65, in init
self.service = build('gmail', 'v1', http=http)
File "/Library/Python/2.7/site-packages/oauth2client/util.py", line 132, in positional_wrapper
return wrapped(_args, *_kwargs)
File "/Library/Python/2.7/site-packages/apiclient/discovery.py", line 192, in build
resp, content = http.request(requested_url)
File "/Library/Python/2.7/site-packages/oauth2client/util.py", line 132, in positional_wrapper
return wrapped(_args, *_kwargs)
File "/Library/Python/2.7/site-packages/oauth2client/client.py", line 475, in new_request
self._refresh(request_orig)
File "/Library/Python/2.7/site-packages/oauth2client/client.py", line 653, in _refresh
self._do_refresh_request(http_request)
File "/Library/Python/2.7/site-packages/oauth2client/client.py", line 682, in _do_refresh_request
self.token_uri, method='POST', body=body, headers=headers)
File "/Library/Python/2.7/site-packages/httplib2/init.py", line 1593, in request
(response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
File "/Library/Python/2.7/site-packages/httplib2/init.py", line 1335, in _request
(response, content) = self._conn_request(conn, request_uri, method, body, headers)
File "/Library/Python/2.7/site-packages/httplib2/init.py", line 1257, in _conn_request
conn.connect()
File "/Library/Python/2.7/site-packages/httplib2/init.py", line 1044, in connect
raise SSLHandshakeError(e)
SSLHandshakeError: [Errno 1] _ssl.c:504: error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure