diff --git a/django_mailbox/google_utils.py b/django_mailbox/google_utils.py new file mode 100644 index 0000000..9900b70 --- /dev/null +++ b/django_mailbox/google_utils.py @@ -0,0 +1,126 @@ +from social.apps.django_app.default.models import UserSocialAuth +import requests +from django.conf import settings + + +class AccessTokenNotFound(Exception): + pass + + +class RefreshTokenNotFound(Exception): + pass + + +def get_google_consumer_key(): + return settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY + + +def get_google_consumer_secret(): + return settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET + + +def get_google_access_token(email): + # TODO: This should be cacheable + try: + me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") + return me.extra_data['access_token'] + except (UserSocialAuth.DoesNotExist, KeyError): + raise AccessTokenNotFound + + +def update_google_extra_data(email, extra_data): + try: + me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") + me.extra_data = extra_data + me.save() + except (UserSocialAuth.DoesNotExist, KeyError): + raise AccessTokenNotFound + + +def get_google_refresh_token(email): + try: + me = UserSocialAuth.objects.get(uid=email, provider="google-oauth2") + return me.extra_data['refresh_token'] + except (UserSocialAuth.DoesNotExist, KeyError): + raise RefreshTokenNotFound + + +def google_api_get(email, url): + headers = dict( + Authorization="Bearer %s" % get_google_access_token(email), + ) + r = requests.get(url, headers=headers) + print "I got a %s" % r.status_code + if r.status_code == 401: + # Go use the refresh token + refresh_authorization(email) + r = requests.get(url, headers=headers) + print "I got a %s" % r.status_code + if r.status_code == 200: + try: + return r.json() + except ValueError: + print "returning text" + return r.text + + +def google_api_post(email, url, post_data, authorized=True): + # TODO: Make this a lot less ugly. especially the 401 handling + headers = dict() + if authorized is True: + headers.update(dict( + Authorization="Bearer %s" % get_google_access_token(email), + )) + r = requests.post(url, headers=headers, data=post_data) + if r.status_code == 401: + refresh_authorization(email) + r = requests.post(url, headers=headers, data=post_data) + if r.status_code == 200: + try: + return r.json() + except ValueError: + print "returning text" + return r.text + + +def refresh_authorization(email): + refresh_token = get_google_refresh_token(email) + post_data = dict( + refresh_token=refresh_token, + client_id=get_google_consumer_key(), + client_secret=get_google_consumer_secret(), + grant_type='refresh_token', + ) + results = google_api_post( + email, + "https://accounts.google.com/o/oauth2/token?access_type=offline", + post_data, + authorized=False) + results.update({'refresh_token': refresh_token}) + update_google_extra_data(email, results) + + +def fetch_user_info(email): + result = google_api_get( + email, + "https://www.googleapis.com/oauth2/v1/userinfo?alt=json" + ) + return result + + +def fetch_google_contacts(email, limit=10000): + result = google_api_get( + email, + "https://www.google.com/m8/feeds/contacts/default/full?v=3.0&alt=json&max-results=%s" % limit + ) + entries = result['feed']['entry'] + valid_entries = [x for x in entries if u'gd$email' in x.keys() and u'gd$name' in x.keys()] + contacts = [] + for each in valid_entries: + try: + name = each[u'gd$name'][u'gd$fullName'][u'$t'] + except KeyError: + name = None + for each_email in each[u'gd$email']: + contacts.append(dict(name=name, email=each_email[u'address'])) + return contacts diff --git a/django_mailbox/models.py b/django_mailbox/models.py index f4b5dc6..4e3fef4 100644 --- a/django_mailbox/models.py +++ b/django_mailbox/models.py @@ -15,7 +15,7 @@ from django.core.files.base import ContentFile from django.db import models from django_mailbox.transports import Pop3Transport, ImapTransport,\ MaildirTransport, MboxTransport, BabylTransport, MHTransport, \ - MMDFTransport + MMDFTransport, GmailImapTransport from django_mailbox.signals import message_received import six from six.moves.urllib.parse import parse_qs, unquote, urlparse @@ -168,6 +168,14 @@ class Mailbox(models.Model): archive=self.archive ) conn.connect(self.username, self.password) + elif self.type == 'gmail': + conn = GmailImapTransport( + self.location, + port=self.port if self.port else None, + ssl=True, + archive=self.archive + ) + conn.connect(self.username, self.password) elif self.type == 'pop3': conn = Pop3Transport( self.location, diff --git a/django_mailbox/transports/__init__.py b/django_mailbox/transports/__init__.py index fc17082..03b31f0 100644 --- a/django_mailbox/transports/__init__.py +++ b/django_mailbox/transports/__init__.py @@ -5,3 +5,4 @@ from django_mailbox.transports.mbox import MboxTransport from django_mailbox.transports.babyl import BabylTransport from django_mailbox.transports.mh import MHTransport from django_mailbox.transports.mmdf import MMDFTransport +from django_mailbox.transports.gmail import GmailImapTransport diff --git a/django_mailbox/transports/gmail.py b/django_mailbox/transports/gmail.py new file mode 100644 index 0000000..e477293 --- /dev/null +++ b/django_mailbox/transports/gmail.py @@ -0,0 +1,49 @@ +from django_mailbox.transports.imap import ImapTransport + +class GmailImapTransport(ImapTransport): + + def connect(self, username, password): + # Try to use oauth2 first. It's much safer + try: + self._connect_oauth(username) + except (TypeError, ValueError) as e: + print " Couldn't do oauth2 because %s" % e + self.server = self.transport(self.hostname, self.port) + typ, msg = self.server.login(username, password) + self.server.select() + + def _connect_oauth(self, username): + # username should be an email address that has already been authorized + # for gmail access + try: + from django_mailbox.google_utils import ( + get_google_access_token, + fetch_user_info, + AccessTokenNotFound, + ) + except ImportError: + raise ValueError( + "Install python-social-auth to use oauth2 auth for gmail" + ) + + access_token = None + while access_token is None: + try: + access_token = get_google_access_token(username) + google_email_address = fetch_user_info(username)['email'] + except TypeError: + # This means that the google process took too long + # Trying again is the right thing to do + pass + except AccessTokenNotFound: + raise ValueError( + "No Token available in python-social-auth for %s" % username + ) + + auth_string = 'user=%s\1auth=Bearer %s\1\1' % ( + google_email_address, + access_token + ) + self.server = self.transport(self.hostname, self.port) + self.server.authenticate('XOAUTH2', lambda x: auth_string) + self.server.select() diff --git a/docs/topics/mailbox_types.rst b/docs/topics/mailbox_types.rst index 014960d..bb8122c 100644 --- a/docs/topics/mailbox_types.rst +++ b/docs/topics/mailbox_types.rst @@ -7,18 +7,19 @@ POP3 and IMAP as well as local file-based mailboxes. .. table:: 'Protocol' Options - ============ ============== ================================================================================================================================================================= - Mailbox Type 'Protocol':// Notes - ============ ============== ================================================================================================================================================================= - POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` - IMAP ``imap://`` Can also specify SSL with ``imap+ssl://``, or specify a folder to save processed messages into by appending ``?archive=my_archive_folder`` to the end of the URI. + ============ ================ ================================================================================================================================================================= + Mailbox Type 'Protocol':// Notes + ============ ================ ================================================================================================================================================================= + POP3 ``pop3://`` Can also specify SSL with ``pop3+ssl://`` + IMAP ``imap://`` Can also specify SSL with ``imap+ssl://``, or specify a folder to save processed messages into by appending ``?archive=my_archive_folder`` to the end of the URI. + Gmail IMAP ``gmail+ssl://`` Password is only used as a fallback if oauth2 fails Maildir ``maildir://`` Mbox ``mbox://`` Babyl ``babyl://`` MH ``mh://`` MMDF ``mmdf://`` - Piped Mail *empty* See :ref:`receiving-mail-from-exim4-or-postfix` - ============ ============== ================================================================================================================================================================= + Piped Mail *empty* See :ref:`receiving-mail-from-exim4-or-postfix` + ============ ================ ================================================================================================================================================================= .. warning:: @@ -61,6 +62,20 @@ would enter the following as your URI:: imap+ssl://youremailaddress%40gmail.com:1234@imap.gmail.com?archive=Archived +Gmail IMAP with Oauth2 authentication +------------------------------------- + +Gmail supports using oauth2 for authentication_ which is more secure. +To handle the handshake and storing the credentials, use python-social-auth_. + +.. _authentication: https://developers.google.com/gmail/xoauth2_protocol +.. _python-social-auth: http://psa.matiasaguirre.net/ + +The Gmail Mailbox is also a regular IMAP mailbox, but the password will be ignored if oauth2 succeeds. It can fall back to password as needed. +Build your URI accordingly:: + + gmail+ssl://youremailaddress%40gmail.com:oauth2@imap.gmail.com?archive=Archived + Local File-based Mailboxes -------------------------- diff --git a/setup.py b/setup.py index c080a93..1f3800b 100755 --- a/setup.py +++ b/setup.py @@ -5,6 +5,10 @@ tests_require = [ 'mock', ] +gmail_oauth2_require = [ + 'python-social-auth', +] + setup( name='django-mailbox', version='3.3', @@ -16,7 +20,10 @@ setup( author='Adam Coddington', author_email='me@adamcoddington.net', tests_require=tests_require, - extras_require={'test': tests_require}, + extras_require={ + 'test': tests_require, + 'gmail-oauth2': gmail_oauth2_require + }, test_suite='django_mailbox.runtests.runtests', classifiers=[ 'Framework :: Django',