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