|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2003 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a dialog to send bug reports or feature requests. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import mimetypes |
|
12 import smtplib |
|
13 import contextlib |
|
14 |
|
15 from PyQt6.QtCore import Qt, pyqtSlot |
|
16 from PyQt6.QtGui import QTextOption |
|
17 from PyQt6.QtWidgets import ( |
|
18 QHeaderView, QLineEdit, QDialog, QInputDialog, QDialogButtonBox, |
|
19 QTreeWidgetItem |
|
20 ) |
|
21 |
|
22 from EricWidgets import EricMessageBox, EricFileDialog |
|
23 from EricGui.EricOverrideCursor import EricOverrideCursor |
|
24 |
|
25 from .Ui_EmailDialog import Ui_EmailDialog |
|
26 |
|
27 from .Info import BugAddress, FeatureAddress |
|
28 import Preferences |
|
29 import Utilities |
|
30 |
|
31 from email.mime.text import MIMEText |
|
32 from email.mime.image import MIMEImage |
|
33 from email.mime.audio import MIMEAudio |
|
34 from email.mime.application import MIMEApplication |
|
35 from email.mime.multipart import MIMEMultipart |
|
36 from email.header import Header |
|
37 |
|
38 |
|
39 ############################################################ |
|
40 ## This code is to work around a bug in the Python email ## |
|
41 ## package for Image and Audio mime messages. ## |
|
42 ############################################################ |
|
43 |
|
44 |
|
45 class EmailDialog(QDialog, Ui_EmailDialog): |
|
46 """ |
|
47 Class implementing a dialog to send bug reports or feature requests. |
|
48 """ |
|
49 def __init__(self, mode="bug", parent=None): |
|
50 """ |
|
51 Constructor |
|
52 |
|
53 @param mode mode of this dialog (string, "bug" or "feature") |
|
54 @param parent parent widget of this dialog (QWidget) |
|
55 """ |
|
56 super().__init__(parent) |
|
57 self.setupUi(self) |
|
58 self.setWindowFlags(Qt.WindowType.Window) |
|
59 |
|
60 self.message.setWordWrapMode(QTextOption.WrapMode.WordWrap) |
|
61 |
|
62 self.__mode = mode |
|
63 if self.__mode == "feature": |
|
64 self.setWindowTitle(self.tr("Send feature request")) |
|
65 self.msgLabel.setText(self.tr( |
|
66 "Enter your &feature request below." |
|
67 " Version information is added automatically.")) |
|
68 self.__toAddress = FeatureAddress |
|
69 else: |
|
70 # default is bug |
|
71 self.msgLabel.setText(self.tr( |
|
72 "Enter your &bug description below." |
|
73 " Version information is added automatically.")) |
|
74 self.__toAddress = BugAddress |
|
75 |
|
76 self.sendButton = self.buttonBox.addButton( |
|
77 self.tr("Send"), QDialogButtonBox.ButtonRole.ActionRole) |
|
78 self.sendButton.setEnabled(False) |
|
79 self.sendButton.setDefault(True) |
|
80 |
|
81 self.googleHelpButton = self.buttonBox.addButton( |
|
82 self.tr("Google Mail API Help"), |
|
83 QDialogButtonBox.ButtonRole.HelpRole) |
|
84 |
|
85 height = self.height() |
|
86 self.mainSplitter.setSizes([int(0.7 * height), int(0.3 * height)]) |
|
87 |
|
88 self.attachments.headerItem().setText( |
|
89 self.attachments.columnCount(), "") |
|
90 self.attachments.header().setSectionResizeMode( |
|
91 QHeaderView.ResizeMode.Interactive) |
|
92 |
|
93 sig = Preferences.getUser("Signature") |
|
94 if sig: |
|
95 self.message.setPlainText(sig) |
|
96 cursor = self.message.textCursor() |
|
97 cursor.setPosition(0) |
|
98 self.message.setTextCursor(cursor) |
|
99 self.message.ensureCursorVisible() |
|
100 |
|
101 self.__deleteFiles = [] |
|
102 |
|
103 self.__helpDialog = None |
|
104 self.__googleMail = None |
|
105 |
|
106 def keyPressEvent(self, ev): |
|
107 """ |
|
108 Protected method to handle the user pressing the escape key. |
|
109 |
|
110 @param ev key event (QKeyEvent) |
|
111 """ |
|
112 if ev.key() == Qt.Key.Key_Escape: |
|
113 res = EricMessageBox.yesNo( |
|
114 self, |
|
115 self.tr("Close dialog"), |
|
116 self.tr("""Do you really want to close the dialog?""")) |
|
117 if res: |
|
118 self.reject() |
|
119 |
|
120 def on_buttonBox_clicked(self, button): |
|
121 """ |
|
122 Private slot called by a button of the button box clicked. |
|
123 |
|
124 @param button button that was clicked (QAbstractButton) |
|
125 """ |
|
126 if button == self.sendButton: |
|
127 self.on_sendButton_clicked() |
|
128 elif button == self.googleHelpButton: |
|
129 self.on_googleHelpButton_clicked() |
|
130 |
|
131 def on_buttonBox_rejected(self): |
|
132 """ |
|
133 Private slot to handle the rejected signal of the button box. |
|
134 """ |
|
135 res = EricMessageBox.yesNo( |
|
136 self, |
|
137 self.tr("Close dialog"), |
|
138 self.tr("""Do you really want to close the dialog?""")) |
|
139 if res: |
|
140 self.reject() |
|
141 |
|
142 @pyqtSlot() |
|
143 def on_googleHelpButton_clicked(self): |
|
144 """ |
|
145 Private slot to show some help text "how to turn on the Gmail API". |
|
146 """ |
|
147 if self.__helpDialog is None: |
|
148 try: |
|
149 from EricNetwork.EricGoogleMail import GoogleMailHelp |
|
150 helpStr = GoogleMailHelp() |
|
151 except ImportError: |
|
152 from EricNetwork.EricGoogleMailHelpers import getInstallCommand |
|
153 helpStr = self.tr( |
|
154 "<p>The Google Mail Client API is not installed." |
|
155 " Use <code>{0}</code> to install it.</p>" |
|
156 ).format(getInstallCommand()) |
|
157 |
|
158 from EricWidgets.EricSimpleHelpDialog import EricSimpleHelpDialog |
|
159 self.__helpDialog = EricSimpleHelpDialog( |
|
160 title=self.tr("Gmail API Help"), |
|
161 helpStr=helpStr, parent=self) |
|
162 |
|
163 self.__helpDialog.show() |
|
164 |
|
165 @pyqtSlot() |
|
166 def on_sendButton_clicked(self): |
|
167 """ |
|
168 Private slot to send the email message. |
|
169 """ |
|
170 msg = ( |
|
171 self.__createMultipartMail() |
|
172 if self.attachments.topLevelItemCount() else |
|
173 self.__createSimpleMail() |
|
174 ) |
|
175 |
|
176 if Preferences.getUser("UseGoogleMailOAuth2"): |
|
177 self.__sendmailGoogle(msg) |
|
178 else: |
|
179 ok = self.__sendmail(msg.as_string()) |
|
180 if ok: |
|
181 self.__deleteAttachedFiles() |
|
182 self.accept() |
|
183 |
|
184 def __deleteAttachedFiles(self): |
|
185 """ |
|
186 Private method to delete attached files. |
|
187 """ |
|
188 for f in self.__deleteFiles: |
|
189 with contextlib.suppress(OSError): |
|
190 os.remove(f) |
|
191 |
|
192 def __encodedText(self, txt): |
|
193 """ |
|
194 Private method to create a MIMEText message with correct encoding. |
|
195 |
|
196 @param txt text to be put into the MIMEText object (string) |
|
197 @return MIMEText object |
|
198 """ |
|
199 try: |
|
200 txt.encode("us-ascii") |
|
201 return MIMEText(txt) |
|
202 except UnicodeEncodeError: |
|
203 coding = Preferences.getSystem("StringEncoding") |
|
204 return MIMEText(txt.encode(coding), _charset=coding) |
|
205 |
|
206 def __encodedHeader(self, txt): |
|
207 """ |
|
208 Private method to create a correctly encoded mail header. |
|
209 |
|
210 @param txt header text to encode (string) |
|
211 @return encoded header (email.header.Header) |
|
212 """ |
|
213 try: |
|
214 txt.encode("us-ascii") |
|
215 return Header(txt) |
|
216 except UnicodeEncodeError: |
|
217 coding = Preferences.getSystem("StringEncoding") |
|
218 return Header(txt, coding) |
|
219 |
|
220 def __createSimpleMail(self): |
|
221 """ |
|
222 Private method to create a simple mail message. |
|
223 |
|
224 @return prepared mail message |
|
225 @rtype email.mime.text.MIMEText |
|
226 """ |
|
227 msgtext = "{0}\r\n----\r\n{1}\r\n----\r\n{2}\r\n----\r\n{3}".format( |
|
228 self.message.toPlainText(), |
|
229 Utilities.generateVersionInfo("\r\n"), |
|
230 Utilities.generatePluginsVersionInfo("\r\n"), |
|
231 Utilities.generateDistroInfo("\r\n")) |
|
232 |
|
233 msg = self.__encodedText(msgtext) |
|
234 msg['From'] = Preferences.getUser("Email") |
|
235 msg['To'] = self.__toAddress |
|
236 subject = '[eric7] {0}'.format(self.subject.text()) |
|
237 msg['Subject'] = self.__encodedHeader(subject) |
|
238 |
|
239 return msg |
|
240 |
|
241 def __createMultipartMail(self): |
|
242 """ |
|
243 Private method to create a multipart mail message. |
|
244 |
|
245 @return prepared mail message |
|
246 @rtype email.mime.text.MIMEMultipart |
|
247 """ |
|
248 mpPreamble = ("This is a MIME-encoded message with attachments. " |
|
249 "If you see this message, your mail client is not " |
|
250 "capable of displaying the attachments.") |
|
251 |
|
252 msgtext = "{0}\r\n----\r\n{1}\r\n----\r\n{2}\r\n----\r\n{3}".format( |
|
253 self.message.toPlainText(), |
|
254 Utilities.generateVersionInfo("\r\n"), |
|
255 Utilities.generatePluginsVersionInfo("\r\n"), |
|
256 Utilities.generateDistroInfo("\r\n")) |
|
257 |
|
258 # first part of multipart mail explains format |
|
259 msg = MIMEMultipart() |
|
260 msg['From'] = Preferences.getUser("Email") |
|
261 msg['To'] = self.__toAddress |
|
262 subject = '[eric7] {0}'.format(self.subject.text()) |
|
263 msg['Subject'] = self.__encodedHeader(subject) |
|
264 msg.preamble = mpPreamble |
|
265 msg.epilogue = '' |
|
266 |
|
267 # second part is intended to be read |
|
268 att = self.__encodedText(msgtext) |
|
269 msg.attach(att) |
|
270 |
|
271 # next parts contain the attachments |
|
272 for index in range(self.attachments.topLevelItemCount()): |
|
273 itm = self.attachments.topLevelItem(index) |
|
274 maintype, subtype = itm.text(1).split('/', 1) |
|
275 fname = itm.text(0) |
|
276 name = os.path.basename(fname) |
|
277 |
|
278 if maintype == 'text': |
|
279 with open(fname, 'r', encoding="utf-8") as f: |
|
280 txt = f.read() |
|
281 try: |
|
282 txt.encode("us-ascii") |
|
283 att = MIMEText(txt, _subtype=subtype) |
|
284 except UnicodeEncodeError: |
|
285 att = MIMEText( |
|
286 txt.encode("utf-8"), _subtype=subtype, |
|
287 _charset="utf-8") |
|
288 elif maintype == 'image': |
|
289 with open(fname, 'rb') as f: |
|
290 att = MIMEImage(f.read(), _subtype=subtype) |
|
291 elif maintype == 'audio': |
|
292 with open(fname, 'rb') as f: |
|
293 att = MIMEAudio(f.read(), _subtype=subtype) |
|
294 else: |
|
295 with open(fname, 'rb') as f: |
|
296 att = MIMEApplication(f.read()) |
|
297 att.add_header('Content-Disposition', 'attachment', filename=name) |
|
298 msg.attach(att) |
|
299 |
|
300 return msg |
|
301 |
|
302 def __sendmail(self, msg): |
|
303 """ |
|
304 Private method to actually send the message. |
|
305 |
|
306 @param msg the message to be sent (string) |
|
307 @return flag indicating success (boolean) |
|
308 """ |
|
309 try: |
|
310 encryption = Preferences.getUser("MailServerEncryption") |
|
311 if encryption == "SSL": |
|
312 server = smtplib.SMTP_SSL( |
|
313 Preferences.getUser("MailServer"), |
|
314 Preferences.getUser("MailServerPort")) |
|
315 else: |
|
316 server = smtplib.SMTP( |
|
317 Preferences.getUser("MailServer"), |
|
318 Preferences.getUser("MailServerPort")) |
|
319 if encryption == "TLS": |
|
320 server.starttls() |
|
321 if Preferences.getUser("MailServerAuthentication"): |
|
322 # mail server needs authentication |
|
323 password = Preferences.getUser("MailServerPassword") |
|
324 if not password: |
|
325 password, ok = QInputDialog.getText( |
|
326 self, |
|
327 self.tr("Mail Server Password"), |
|
328 self.tr("Enter your mail server password"), |
|
329 QLineEdit.EchoMode.Password) |
|
330 if not ok: |
|
331 # abort |
|
332 return False |
|
333 try: |
|
334 server.login(Preferences.getUser("MailServerUser"), |
|
335 password) |
|
336 except (smtplib.SMTPException, OSError) as e: |
|
337 if isinstance(e, smtplib.SMTPResponseException): |
|
338 errorStr = e.smtp_error.decode() |
|
339 elif isinstance(e, OSError): |
|
340 errorStr = e.strerror |
|
341 elif isinstance(e, OSError): |
|
342 errorStr = e[1] |
|
343 else: |
|
344 errorStr = str(e) |
|
345 res = EricMessageBox.retryAbort( |
|
346 self, |
|
347 self.tr("Send Message"), |
|
348 self.tr( |
|
349 """<p>Authentication failed.<br>Reason: {0}</p>""") |
|
350 .format(errorStr), |
|
351 EricMessageBox.Critical) |
|
352 if res: |
|
353 return self.__sendmail(msg) |
|
354 else: |
|
355 return False |
|
356 |
|
357 with EricOverrideCursor(): |
|
358 server.sendmail(Preferences.getUser("Email"), self.__toAddress, |
|
359 msg) |
|
360 server.quit() |
|
361 except (smtplib.SMTPException, OSError) as e: |
|
362 if isinstance(e, smtplib.SMTPResponseException): |
|
363 errorStr = e.smtp_error.decode() |
|
364 elif isinstance(e, smtplib.SMTPException): |
|
365 errorStr = str(e) |
|
366 elif isinstance(e, OSError): |
|
367 errorStr = e.strerror |
|
368 else: |
|
369 errorStr = str(e) |
|
370 res = EricMessageBox.retryAbort( |
|
371 self, |
|
372 self.tr("Send Message"), |
|
373 self.tr( |
|
374 """<p>Message could not be sent.<br>Reason: {0}</p>""") |
|
375 .format(errorStr), |
|
376 EricMessageBox.Critical) |
|
377 if res: |
|
378 return self.__sendmail(msg) |
|
379 else: |
|
380 return False |
|
381 return True |
|
382 |
|
383 def __sendmailGoogle(self, msg): |
|
384 """ |
|
385 Private method to actually send the message via Google Mail. |
|
386 |
|
387 @param msg email message to be sent |
|
388 @type email.mime.text.MIMEBase |
|
389 """ |
|
390 from EricNetwork.EricGoogleMail import EricGoogleMail |
|
391 |
|
392 if self.__googleMail is None: |
|
393 self.__googleMail = EricGoogleMail(self) |
|
394 self.__googleMail.sendResult.connect(self.__gmailSendResult) |
|
395 |
|
396 self.__googleMail.sendMessage(msg) |
|
397 |
|
398 @pyqtSlot(bool, str) |
|
399 def __gmailSendResult(self, ok, message): |
|
400 """ |
|
401 Private slot handling the send result reported by the Google Mail |
|
402 interface. |
|
403 |
|
404 @param ok flag indicating success |
|
405 @type bool |
|
406 @param message message from the interface |
|
407 @type str |
|
408 """ |
|
409 if ok: |
|
410 self.__deleteAttachedFiles() |
|
411 self.accept() |
|
412 else: |
|
413 # we got an error |
|
414 EricMessageBox.critical( |
|
415 self, |
|
416 self.tr("Send Message via Gmail"), |
|
417 self.tr( |
|
418 """<p>Message could not be sent.<br>Reason: {0}</p>""") |
|
419 .format(message) |
|
420 ) |
|
421 |
|
422 @pyqtSlot() |
|
423 def on_addButton_clicked(self): |
|
424 """ |
|
425 Private slot to handle the Add... button. |
|
426 """ |
|
427 fname = EricFileDialog.getOpenFileName( |
|
428 self, |
|
429 self.tr("Attach file")) |
|
430 if fname: |
|
431 self.attachFile(fname, False) |
|
432 |
|
433 def attachFile(self, fname, deleteFile): |
|
434 """ |
|
435 Public method to add an attachment. |
|
436 |
|
437 @param fname name of the file to be attached (string) |
|
438 @param deleteFile flag indicating to delete the file after it has |
|
439 been sent (boolean) |
|
440 """ |
|
441 mimeType = mimetypes.guess_type(fname)[0] |
|
442 if not mimeType: |
|
443 mimeType = "application/octet-stream" |
|
444 QTreeWidgetItem(self.attachments, [fname, mimeType]) |
|
445 self.attachments.header().resizeSections( |
|
446 QHeaderView.ResizeMode.ResizeToContents) |
|
447 self.attachments.header().setStretchLastSection(True) |
|
448 |
|
449 if deleteFile: |
|
450 self.__deleteFiles.append(fname) |
|
451 |
|
452 @pyqtSlot() |
|
453 def on_deleteButton_clicked(self): |
|
454 """ |
|
455 Private slot to handle the Delete button. |
|
456 """ |
|
457 itm = self.attachments.currentItem() |
|
458 if itm is not None: |
|
459 itm = self.attachments.takeTopLevelItem( |
|
460 self.attachments.indexOfTopLevelItem(itm)) |
|
461 del itm |
|
462 |
|
463 def on_subject_textChanged(self, txt): |
|
464 """ |
|
465 Private slot to handle the textChanged signal of the subject edit. |
|
466 |
|
467 @param txt changed text (string) |
|
468 """ |
|
469 self.sendButton.setEnabled( |
|
470 self.subject.text() != "" and |
|
471 self.message.toPlainText() != "") |
|
472 |
|
473 def on_message_textChanged(self): |
|
474 """ |
|
475 Private slot to handle the textChanged signal of the message edit. |
|
476 """ |
|
477 self.sendButton.setEnabled( |
|
478 self.subject.text() != "" and |
|
479 self.message.toPlainText() != "") |