--- 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():