E5Network/E5GoogleMail.py

changeset 6828
bb6667ea9ae7
parent 6825
e659bb96cdfa
child 6838
cd9b76b2967a
--- a/E5Network/E5GoogleMail.py	Sat Mar 02 11:12:25 2019 +0100
+++ b/E5Network/E5GoogleMail.py	Sat Mar 02 17:33:58 2019 +0100
@@ -16,125 +16,346 @@
 import os
 import sys
 import base64
-import pickle
+import json
+import datetime
 
 from googleapiclient import discovery
-from google_auth_oauthlib.flow import InstalledAppFlow
-from google.auth.transport.requests import Request
+from google.oauth2.credentials import Credentials
+from requests_oauthlib import OAuth2Session
 
-from PyQt5.QtCore import QCoreApplication
+from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl
+from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
+
+from E5Gui.E5TextInputDialog import E5TextInputDialog
 
 import Globals
 
-
-SCOPES = 'https://www.googleapis.com/auth/gmail.send'
-CLIENT_SECRET_FILE = 'eric_client_secret.json'
-CREDENTIALS_FILE = 'eric-python-email-send.pickle'
-APPLICATION_NAME = 'Eric Python Send Email'
+from .E5GoogleMailHelpers import CLIENT_SECRET_FILE, SCOPES, TOKEN_FILE, \
+    APPLICATION_NAME
 
 
-def isClientSecretFileAvailable():
-    """
-    Module function to check, if the client secret file has been installed.
-    
-    @return flag indicating, that the credentials file is there
-    @rtype bool
-    """
-    return os.path.exists(
-        os.path.join(Globals.getConfigDir(), CLIENT_SECRET_FILE))
-
-
-def getCredentials():
+class E5GoogleMailAuthBrowser(QDialog):
     """
     Module function to get the Google credentials.
     
+    Class implementing a simple web browser to perform the OAuth2
+    authentication process.
+    
     @return Google Mail credentials
+    @signal approvalCodeReceived(str) emitted to indicate the receipt of the
+        approval code
     """
-    homeDir = os.path.expanduser('~')
-    credentialsDir = os.path.join(homeDir, '.credentials')
-    if not os.path.exists(credentialsDir):
-        os.makedirs(credentialsDir)
-    credentialsPath = os.path.join(credentialsDir, CREDENTIALS_FILE)
-
-    credentials = None
-    # The file eric-python-email-send.pickle stores the user's access and
-    # refresh tokens, and is created automatically when the authorization
-    # flow completes for the first time.
-    if os.path.exists(credentialsPath):
-        with open(credentialsPath, 'rb') as token:
-            credentials = pickle.load(token)
-    # If there are no (valid) credentials available, let the user log in.
-    if not credentials or not credentials.valid:
-        if credentials and credentials.expired and credentials.refresh_token:
-            credentials.refresh(Request())
-        else:
-            flow = InstalledAppFlow.from_client_secrets_file(
-                os.path.join(Globals.getConfigDir(), CLIENT_SECRET_FILE),
-                SCOPES)
-            credentials = flow.run_local_server()
-        # Save the credentials for the next run
-        with open(credentialsPath, 'wb') as credentialsFile:
-            pickle.dump(credentials, credentialsFile)
-    return credentials
+    approvalCodeReceived = pyqtSignal(str)
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super(E5GoogleMailAuthBrowser, self).__init__(parent)
+        
+        self.__layout = QVBoxLayout(self)
+        
+        try:
+            from PyQt5.QtWebEngineWidgets import QWebEngineView
+            self.__browser = QWebEngineView(self)
+            self.__browser.titleChanged.connect(self.__titleChanged)
+            self.__browser.loadFinished.connect(self.__pageLoadFinished)
+        except ImportError:
+            from PyQt5.QtWebKitWidgets import QWebView
+            self.__browser = QWebView(self)
+            self.__browser.titleChanged.connect(self.__titleChanged)
+            self.__browser.loadFinished.connect(self.__pageLoadFinished)
+        self.__layout.addWidget(self.__browser)
+        
+        self.__buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
+        self.__buttonBox.rejected.connect(self.reject)
+        self.__layout.addWidget(self.__buttonBox)
+        
+        self.resize(600, 700)
+    
+    @pyqtSlot(str)
+    def __titleChanged(self, title):
+        """
+        Private slot handling changes of the web page title.
+        
+        @param title web page title
+        @type str
+        """
+        self.setWindowTitle(title)
+    
+    @pyqtSlot()
+    def __pageLoadFinished(self):
+        """
+        Private slot handling the loadFinished signal.
+        """
+        url = self.__browser.url()
+        if url.toString().startswith(
+                "https://accounts.google.com/o/oauth2/approval/v2"):
+            if Globals.qVersionTuple() >= (5, 0, 0):
+                from PyQt5.QtCore import QUrlQuery
+                urlQuery = QUrlQuery(url)
+                approvalCode = urlQuery.queryItemValue(
+                    "approvalCode", QUrl.FullyDecoded)
+            else:
+                approvalCode = QUrl.fromPercentEncoding(
+                    url.encodedQueryItemValue(b"approvalCode"))
+            if approvalCode:
+                self.approvalCodeReceived.emit(approvalCode)
+                self.close()
+    
+    def load(self, url):
+        """
+        Public method to start the authorization flow by loading the given URL.
+        
+        @param url URL to be laoded
+        @type str or QUrl
+        """
+        self.__browser.setUrl(QUrl(url))
 
 
-def GoogleMailSendMessage(message):
+class E5GoogleMail(QObject):
     """
-    Module function to send an email message via Google Mail.
+    Class implementing the logic to send emails via Google Mail.
     
-    @param message email message to be sent
-    @type email.mime.text.MIMEBase
-    @return tuple containing a success flag and a result or error message
-    @rtype tuple of (bool, str)
+    @signal sendResult(bool, str) emitted to indicate the transmission result
+        and a result message
     """
-    # check for secrets file first
-    if not os.path.exists(os.path.join(Globals.getConfigDir(),
-                                       CLIENT_SECRET_FILE)):
-        return False, QCoreApplication.translate(
-            "GoogleMailSendMessage",
-            "The credentials file is not present. Has the Gmail API"
-            " been enabled?")
+    sendResult = pyqtSignal(bool, str)
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent object
+        @type QObject
+        """
+        super(E5GoogleMail, self).__init__(parent=parent)
+        
+        self.__messages = []
+        
+        self.__session = None
+        self.__clientConfig = {}
+        
+        self.__browser = None
+    
+    def sendMessage(self, message):
+        """
+        Public method to send a message via Google Mail.
+        
+        @param message email message to be sent
+        @type email.mime.text.MIMEBase
+        """
+        self.__messages.append(message)
+        
+        if not self.__session:
+            self.__startSession()
+        else:
+            self.__doSendMessages()
+    
+    def __prepareMessage_v2(self, message):
+        """
+        Private method to prepare the message for sending (Python2 Variant).
+        
+        @param message message to be prepared
+        @type email.mime.text.MIMEBase
+        @return prepared message dictionary
+        @rtype dict
+        """
+        raw = base64.urlsafe_b64encode(message.as_string())
+        return {'raw': raw}
+    
+    def __prepareMessage_v3(self, message):
+        """
+        Private method to prepare the message for sending (Python2 Variant).
+        
+        @param message message to be prepared
+        @type email.mime.text.MIMEBase
+        @return prepared message dictionary
+        @rtype dict
+        """
+        messageAsBase64 = base64.urlsafe_b64encode(message.as_bytes())
+        raw = messageAsBase64.decode()
+        return {'raw': raw}
     
-    try:
-        credentials = getCredentials()
-        service = discovery.build('gmail', 'v1', credentials=credentials)
-        if sys.version_info[0] == 2:
-            message1 = _prepareMessage_v2(message)
+    def __startSession(self):
+        """
+        Private method to start an authorized session and optionally start the
+        authorization flow.
+        """
+        # check for availability of secrets file
+        if not os.path.exists(os.path.join(Globals.getConfigDir(),
+                                           CLIENT_SECRET_FILE)):
+            self.sendResult.emit(
+                False,
+                self.tr("The client secrets file is not present. Has the Gmail"
+                        " API been enabled?")
+            )
+            return
+        
+        with open(os.path.join(Globals.getConfigDir(), CLIENT_SECRET_FILE),
+                  "r") as clientSecret:
+            clientData = json.load(clientSecret)
+            self.__clientConfig = clientData['installed']
+        token = self.__loadToken()
+        if token is None:
+            # no valid OAuth2 token available
+            self.__session = OAuth2Session(
+                self.__clientConfig['client_id'],
+                scope=SCOPES,
+                redirect_uri=self.__clientConfig['redirect_uris'][0]
+            )
+            authorizationUrl, _ = self.__session.authorization_url(
+                self.__clientConfig['auth_uri'],
+                access_type="offline",
+                prompt="select_account"
+            )
+            if self.__browser is None:
+                try:
+                    self.__browser = E5GoogleMailAuthBrowser()
+                    self.__browser.approvalCodeReceived.connect(
+                        self.__processAuthorization)
+                except ImportError:
+                    pass
+            if self.__browser:
+                self.__browser.show()
+                self.__browser.load(QUrl(authorizationUrl))
+            else:
+                from PyQt5.QtGui import QDesktopServices
+                QDesktopServices.openUrl(QUrl(authorizationUrl))
+                ok, authCode = E5TextInputDialog.getText(
+                    None,
+                    self.tr("OAuth2 Authorization Code"),
+                    self.tr("Enter the OAuth2 authorization code:"))
+                if ok and authCode:
+                    self.__processAuthorization(authCode)
+                else:
+                    self.__session = None
         else:
-            message1 = _prepareMessage_v3(message)
-        result = service.users().messages()\
-            .send(userId="me", body=message1).execute()
+            self.__session = OAuth2Session(
+                self.__clientConfig['client_id'],
+                scope=SCOPES,
+                redirect_uri=self.__clientConfig['redirect_uris'][0],
+                token=token,
+                auto_refresh_kwargs={
+                    'client_id': self.__clientConfig['client_id'],
+                    'client_secret': self.__clientConfig['client_secret'],
+                },
+                auto_refresh_url=self.__clientConfig['token_uri'],
+                token_updater=self.__saveToken)
+            self.__doSendMessages()
+    
+    @pyqtSlot(str)
+    def __processAuthorization(self, authCode):
+        """
+        Private slot to process the received authorization code.
+        
+        @param authCode received authorization code
+        @type str
+        """
+        self.__session.fetch_token(
+            self.__clientConfig['token_uri'],
+            client_secret=self.__clientConfig['client_secret'],
+            code=authCode)
+        self.__saveToken(self.__session.token)
+        
+        # authorization completed; now send all queued messages
+        self.__doSendMessages()
+    
+    def __doSendMessages(self):
+        """
+        Private method to send all queued messages.
+        """
+        if not self.__session:
+            self.sendResult.emit(
+                False,
+                self.tr("No authorized session available.")
+            )
+            return
+        
+        try:
+            results = []
+            credentials = self.__credentialsFromSession()
+            service = discovery.build('gmail', 'v1', credentials=credentials,
+                                      cache_discovery=False)
+            count = 0
+            while self.__messages:
+                count += 1
+                message = self.__messages.pop(0)
+                if sys.version_info[0] == 2:
+                    message1 = self.__prepareMessage_v2(message)
+                else:
+                    message1 = self.__prepareMessage_v3(message)
+                service.users().messages()\
+                    .send(userId="me", body=message1).execute()
+                results.append(self.tr("Message #{0} sent.").format(count))
 
-        return True, result
-    except Exception as error:
-        return False, str(error)
-
-
-def _prepareMessage_v2(message):
-    """
-    Module function to prepare the message for sending (Python2 Variant).
+            self.sendResult.emit(True, "\n\n".join(results))
+        except Exception as error:
+            self.sendResult.emit(False, str(error))
+    
+    def __loadToken(self):
+        """
+        Private method to load a token from the token file.
+        
+        @return loaded token
+        @rtype dict or None
+        """
+        homeDir = os.path.expanduser('~')
+        credentialsDir = os.path.join(homeDir, '.credentials')
+        if not os.path.exists(credentialsDir):
+            os.makedirs(credentialsDir)
+        tokenPath = os.path.join(credentialsDir, TOKEN_FILE)
+        
+        if os.path.exists(tokenPath):
+            with open(tokenPath, "r") as tokenFile:
+                return json.load(tokenFile)
+        else:
+            return None
     
-    @param message message to be prepared
-    @type email.mime.text.MIMEBase
-    @return prepared message dictionary
-    @rtype dict
-    """
-    raw = base64.urlsafe_b64encode(message.as_string())
-    return {'raw': raw}
-
-
-def _prepareMessage_v3(message):
-    """
-    Module function to prepare the message for sending (Python2 Variant).
+    def __saveToken(self, token):
+        """
+        Private method to save a token to the token file.
+        
+        @param token token to be saved
+        @type dict
+        """
+        homeDir = os.path.expanduser('~')
+        credentialsDir = os.path.join(homeDir, '.credentials')
+        if not os.path.exists(credentialsDir):
+            os.makedirs(credentialsDir)
+        tokenPath = os.path.join(credentialsDir, TOKEN_FILE)
+        
+        with open(tokenPath, "w") as tokenFile:
+            json.dump(token, tokenFile)
     
-    @param message message to be prepared
-    @type email.mime.text.MIMEBase
-    @return prepared message dictionary
-    @rtype dict
-    """
-    messageAsBase64 = base64.urlsafe_b64encode(message.as_bytes())
-    raw = messageAsBase64.decode()
-    return {'raw': raw}
+    def __credentialsFromSession(self):
+        """
+        Private method to create a credentials object.
+        
+        @return created credentials object
+        @rtype google.oauth2.credentials.Credentials
+        """
+        credentials = None
+        
+        if self.__clientConfig and self.__session:
+            token = self.__session.token
+            if token:
+                credentials = Credentials(
+                    token['access_token'],
+                    refresh_token=token.get('refresh_token'),
+                    id_token=token.get('id_token'),
+                    token_uri=self.__clientConfig['token_uri'],
+                    client_id=self.__clientConfig['client_id'],
+                    client_secret=self.__clientConfig['client_secret'],
+                    scopes=SCOPES
+                )
+                credentials.expiry = datetime.datetime.fromtimestamp(
+                    token['expires_at'])
+        
+        return credentials
 
 
 def GoogleMailHelp():

eric ide

mercurial