|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2017 - 2019 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to send bug reports. |
|
8 """ |
|
9 |
|
10 from __future__ import unicode_literals |
|
11 try: |
|
12 str = unicode # __IGNORE_EXCEPTION__ |
|
13 except NameError: |
|
14 pass |
|
15 |
|
16 import os |
|
17 import sys |
|
18 import base64 |
|
19 import json |
|
20 import datetime |
|
21 |
|
22 from googleapiclient import discovery |
|
23 from google.oauth2.credentials import Credentials |
|
24 from requests_oauthlib import OAuth2Session |
|
25 |
|
26 from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QUrl |
|
27 from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout |
|
28 |
|
29 from E5Gui.E5TextInputDialog import E5TextInputDialog |
|
30 |
|
31 import Globals |
|
32 |
|
33 from .E5GoogleMailHelpers import CLIENT_SECRET_FILE, SCOPES, TOKEN_FILE, \ |
|
34 APPLICATION_NAME |
|
35 |
|
36 |
|
37 class E5GoogleMailAuthBrowser(QDialog): |
|
38 """ |
|
39 Class implementing a simple web browser to perform the OAuth2 |
|
40 authentication process. |
|
41 |
|
42 @signal approvalCodeReceived(str) emitted to indicate the receipt of the |
|
43 approval code |
|
44 """ |
|
45 approvalCodeReceived = pyqtSignal(str) |
|
46 |
|
47 def __init__(self, parent=None): |
|
48 """ |
|
49 Constructor |
|
50 |
|
51 @param parent reference to the parent widget |
|
52 @type QWidget |
|
53 """ |
|
54 super(E5GoogleMailAuthBrowser, self).__init__(parent) |
|
55 |
|
56 self.__layout = QVBoxLayout(self) |
|
57 |
|
58 try: |
|
59 from PyQt5.QtWebEngineWidgets import QWebEngineView |
|
60 self.__browser = QWebEngineView(self) |
|
61 self.__browser.titleChanged.connect(self.__titleChanged) |
|
62 self.__browser.loadFinished.connect(self.__pageLoadFinished) |
|
63 except ImportError: |
|
64 from PyQt5.QtWebKitWidgets import QWebView |
|
65 self.__browser = QWebView(self) |
|
66 self.__browser.titleChanged.connect(self.__titleChanged) |
|
67 self.__browser.loadFinished.connect(self.__pageLoadFinished) |
|
68 self.__layout.addWidget(self.__browser) |
|
69 |
|
70 self.__buttonBox = QDialogButtonBox(QDialogButtonBox.Close) |
|
71 self.__buttonBox.rejected.connect(self.reject) |
|
72 self.__layout.addWidget(self.__buttonBox) |
|
73 |
|
74 self.resize(600, 700) |
|
75 |
|
76 @pyqtSlot(str) |
|
77 def __titleChanged(self, title): |
|
78 """ |
|
79 Private slot handling changes of the web page title. |
|
80 |
|
81 @param title web page title |
|
82 @type str |
|
83 """ |
|
84 self.setWindowTitle(title) |
|
85 |
|
86 @pyqtSlot() |
|
87 def __pageLoadFinished(self): |
|
88 """ |
|
89 Private slot handling the loadFinished signal. |
|
90 """ |
|
91 url = self.__browser.url() |
|
92 if url.toString().startswith( |
|
93 "https://accounts.google.com/o/oauth2/approval/v2"): |
|
94 if Globals.qVersionTuple() >= (5, 0, 0): |
|
95 from PyQt5.QtCore import QUrlQuery |
|
96 urlQuery = QUrlQuery(url) |
|
97 approvalCode = urlQuery.queryItemValue( |
|
98 "approvalCode", QUrl.FullyDecoded) |
|
99 else: |
|
100 approvalCode = QUrl.fromPercentEncoding( |
|
101 url.encodedQueryItemValue(b"approvalCode")) |
|
102 if approvalCode: |
|
103 self.approvalCodeReceived.emit(approvalCode) |
|
104 self.close() |
|
105 |
|
106 def load(self, url): |
|
107 """ |
|
108 Public method to start the authorization flow by loading the given URL. |
|
109 |
|
110 @param url URL to be laoded |
|
111 @type str or QUrl |
|
112 """ |
|
113 self.__browser.setUrl(QUrl(url)) |
|
114 |
|
115 |
|
116 class E5GoogleMail(QObject): |
|
117 """ |
|
118 Class implementing the logic to send emails via Google Mail. |
|
119 |
|
120 @signal sendResult(bool, str) emitted to indicate the transmission result |
|
121 and a result message |
|
122 """ |
|
123 sendResult = pyqtSignal(bool, str) |
|
124 |
|
125 def __init__(self, parent=None): |
|
126 """ |
|
127 Constructor |
|
128 |
|
129 @param parent reference to the parent object |
|
130 @type QObject |
|
131 """ |
|
132 super(E5GoogleMail, self).__init__(parent=parent) |
|
133 |
|
134 self.__messages = [] |
|
135 |
|
136 self.__session = None |
|
137 self.__clientConfig = {} |
|
138 |
|
139 self.__browser = None |
|
140 |
|
141 def sendMessage(self, message): |
|
142 """ |
|
143 Public method to send a message via Google Mail. |
|
144 |
|
145 @param message email message to be sent |
|
146 @type email.mime.text.MIMEBase |
|
147 """ |
|
148 self.__messages.append(message) |
|
149 |
|
150 if not self.__session: |
|
151 self.__startSession() |
|
152 else: |
|
153 self.__doSendMessages() |
|
154 |
|
155 def __prepareMessage_v2(self, message): |
|
156 """ |
|
157 Private method to prepare the message for sending (Python2 Variant). |
|
158 |
|
159 @param message message to be prepared |
|
160 @type email.mime.text.MIMEBase |
|
161 @return prepared message dictionary |
|
162 @rtype dict |
|
163 """ |
|
164 raw = base64.urlsafe_b64encode(message.as_string()) |
|
165 return {'raw': raw} |
|
166 |
|
167 def __prepareMessage_v3(self, message): |
|
168 """ |
|
169 Private method to prepare the message for sending (Python2 Variant). |
|
170 |
|
171 @param message message to be prepared |
|
172 @type email.mime.text.MIMEBase |
|
173 @return prepared message dictionary |
|
174 @rtype dict |
|
175 """ |
|
176 messageAsBase64 = base64.urlsafe_b64encode(message.as_bytes()) |
|
177 raw = messageAsBase64.decode() |
|
178 return {'raw': raw} |
|
179 |
|
180 def __startSession(self): |
|
181 """ |
|
182 Private method to start an authorized session and optionally start the |
|
183 authorization flow. |
|
184 """ |
|
185 # check for availability of secrets file |
|
186 if not os.path.exists(os.path.join(Globals.getConfigDir(), |
|
187 CLIENT_SECRET_FILE)): |
|
188 self.sendResult.emit( |
|
189 False, |
|
190 self.tr("The client secrets file is not present. Has the Gmail" |
|
191 " API been enabled?") |
|
192 ) |
|
193 return |
|
194 |
|
195 with open(os.path.join(Globals.getConfigDir(), CLIENT_SECRET_FILE), |
|
196 "r") as clientSecret: |
|
197 clientData = json.load(clientSecret) |
|
198 self.__clientConfig = clientData['installed'] |
|
199 token = self.__loadToken() |
|
200 if token is None: |
|
201 # no valid OAuth2 token available |
|
202 self.__session = OAuth2Session( |
|
203 self.__clientConfig['client_id'], |
|
204 scope=SCOPES, |
|
205 redirect_uri=self.__clientConfig['redirect_uris'][0] |
|
206 ) |
|
207 authorizationUrl, _ = self.__session.authorization_url( |
|
208 self.__clientConfig['auth_uri'], |
|
209 access_type="offline", |
|
210 prompt="select_account" |
|
211 ) |
|
212 if self.__browser is None: |
|
213 try: |
|
214 self.__browser = E5GoogleMailAuthBrowser() |
|
215 self.__browser.approvalCodeReceived.connect( |
|
216 self.__processAuthorization) |
|
217 except ImportError: |
|
218 pass |
|
219 if self.__browser: |
|
220 self.__browser.show() |
|
221 self.__browser.load(QUrl(authorizationUrl)) |
|
222 else: |
|
223 from PyQt5.QtGui import QDesktopServices |
|
224 QDesktopServices.openUrl(QUrl(authorizationUrl)) |
|
225 ok, authCode = E5TextInputDialog.getText( |
|
226 None, |
|
227 self.tr("OAuth2 Authorization Code"), |
|
228 self.tr("Enter the OAuth2 authorization code:")) |
|
229 if ok and authCode: |
|
230 self.__processAuthorization(authCode) |
|
231 else: |
|
232 self.__session = None |
|
233 else: |
|
234 self.__session = OAuth2Session( |
|
235 self.__clientConfig['client_id'], |
|
236 scope=SCOPES, |
|
237 redirect_uri=self.__clientConfig['redirect_uris'][0], |
|
238 token=token, |
|
239 auto_refresh_kwargs={ |
|
240 'client_id': self.__clientConfig['client_id'], |
|
241 'client_secret': self.__clientConfig['client_secret'], |
|
242 }, |
|
243 auto_refresh_url=self.__clientConfig['token_uri'], |
|
244 token_updater=self.__saveToken) |
|
245 self.__doSendMessages() |
|
246 |
|
247 @pyqtSlot(str) |
|
248 def __processAuthorization(self, authCode): |
|
249 """ |
|
250 Private slot to process the received authorization code. |
|
251 |
|
252 @param authCode received authorization code |
|
253 @type str |
|
254 """ |
|
255 self.__session.fetch_token( |
|
256 self.__clientConfig['token_uri'], |
|
257 client_secret=self.__clientConfig['client_secret'], |
|
258 code=authCode) |
|
259 self.__saveToken(self.__session.token) |
|
260 |
|
261 # authorization completed; now send all queued messages |
|
262 self.__doSendMessages() |
|
263 |
|
264 def __doSendMessages(self): |
|
265 """ |
|
266 Private method to send all queued messages. |
|
267 """ |
|
268 if not self.__session: |
|
269 self.sendResult.emit( |
|
270 False, |
|
271 self.tr("No authorized session available.") |
|
272 ) |
|
273 return |
|
274 |
|
275 try: |
|
276 results = [] |
|
277 credentials = self.__credentialsFromSession() |
|
278 service = discovery.build('gmail', 'v1', credentials=credentials, |
|
279 cache_discovery=False) |
|
280 count = 0 |
|
281 while self.__messages: |
|
282 count += 1 |
|
283 message = self.__messages.pop(0) |
|
284 if sys.version_info[0] == 2: |
|
285 message1 = self.__prepareMessage_v2(message) |
|
286 else: |
|
287 message1 = self.__prepareMessage_v3(message) |
|
288 service.users().messages()\ |
|
289 .send(userId="me", body=message1).execute() |
|
290 results.append(self.tr("Message #{0} sent.").format(count)) |
|
291 |
|
292 self.sendResult.emit(True, "\n\n".join(results)) |
|
293 except Exception as error: |
|
294 self.sendResult.emit(False, str(error)) |
|
295 |
|
296 def __loadToken(self): |
|
297 """ |
|
298 Private method to load a token from the token file. |
|
299 |
|
300 @return loaded token |
|
301 @rtype dict or None |
|
302 """ |
|
303 homeDir = os.path.expanduser('~') |
|
304 credentialsDir = os.path.join(homeDir, '.credentials') |
|
305 if not os.path.exists(credentialsDir): |
|
306 os.makedirs(credentialsDir) |
|
307 tokenPath = os.path.join(credentialsDir, TOKEN_FILE) |
|
308 |
|
309 if os.path.exists(tokenPath): |
|
310 with open(tokenPath, "r") as tokenFile: |
|
311 return json.load(tokenFile) |
|
312 else: |
|
313 return None |
|
314 |
|
315 def __saveToken(self, token): |
|
316 """ |
|
317 Private method to save a token to the token file. |
|
318 |
|
319 @param token token to be saved |
|
320 @type dict |
|
321 """ |
|
322 homeDir = os.path.expanduser('~') |
|
323 credentialsDir = os.path.join(homeDir, '.credentials') |
|
324 if not os.path.exists(credentialsDir): |
|
325 os.makedirs(credentialsDir) |
|
326 tokenPath = os.path.join(credentialsDir, TOKEN_FILE) |
|
327 |
|
328 with open(tokenPath, "w") as tokenFile: |
|
329 json.dump(token, tokenFile) |
|
330 |
|
331 def __credentialsFromSession(self): |
|
332 """ |
|
333 Private method to create a credentials object. |
|
334 |
|
335 @return created credentials object |
|
336 @rtype google.oauth2.credentials.Credentials |
|
337 """ |
|
338 credentials = None |
|
339 |
|
340 if self.__clientConfig and self.__session: |
|
341 token = self.__session.token |
|
342 if token: |
|
343 credentials = Credentials( |
|
344 token['access_token'], |
|
345 refresh_token=token.get('refresh_token'), |
|
346 id_token=token.get('id_token'), |
|
347 token_uri=self.__clientConfig['token_uri'], |
|
348 client_id=self.__clientConfig['client_id'], |
|
349 client_secret=self.__clientConfig['client_secret'], |
|
350 scopes=SCOPES |
|
351 ) |
|
352 credentials.expiry = datetime.datetime.fromtimestamp( |
|
353 token['expires_at']) |
|
354 |
|
355 return credentials |
|
356 |
|
357 |
|
358 def GoogleMailHelp(): |
|
359 """ |
|
360 Module function to get some help about how to enable the Google Mail |
|
361 OAuth2 service. |
|
362 |
|
363 @return help text |
|
364 @rtype str |
|
365 """ |
|
366 return ( |
|
367 "<h2>Steps to turn on the Gmail API</h2>" |
|
368 "<ol>" |
|
369 "<li>Use <a href='{0}'>this wizard</a> to create or select a project" |
|
370 " in the Google Developers Console and automatically turn on the API." |
|
371 " Click <b>Continue</b>, then <b>Go to credentials</b>.</li>" |
|
372 "<li>At the top of the page, select the <b>OAuth consent screen</b>" |
|
373 " tab. Select an <b>Email address</b>, enter a <b>Product name</b> if" |
|
374 " not already set, and click the <b>Save</b> button.</li>" |
|
375 "<li>Select the <b>Credentials</b> tab, click the <b>Add credentials" |
|
376 "</b> button and select <b>OAuth 2.0 client ID</b>.</li>" |
|
377 "<li>Select the application type <b>Other</b>, enter the name "" |
|
378 "{1}", and click the <b>Create</b>" |
|
379 " button.</li>" |
|
380 "<li>Click <b>OK</b> to dismiss the resulting dialog.</li>" |
|
381 "<li>Click the (Download JSON) button to the right of the client ID." |
|
382 "</li>" |
|
383 "<li>Move this file to the eric configuration directory" |
|
384 " <code>{2}</code> and rename it <code>{3}</code>.</li>" |
|
385 "</ol>".format( |
|
386 "https://console.developers.google.com/start/api?id=gmail", |
|
387 APPLICATION_NAME, |
|
388 Globals.getConfigDir(), |
|
389 CLIENT_SECRET_FILE |
|
390 ) |
|
391 ) |