src/eric7/EricNetwork/EricGoogleMail.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
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 &quot;"
346 "{1}&quot;, 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 )

eric ide

mercurial