1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import os 10import socket 11import textwrap 12import time 13from collections import defaultdict 14from functools import partial 15from itertools import repeat 16from qt.core import ( 17 QDialog, QDialogButtonBox, QGridLayout, QIcon, QLabel, QLineEdit, QListWidget, 18 QListWidgetItem, QPushButton, Qt 19) 20from threading import Thread 21 22from calibre.constants import preferred_encoding 23from calibre.customize.ui import available_input_formats, available_output_formats 24from calibre.ebooks.metadata import authors_to_string 25from calibre.gui2 import Dispatcher, config, error_dialog, gprefs, warning_dialog 26from calibre.gui2.threaded_jobs import ThreadedJob 27from calibre.library.save_to_disk import get_components 28from calibre.utils.config import prefs, tweaks 29from calibre.utils.filenames import ascii_filename 30from calibre.utils.icu import primary_sort_key 31from calibre.utils.smtp import ( 32 compose_mail, config as email_config, extract_email_address, sendmail 33) 34from polyglot.binary import from_hex_unicode 35from polyglot.builtins import iteritems, itervalues 36 37 38class Worker(Thread): 39 40 def __init__(self, func, args): 41 Thread.__init__(self) 42 self.daemon = True 43 self.exception = self.tb = None 44 self.func, self.args = func, args 45 46 def run(self): 47 # time.sleep(1000) 48 try: 49 self.func(*self.args) 50 except Exception as e: 51 import traceback 52 self.exception = e 53 self.tb = traceback.format_exc() 54 finally: 55 self.func = self.args = None 56 57 58class Sendmail: 59 60 MAX_RETRIES = 1 61 TIMEOUT = 25 * 60 # seconds 62 63 def __init__(self): 64 self.calculate_rate_limit() 65 self.last_send_time = time.time() - self.rate_limit 66 67 def calculate_rate_limit(self): 68 self.rate_limit = 1 69 opts = email_config().parse() 70 rh = opts.relay_host 71 if rh: 72 for suffix in tweaks['public_smtp_relay_host_suffixes']: 73 if rh.lower().endswith(suffix): 74 self.rate_limit = tweaks['public_smtp_relay_delay'] 75 break 76 77 def __call__(self, attachment, aname, to, subject, text, log=None, 78 abort=None, notifications=None): 79 80 try_count = 0 81 while True: 82 if try_count > 0: 83 log('\nRetrying in %d seconds...\n' % 84 self.rate_limit) 85 worker = Worker(self.sendmail, 86 (attachment, aname, to, subject, text, log)) 87 worker.start() 88 start_time = time.time() 89 while worker.is_alive(): 90 worker.join(0.2) 91 if abort.is_set(): 92 log('Sending aborted by user') 93 return 94 if time.time() - start_time > self.TIMEOUT: 95 log('Sending timed out') 96 raise Exception( 97 'Sending email %r to %r timed out, aborting'% (subject, 98 to)) 99 if worker.exception is None: 100 log('Email successfully sent') 101 return 102 log.error('\nSending failed...\n') 103 log.debug(worker.tb) 104 try_count += 1 105 if try_count > self.MAX_RETRIES: 106 raise worker.exception 107 108 def sendmail(self, attachment, aname, to, subject, text, log): 109 logged = False 110 while time.time() - self.last_send_time <= self.rate_limit: 111 if not logged and self.rate_limit > 0: 112 log('Waiting %s seconds before sending, to avoid being marked as spam.\nYou can control this delay via Preferences->Tweaks' % self.rate_limit) 113 logged = True 114 time.sleep(1) 115 try: 116 opts = email_config().parse() 117 from_ = opts.from_ 118 if not from_: 119 from_ = 'calibre <calibre@'+socket.getfqdn()+'>' 120 with lopen(attachment, 'rb') as f: 121 msg = compose_mail(from_, to, text, subject, f, aname) 122 efrom = extract_email_address(from_) 123 eto = [] 124 for x in to.split(','): 125 eto.append(extract_email_address(x.strip())) 126 127 def safe_debug(*args, **kwargs): 128 try: 129 return log.debug(*args, **kwargs) 130 except Exception: 131 pass 132 133 sendmail(msg, efrom, eto, localhost=None, 134 verbose=1, 135 relay=opts.relay_host, 136 username=opts.relay_username, 137 password=from_hex_unicode(opts.relay_password), port=opts.relay_port, 138 encryption=opts.encryption, 139 debug_output=safe_debug) 140 finally: 141 self.last_send_time = time.time() 142 143 144gui_sendmail = Sendmail() 145 146 147def send_mails(jobnames, callback, attachments, to_s, subjects, 148 texts, attachment_names, job_manager): 149 for name, attachment, to, subject, text, aname in zip(jobnames, 150 attachments, to_s, subjects, texts, attachment_names): 151 description = _('Email %(name)s to %(to)s') % dict(name=name, to=to) 152 if isinstance(to, str) and ('@pbsync.com' in to or '@kindle.com' in to): 153 # The pbsync service chokes on non-ascii filenames 154 # Dont know if amazon's service chokes or not, but since filenames 155 # arent visible on Kindles anyway, might as well be safe 156 aname = ascii_filename(aname) 157 job = ThreadedJob('email', description, gui_sendmail, (attachment, aname, to, 158 subject, text), {}, callback) 159 job_manager.run_threaded_job(job) 160 161 162def email_news(mi, remove, get_fmts, done, job_manager): 163 opts = email_config().parse() 164 accounts = [(account, [x.strip().lower() for x in x[0].split(',')]) 165 for account, x in opts.accounts.items() if x[1]] 166 sent_mails = [] 167 for i, x in enumerate(accounts): 168 account, fmts = x 169 files = get_fmts(fmts) 170 files = [f for f in files if f is not None] 171 if not files: 172 continue 173 if opts.tags.get(account, False) and not ({t.strip() for t in opts.tags[account].split(',')} & set(mi.tags)): 174 continue 175 attachment = files[0] 176 to_s = [account] 177 subjects = [_('News:')+' '+mi.title] 178 texts = [_( 179 'Attached is the %s periodical downloaded by calibre.') % (mi.title,)] 180 attachment_names = [mi.title+os.path.splitext(attachment)[1]] 181 attachments = [attachment] 182 jobnames = [mi.title] 183 do_remove = [] 184 if i == len(accounts) - 1: 185 do_remove = remove 186 send_mails(jobnames, 187 Dispatcher(partial(done, remove=do_remove)), 188 attachments, to_s, subjects, texts, attachment_names, 189 job_manager) 190 sent_mails.append(to_s[0]) 191 return sent_mails 192 193 194plugboard_email_value = 'email' 195plugboard_email_formats = ['epub', 'mobi', 'azw3'] 196 197 198class SelectRecipients(QDialog): # {{{ 199 200 def __init__(self, parent=None): 201 QDialog.__init__(self, parent) 202 self._layout = l = QGridLayout(self) 203 self.setLayout(l) 204 self.setWindowIcon(QIcon(I('mail.png'))) 205 self.setWindowTitle(_('Select recipients')) 206 self.recipients = r = QListWidget(self) 207 l.addWidget(r, 0, 0, 1, -1) 208 self.la = la = QLabel(_('Add a new recipient:')) 209 la.setStyleSheet('QLabel { font-weight: bold }') 210 l.addWidget(la, l.rowCount(), 0, 1, -1) 211 212 self.labels = tuple(map(QLabel, ( 213 _('&Address'), _('A&lias'), _('&Formats'), _('&Subject')))) 214 tooltips = ( 215 _('The email address of the recipient'), 216 _('The optional alias (simple name) of the recipient'), 217 _('Formats to email. The first matching one will be sent (comma separated list)'), 218 _('The optional subject for email sent to this recipient')) 219 220 for i, name in enumerate(('address', 'alias', 'formats', 'subject')): 221 c = i % 2 222 row = l.rowCount() - c 223 self.labels[i].setText(str(self.labels[i].text()) + ':') 224 l.addWidget(self.labels[i], row, (2*c)) 225 le = QLineEdit(self) 226 le.setToolTip(tooltips[i]) 227 setattr(self, name, le) 228 self.labels[i].setBuddy(le) 229 l.addWidget(le, row, (2*c) + 1) 230 self.formats.setText(prefs['output_format'].upper()) 231 self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add recipient'), self) 232 b.clicked.connect(self.add_recipient) 233 l.addWidget(b, l.rowCount(), 0, 1, -1) 234 235 self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel) 236 l.addWidget(bb, l.rowCount(), 0, 1, -1) 237 bb.accepted.connect(self.accept) 238 bb.rejected.connect(self.reject) 239 self.setMinimumWidth(500) 240 self.setMinimumHeight(400) 241 self.resize(self.sizeHint()) 242 self.init_list() 243 244 def add_recipient(self): 245 to = str(self.address.text()).strip() 246 if not to: 247 return error_dialog( 248 self, _('Need address'), _('You must specify an address'), show=True) 249 formats = ','.join([x.strip().upper() for x in str(self.formats.text()).strip().split(',') if x.strip()]) 250 if not formats: 251 return error_dialog( 252 self, _('Need formats'), _('You must specify at least one format to send'), show=True) 253 opts = email_config().parse() 254 if to in opts.accounts: 255 return error_dialog( 256 self, _('Already exists'), _('The recipient %s already exists') % to, show=True) 257 acc = opts.accounts 258 acc[to] = [formats, False, False] 259 c = email_config() 260 c.set('accounts', acc) 261 alias = str(self.alias.text()).strip() 262 if alias: 263 opts.aliases[to] = alias 264 c.set('aliases', opts.aliases) 265 subject = str(self.subject.text()).strip() 266 if subject: 267 opts.subjects[to] = subject 268 c.set('subjects', opts.subjects) 269 self.create_item(alias or to, to, checked=True) 270 271 def create_item(self, alias, key, checked=False): 272 i = QListWidgetItem(alias, self.recipients) 273 i.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable) 274 i.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked) 275 i.setData(Qt.ItemDataRole.UserRole, key) 276 self.items.append(i) 277 278 def init_list(self): 279 opts = email_config().parse() 280 self.items = [] 281 282 def sk(account): 283 return primary_sort_key(opts.aliases.get(account) or account) 284 285 for key in sorted(opts.accounts or (), key=sk): 286 self.create_item(opts.aliases.get(key, key), key) 287 288 def accept(self): 289 if not self.ans: 290 return error_dialog(self, _('No recipients'), 291 _('You must select at least one recipient'), show=True) 292 QDialog.accept(self) 293 294 @property 295 def ans(self): 296 opts = email_config().parse() 297 ans = [] 298 for i in self.items: 299 if i.checkState() == Qt.CheckState.Checked: 300 to = str(i.data(Qt.ItemDataRole.UserRole) or '') 301 fmts = tuple(x.strip().upper() for x in (opts.accounts[to][0] or '').split(',')) 302 subject = opts.subjects.get(to, '') 303 ans.append((to, fmts, subject)) 304 return ans 305 306 307def select_recipients(parent=None): 308 d = SelectRecipients(parent) 309 if d.exec() == QDialog.DialogCode.Accepted: 310 return d.ans 311 return () 312# }}} 313 314 315class EmailMixin: # {{{ 316 317 def __init__(self, *args, **kwargs): 318 pass 319 320 def send_multiple_by_mail(self, recipients, delete_from_library): 321 ids = {self.library_view.model().id(r) for r in self.library_view.selectionModel().selectedRows()} 322 if not ids: 323 return 324 db = self.current_db 325 db_fmt_map = {book_id:set((db.formats(book_id, index_is_id=True) or '').upper().split(',')) for book_id in ids} 326 ofmts = {x.upper() for x in available_output_formats()} 327 ifmts = {x.upper() for x in available_input_formats()} 328 bad_recipients = {} 329 auto_convert_map = defaultdict(list) 330 331 for to, fmts, subject in recipients: 332 rfmts = set(fmts) 333 ok_ids = {book_id for book_id, bfmts in iteritems(db_fmt_map) if bfmts.intersection(rfmts)} 334 convert_ids = ids - ok_ids 335 self.send_by_mail(to, fmts, delete_from_library, subject=subject, send_ids=ok_ids, do_auto_convert=False) 336 if not rfmts.intersection(ofmts): 337 bad_recipients[to] = (convert_ids, True) 338 continue 339 outfmt = tuple(f for f in fmts if f in ofmts)[0] 340 ok_ids = {book_id for book_id in convert_ids if db_fmt_map[book_id].intersection(ifmts)} 341 bad_ids = convert_ids - ok_ids 342 if bad_ids: 343 bad_recipients[to] = (bad_ids, False) 344 if ok_ids: 345 auto_convert_map[outfmt].append((to, subject, ok_ids)) 346 347 if auto_convert_map: 348 titles = {book_id for x in itervalues(auto_convert_map) for data in x for book_id in data[2]} 349 titles = {db.title(book_id, index_is_id=True) for book_id in titles} 350 if self.auto_convert_question( 351 _('Auto convert the following books before sending via email?'), list(titles)): 352 for ofmt, data in iteritems(auto_convert_map): 353 ids = {bid for x in data for bid in x[2]} 354 data = [(to, subject) for to, subject, x in data] 355 self.iactions['Convert Books'].auto_convert_multiple_mail(ids, data, ofmt, delete_from_library) 356 357 if bad_recipients: 358 det_msg = [] 359 titles = {book_id for x in itervalues(bad_recipients) for book_id in x[0]} 360 titles = {book_id:db.title(book_id, index_is_id=True) for book_id in titles} 361 for to, (ids, nooutput) in iteritems(bad_recipients): 362 msg = _('This recipient has no valid formats defined') if nooutput else \ 363 _('These books have no suitable input formats for conversion') 364 det_msg.append('%s - %s' % (to, msg)) 365 det_msg.extend('\t' + titles[bid] for bid in ids) 366 det_msg.append('\n') 367 warning_dialog(self, _('Could not send'), 368 _('Could not send books to some recipients. Click "Show details" for more information'), 369 det_msg='\n'.join(det_msg), show=True) 370 371 def send_by_mail(self, to, fmts, delete_from_library, subject='', send_ids=None, 372 do_auto_convert=True, specific_format=None): 373 ids = [self.library_view.model().id(r) for r in self.library_view.selectionModel().selectedRows()] if send_ids is None else send_ids 374 if not ids or len(ids) == 0: 375 return 376 377 files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(ids, 378 fmts, set_metadata=True, 379 specific_format=specific_format, 380 exclude_auto=do_auto_convert, 381 use_plugboard=plugboard_email_value, 382 plugboard_formats=plugboard_email_formats) 383 if do_auto_convert: 384 nids = list(set(ids).difference(_auto_ids)) 385 ids = [i for i in ids if i in nids] 386 else: 387 _auto_ids = [] 388 389 full_metadata = self.library_view.model().metadata_for(ids, 390 get_cover=False) 391 392 bad, remove_ids, jobnames = [], [], [] 393 texts, subjects, attachments, attachment_names = [], [], [], [] 394 for f, mi, id in zip(files, full_metadata, ids): 395 t = mi.title 396 if not t: 397 t = _('Unknown') 398 if f is None: 399 bad.append(t) 400 else: 401 remove_ids.append(id) 402 jobnames.append(t) 403 attachments.append(f) 404 if not subject: 405 subjects.append(_('E-book:')+ ' '+t) 406 else: 407 components = get_components(subject, mi, id) 408 if not components: 409 components = [mi.title] 410 subjects.append(os.path.join(*components)) 411 a = authors_to_string(mi.authors if mi.authors else 412 [_('Unknown')]) 413 texts.append(_('Attached, you will find the e-book') + 414 '\n\n' + t + '\n\t' + _('by') + ' ' + a + '\n\n' + 415 _('in the %s format.') % 416 os.path.splitext(f)[1][1:].upper()) 417 if mi.comments and gprefs['add_comments_to_email']: 418 from calibre.utils.html2text import html2text 419 texts[-1] += '\n\n' + _('About this book:') + '\n\n' + textwrap.fill(html2text(mi.comments)) 420 prefix = f'{t} - {a}' 421 if not isinstance(prefix, str): 422 prefix = prefix.decode(preferred_encoding, 'replace') 423 attachment_names.append(prefix + os.path.splitext(f)[1]) 424 remove = remove_ids if delete_from_library else [] 425 426 to_s = list(repeat(to, len(attachments))) 427 if attachments: 428 send_mails(jobnames, 429 Dispatcher(partial(self.email_sent, remove=remove)), 430 attachments, to_s, subjects, texts, attachment_names, 431 self.job_manager) 432 self.status_bar.show_message(_('Sending email to')+' '+to, 3000) 433 434 auto = [] 435 if _auto_ids != []: 436 for id in _auto_ids: 437 if specific_format is None: 438 dbfmts = self.library_view.model().db.formats(id, index_is_id=True) 439 formats = [f.lower() for f in (dbfmts.split(',') if dbfmts else 440 [])] 441 if set(formats).intersection(available_input_formats()) and set(fmts).intersection(available_output_formats()): 442 auto.append(id) 443 else: 444 bad.append(self.library_view.model().db.title(id, index_is_id=True)) 445 else: 446 if specific_format in list(set(fmts).intersection(set(available_output_formats()))): 447 auto.append(id) 448 else: 449 bad.append(self.library_view.model().db.title(id, index_is_id=True)) 450 451 if auto != []: 452 format = specific_format if specific_format in list(set(fmts).intersection(set(available_output_formats()))) else None 453 if not format: 454 for fmt in fmts: 455 if fmt in list(set(fmts).intersection(set(available_output_formats()))): 456 format = fmt 457 break 458 if format is None: 459 bad += auto 460 else: 461 autos = [self.library_view.model().db.title(id, index_is_id=True) for id in auto] 462 if self.auto_convert_question( 463 _('Auto convert the following books to %s before sending via ' 464 'email?') % format.upper(), autos): 465 self.iactions['Convert Books'].auto_convert_mail(to, fmts, delete_from_library, auto, format, subject) 466 467 if bad: 468 bad = '\n'.join('%s'%(i,) for i in bad) 469 d = warning_dialog(self, _('No suitable formats'), 470 _('Could not email the following books ' 471 'as no suitable formats were found:'), bad) 472 d.exec() 473 474 def email_sent(self, job, remove=[]): 475 if job.failed: 476 self.job_exception(job, dialog_title=_('Failed to email book')) 477 return 478 479 self.status_bar.show_message(job.description + ' ' + _('sent'), 480 5000) 481 if remove: 482 try: 483 next_id = self.library_view.next_id 484 self.library_view.model().delete_books_by_id(remove) 485 self.iactions['Remove Books'].library_ids_deleted2(remove, 486 next_id=next_id) 487 except: 488 import traceback 489 490 # Probably the user deleted the files, in any case, failing 491 # to delete the book is not catastrophic 492 traceback.print_exc() 493 494 def email_news(self, id_): 495 mi = self.library_view.model().db.get_metadata(id_, 496 index_is_id=True) 497 remove = [id_] if config['delete_news_from_library_on_upload'] \ 498 else [] 499 500 def get_fmts(fmts): 501 files, auto = self.library_view.model().\ 502 get_preferred_formats_from_ids([id_], fmts, 503 set_metadata=True, 504 use_plugboard=plugboard_email_value, 505 plugboard_formats=plugboard_email_formats) 506 return files 507 sent_mails = email_news(mi, remove, 508 get_fmts, self.email_sent, self.job_manager) 509 if sent_mails: 510 self.status_bar.show_message(_('Sent news to')+' '+ 511 ', '.join(sent_mails), 3000) 512 513# }}} 514 515 516if __name__ == '__main__': 517 from qt.core import QApplication 518 app = QApplication([]) # noqa 519 print(select_recipients()) 520