%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/ |
| Current File : //lib/calibre/calibre/gui2/email.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os
import socket
import textwrap
import time
from collections import defaultdict
from functools import partial
from itertools import repeat
from qt.core import (
QDialog, QDialogButtonBox, QGridLayout, QIcon, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QPushButton, Qt
)
from threading import Thread
from calibre.constants import preferred_encoding
from calibre.customize.ui import available_input_formats, available_output_formats
from calibre.ebooks.metadata import authors_to_string
from calibre.gui2 import Dispatcher, config, error_dialog, gprefs, warning_dialog
from calibre.gui2.threaded_jobs import ThreadedJob
from calibre.library.save_to_disk import get_components
from calibre.utils.config import prefs, tweaks
from calibre.utils.filenames import ascii_filename
from calibre.utils.icu import primary_sort_key
from calibre.utils.smtp import (
compose_mail, config as email_config, extract_email_address, sendmail
)
from polyglot.binary import from_hex_unicode
from polyglot.builtins import iteritems, itervalues
class Worker(Thread):
def __init__(self, func, args):
Thread.__init__(self)
self.daemon = True
self.exception = self.tb = None
self.func, self.args = func, args
def run(self):
# time.sleep(1000)
try:
self.func(*self.args)
except Exception as e:
import traceback
self.exception = e
self.tb = traceback.format_exc()
finally:
self.func = self.args = None
class Sendmail:
MAX_RETRIES = 1
TIMEOUT = 25 * 60 # seconds
def __init__(self):
self.calculate_rate_limit()
self.last_send_time = time.time() - self.rate_limit
def calculate_rate_limit(self):
self.rate_limit = 1
opts = email_config().parse()
rh = opts.relay_host
if rh:
for suffix in tweaks['public_smtp_relay_host_suffixes']:
if rh.lower().endswith(suffix):
self.rate_limit = tweaks['public_smtp_relay_delay']
break
def __call__(self, attachment, aname, to, subject, text, log=None,
abort=None, notifications=None):
try_count = 0
while True:
if try_count > 0:
log('\nRetrying in %d seconds...\n' %
self.rate_limit)
worker = Worker(self.sendmail,
(attachment, aname, to, subject, text, log))
worker.start()
start_time = time.time()
while worker.is_alive():
worker.join(0.2)
if abort.is_set():
log('Sending aborted by user')
return
if time.time() - start_time > self.TIMEOUT:
log('Sending timed out')
raise Exception(
'Sending email %r to %r timed out, aborting'% (subject,
to))
if worker.exception is None:
log('Email successfully sent')
return
log.error('\nSending failed...\n')
log.debug(worker.tb)
try_count += 1
if try_count > self.MAX_RETRIES:
raise worker.exception
def sendmail(self, attachment, aname, to, subject, text, log):
logged = False
while time.time() - self.last_send_time <= self.rate_limit:
if not logged and self.rate_limit > 0:
log('Waiting %s seconds before sending, to avoid being marked as spam.\nYou can control this delay via Preferences->Tweaks' % self.rate_limit)
logged = True
time.sleep(1)
try:
opts = email_config().parse()
from_ = opts.from_
if not from_:
from_ = 'calibre <calibre@'+socket.getfqdn()+'>'
with lopen(attachment, 'rb') as f:
msg = compose_mail(from_, to, text, subject, f, aname)
efrom = extract_email_address(from_)
eto = []
for x in to.split(','):
eto.append(extract_email_address(x.strip()))
def safe_debug(*args, **kwargs):
try:
return log.debug(*args, **kwargs)
except Exception:
pass
relay = opts.relay_host
if relay and relay == 'smtp.live.com':
# Microsoft changed the SMTP server
relay = 'smtp-mail.outlook.com'
sendmail(msg, efrom, eto, localhost=None,
verbose=1,
relay=relay,
username=opts.relay_username,
password=from_hex_unicode(opts.relay_password), port=opts.relay_port,
encryption=opts.encryption,
debug_output=safe_debug)
finally:
self.last_send_time = time.time()
gui_sendmail = Sendmail()
def send_mails(jobnames, callback, attachments, to_s, subjects,
texts, attachment_names, job_manager):
for name, attachment, to, subject, text, aname in zip(jobnames,
attachments, to_s, subjects, texts, attachment_names):
description = _('Email %(name)s to %(to)s') % dict(name=name, to=to)
if isinstance(to, str) and ('@pbsync.com' in to or '@kindle.com' in to):
# The pbsync service chokes on non-ascii filenames
# Dont know if amazon's service chokes or not, but since filenames
# arent visible on Kindles anyway, might as well be safe
aname = ascii_filename(aname)
job = ThreadedJob('email', description, gui_sendmail, (attachment, aname, to,
subject, text), {}, callback)
job_manager.run_threaded_job(job)
def email_news(mi, remove, get_fmts, done, job_manager):
opts = email_config().parse()
accounts = [(account, [x.strip().lower() for x in x[0].split(',')])
for account, x in opts.accounts.items() if x[1]]
sent_mails = []
for i, x in enumerate(accounts):
account, fmts = x
files = get_fmts(fmts)
files = [f for f in files if f is not None]
if not files:
continue
if opts.tags.get(account, False) and not ({t.strip() for t in opts.tags[account].split(',')} & set(mi.tags)):
continue
attachment = files[0]
to_s = [account]
subjects = [_('News:')+' '+mi.title]
texts = [_(
'Attached is the %s periodical downloaded by calibre.') % (mi.title,)]
attachment_names = [mi.title+os.path.splitext(attachment)[1]]
attachments = [attachment]
jobnames = [mi.title]
do_remove = []
if i == len(accounts) - 1:
do_remove = remove
send_mails(jobnames,
Dispatcher(partial(done, remove=do_remove)),
attachments, to_s, subjects, texts, attachment_names,
job_manager)
sent_mails.append(to_s[0])
return sent_mails
plugboard_email_value = 'email'
plugboard_email_formats = ['epub', 'mobi', 'azw3']
class SelectRecipients(QDialog): # {{{
def __init__(self, parent=None):
QDialog.__init__(self, parent)
self._layout = l = QGridLayout(self)
self.setLayout(l)
self.setWindowIcon(QIcon(I('mail.png')))
self.setWindowTitle(_('Select recipients'))
self.recipients = r = QListWidget(self)
l.addWidget(r, 0, 0, 1, -1)
self.la = la = QLabel(_('Add a new recipient:'))
la.setStyleSheet('QLabel { font-weight: bold }')
l.addWidget(la, l.rowCount(), 0, 1, -1)
self.labels = tuple(map(QLabel, (
_('&Address'), _('A&lias'), _('&Formats'), _('&Subject'))))
tooltips = (
_('The email address of the recipient'),
_('The optional alias (simple name) of the recipient'),
_('Formats to email. The first matching one will be sent (comma separated list)'),
_('The optional subject for email sent to this recipient'))
for i, name in enumerate(('address', 'alias', 'formats', 'subject')):
c = i % 2
row = l.rowCount() - c
self.labels[i].setText(str(self.labels[i].text()) + ':')
l.addWidget(self.labels[i], row, (2*c))
le = QLineEdit(self)
le.setToolTip(tooltips[i])
setattr(self, name, le)
self.labels[i].setBuddy(le)
l.addWidget(le, row, (2*c) + 1)
self.formats.setText(prefs['output_format'].upper())
self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add recipient'), self)
b.clicked.connect(self.add_recipient)
l.addWidget(b, l.rowCount(), 0, 1, -1)
self.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel)
l.addWidget(bb, l.rowCount(), 0, 1, -1)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
self.setMinimumWidth(500)
self.setMinimumHeight(400)
self.resize(self.sizeHint())
self.init_list()
def add_recipient(self):
to = str(self.address.text()).strip()
if not to:
return error_dialog(
self, _('Need address'), _('You must specify an address'), show=True)
from email.utils import parseaddr
if not parseaddr(to)[-1] or '@' not in to:
return error_dialog(
self, _('Invalid email address'), _('The address {} is invalid').format(to), show=True)
formats = ','.join([x.strip().upper() for x in str(self.formats.text()).strip().split(',') if x.strip()])
if not formats:
return error_dialog(
self, _('Need formats'), _('You must specify at least one format to send'), show=True)
opts = email_config().parse()
if to in opts.accounts:
return error_dialog(
self, _('Already exists'), _('The recipient %s already exists') % to, show=True)
acc = opts.accounts
acc[to] = [formats, False, False]
c = email_config()
c.set('accounts', acc)
alias = str(self.alias.text()).strip()
if alias:
opts.aliases[to] = alias
c.set('aliases', opts.aliases)
subject = str(self.subject.text()).strip()
if subject:
opts.subjects[to] = subject
c.set('subjects', opts.subjects)
self.create_item(alias or to, to, checked=True)
def create_item(self, alias, key, checked=False):
i = QListWidgetItem(alias, self.recipients)
i.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
i.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
i.setData(Qt.ItemDataRole.UserRole, key)
self.items.append(i)
def init_list(self):
opts = email_config().parse()
self.items = []
def sk(account):
return primary_sort_key(opts.aliases.get(account) or account)
for key in sorted(opts.accounts or (), key=sk):
self.create_item(opts.aliases.get(key, key), key)
def accept(self):
if not self.ans:
return error_dialog(self, _('No recipients'),
_('You must select at least one recipient'), show=True)
QDialog.accept(self)
@property
def ans(self):
opts = email_config().parse()
ans = []
for i in self.items:
if i.checkState() == Qt.CheckState.Checked:
to = str(i.data(Qt.ItemDataRole.UserRole) or '')
fmts = tuple(x.strip().upper() for x in (opts.accounts[to][0] or '').split(','))
subject = opts.subjects.get(to, '')
ans.append((to, fmts, subject))
return ans
def select_recipients(parent=None):
d = SelectRecipients(parent)
if d.exec() == QDialog.DialogCode.Accepted:
return d.ans
return ()
# }}}
class EmailMixin: # {{{
def __init__(self, *args, **kwargs):
pass
def send_multiple_by_mail(self, recipients, delete_from_library):
ids = {self.library_view.model().id(r) for r in self.library_view.selectionModel().selectedRows()}
if not ids:
return
db = self.current_db
db_fmt_map = {book_id:set((db.formats(book_id, index_is_id=True) or '').upper().split(',')) for book_id in ids}
ofmts = {x.upper() for x in available_output_formats()}
ifmts = {x.upper() for x in available_input_formats()}
bad_recipients = {}
auto_convert_map = defaultdict(list)
for to, fmts, subject in recipients:
rfmts = set(fmts)
ok_ids = {book_id for book_id, bfmts in iteritems(db_fmt_map) if bfmts.intersection(rfmts)}
convert_ids = ids - ok_ids
self.send_by_mail(to, fmts, delete_from_library, subject=subject, send_ids=ok_ids, do_auto_convert=False)
if not rfmts.intersection(ofmts):
bad_recipients[to] = (convert_ids, True)
continue
outfmt = tuple(f for f in fmts if f in ofmts)[0]
ok_ids = {book_id for book_id in convert_ids if db_fmt_map[book_id].intersection(ifmts)}
bad_ids = convert_ids - ok_ids
if bad_ids:
bad_recipients[to] = (bad_ids, False)
if ok_ids:
auto_convert_map[outfmt].append((to, subject, ok_ids))
if auto_convert_map:
titles = {book_id for x in itervalues(auto_convert_map) for data in x for book_id in data[2]}
titles = {db.title(book_id, index_is_id=True) for book_id in titles}
if self.auto_convert_question(
_('Auto convert the following books before sending via email?'), list(titles)):
for ofmt, data in iteritems(auto_convert_map):
ids = {bid for x in data for bid in x[2]}
data = [(to, subject) for to, subject, x in data]
self.iactions['Convert Books'].auto_convert_multiple_mail(ids, data, ofmt, delete_from_library)
if bad_recipients:
det_msg = []
titles = {book_id for x in itervalues(bad_recipients) for book_id in x[0]}
titles = {book_id:db.title(book_id, index_is_id=True) for book_id in titles}
for to, (ids, nooutput) in iteritems(bad_recipients):
msg = _('This recipient has no valid formats defined') if nooutput else \
_('These books have no suitable input formats for conversion')
det_msg.append(f'{to} - {msg}')
det_msg.extend('\t' + titles[bid] for bid in ids)
det_msg.append('\n')
warning_dialog(self, _('Could not send'),
_('Could not send books to some recipients. Click "Show details" for more information'),
det_msg='\n'.join(det_msg), show=True)
def send_by_mail(self, to, fmts, delete_from_library, subject='', send_ids=None,
do_auto_convert=True, specific_format=None):
ids = [self.library_view.model().id(r) for r in self.library_view.selectionModel().selectedRows()] if send_ids is None else send_ids
if not ids or len(ids) == 0:
return
files, _auto_ids = self.library_view.model().get_preferred_formats_from_ids(ids,
fmts, set_metadata=True,
specific_format=specific_format,
exclude_auto=do_auto_convert,
use_plugboard=plugboard_email_value,
plugboard_formats=plugboard_email_formats)
if do_auto_convert:
nids = list(set(ids).difference(_auto_ids))
ids = [i for i in ids if i in nids]
else:
_auto_ids = []
full_metadata = self.library_view.model().metadata_for(ids,
get_cover=False)
bad, remove_ids, jobnames = [], [], []
texts, subjects, attachments, attachment_names = [], [], [], []
for f, mi, id in zip(files, full_metadata, ids):
t = mi.title
if not t:
t = _('Unknown')
if f is None:
bad.append(t)
else:
remove_ids.append(id)
jobnames.append(t)
attachments.append(f)
if not subject:
subjects.append(_('E-book:')+ ' '+t)
else:
components = get_components(subject, mi, id)
if not components:
components = [mi.title]
subjects.append(os.path.join(*components))
a = authors_to_string(mi.authors if mi.authors else
[_('Unknown')])
texts.append(_('Attached, you will find the e-book') +
'\n\n' + t + '\n\t' + _('by') + ' ' + a + '\n\n' +
_('in the %s format.') %
os.path.splitext(f)[1][1:].upper())
if mi.comments and gprefs['add_comments_to_email']:
from calibre.utils.html2text import html2text
texts[-1] += '\n\n' + _('About this book:') + '\n\n' + textwrap.fill(html2text(mi.comments))
prefix = f'{t} - {a}'
if not isinstance(prefix, str):
prefix = prefix.decode(preferred_encoding, 'replace')
attachment_names.append(prefix + os.path.splitext(f)[1])
remove = remove_ids if delete_from_library else []
to_s = list(repeat(to, len(attachments)))
if attachments:
send_mails(jobnames,
Dispatcher(partial(self.email_sent, remove=remove)),
attachments, to_s, subjects, texts, attachment_names,
self.job_manager)
self.status_bar.show_message(_('Sending email to')+' '+to, 3000)
auto = []
if _auto_ids != []:
for id in _auto_ids:
if specific_format is None:
dbfmts = self.library_view.model().db.formats(id, index_is_id=True)
formats = [f.lower() for f in (dbfmts.split(',') if dbfmts else
[])]
if set(formats).intersection(available_input_formats()) and set(fmts).intersection(available_output_formats()):
auto.append(id)
else:
bad.append(self.library_view.model().db.title(id, index_is_id=True))
else:
if specific_format in list(set(fmts).intersection(set(available_output_formats()))):
auto.append(id)
else:
bad.append(self.library_view.model().db.title(id, index_is_id=True))
if auto != []:
format = specific_format if specific_format in list(set(fmts).intersection(set(available_output_formats()))) else None
if not format:
for fmt in fmts:
if fmt in list(set(fmts).intersection(set(available_output_formats()))):
format = fmt
break
if format is None:
bad += auto
else:
autos = [self.library_view.model().db.title(id, index_is_id=True) for id in auto]
if self.auto_convert_question(
_('Auto convert the following books to %s before sending via '
'email?') % format.upper(), autos):
self.iactions['Convert Books'].auto_convert_mail(to, fmts, delete_from_library, auto, format, subject)
if bad:
bad = '\n'.join('%s'%(i,) for i in bad)
d = warning_dialog(self, _('No suitable formats'),
_('Could not email the following books '
'as no suitable formats were found:'), bad)
d.exec()
def email_sent(self, job, remove=[]):
if job.failed:
self.job_exception(job, dialog_title=_('Failed to email book'))
return
self.status_bar.show_message(job.description + ' ' + _('sent'),
5000)
if remove:
try:
next_id = self.library_view.next_id
self.library_view.model().delete_books_by_id(remove)
self.iactions['Remove Books'].library_ids_deleted2(remove,
next_id=next_id)
except:
import traceback
# Probably the user deleted the files, in any case, failing
# to delete the book is not catastrophic
traceback.print_exc()
def email_news(self, id_):
mi = self.library_view.model().db.get_metadata(id_,
index_is_id=True)
remove = [id_] if config['delete_news_from_library_on_upload'] \
else []
def get_fmts(fmts):
files, auto = self.library_view.model().\
get_preferred_formats_from_ids([id_], fmts,
set_metadata=True,
use_plugboard=plugboard_email_value,
plugboard_formats=plugboard_email_formats)
return files
sent_mails = email_news(mi, remove,
get_fmts, self.email_sent, self.job_manager)
if sent_mails:
self.status_bar.show_message(_('Sent news to')+' '+
', '.join(sent_mails), 3000)
# }}}
if __name__ == '__main__':
from qt.core import QApplication
app = QApplication([]) # noqa
print(select_recipients())