src/eric7/EricNetwork/EricGoogleMail.py

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

eric ide

mercurial