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