From ac8dc12785d2bbd693bbf129ee93e99cbe315c48 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 24 Nov 2010 22:59:26 -0700 Subject: [PATCH] E-mail sending: calibre will now send atmost one email every minute. ALso if sending an email fails, it will be automatically retired once, after a minute. Finally, email sending is now a normal job and can be viewed in the jobs list. Fixes #2473 (Feature request: Show email sending as job in queue) --- src/calibre/gui2/device.py | 217 +-------- src/calibre/gui2/email.py | 321 ++++++++++++++ src/calibre/gui2/jobs.py | 15 +- src/calibre/gui2/ui.py | 8 +- src/calibre/utils/smtp.py | 21 +- src/calibre/utils/smtplib.py | 826 +++++++++++++++++++++++++++++++++++ 6 files changed, 1181 insertions(+), 227 deletions(-) create mode 100644 src/calibre/gui2/email.py create mode 100755 src/calibre/utils/smtplib.py diff --git a/src/calibre/gui2/device.py b/src/calibre/gui2/device.py index 048b6e6235..008649f534 100644 --- a/src/calibre/gui2/device.py +++ b/src/calibre/gui2/device.py @@ -3,11 +3,8 @@ __license__ = 'GPL v3' __copyright__ = '2008, Kovid Goyal ' # Imports {{{ -import os, traceback, Queue, time, socket, cStringIO, re, sys -from threading import Thread, RLock -from itertools import repeat -from functools import partial -from binascii import unhexlify +import os, traceback, Queue, time, cStringIO, re, sys +from threading import Thread from PyQt4.Qt import QMenu, QAction, QActionGroup, QIcon, SIGNAL, \ Qt, pyqtSignal, QDialog, QMessageBox @@ -25,8 +22,6 @@ from calibre.ebooks.metadata import authors_to_string from calibre import preferred_encoding, prints, force_unicode from calibre.utils.filenames import ascii_filename from calibre.devices.errors import FreeSpaceError -from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \ - config as email_config from calibre.devices.apple.driver import ITUNES_ASYNC from calibre.devices.folder_device.driver import FOLDER_DEVICE from calibre.ebooks.metadata.meta import set_metadata @@ -591,64 +586,6 @@ class DeviceMenu(QMenu): # {{{ # }}} -class Emailer(Thread): # {{{ - - def __init__(self, timeout=60): - Thread.__init__(self) - self.setDaemon(True) - self.job_lock = RLock() - self.jobs = [] - self._run = True - self.timeout = timeout - - def run(self): - while self._run: - job = None - with self.job_lock: - if self.jobs: - job = self.jobs[0] - self.jobs = self.jobs[1:] - if job is not None: - self._send_mails(*job) - time.sleep(1) - - def stop(self): - self._run = False - - def send_mails(self, jobnames, callback, attachments, to_s, subjects, - texts, attachment_names): - job = (jobnames, callback, attachments, to_s, subjects, texts, - attachment_names) - with self.job_lock: - self.jobs.append(job) - - def _send_mails(self, jobnames, callback, attachments, - to_s, subjects, texts, attachment_names): - opts = email_config().parse() - opts.verbose = 3 if os.environ.get('CALIBRE_DEBUG_EMAIL', False) else 0 - from_ = opts.from_ - if not from_: - from_ = 'calibre ' - results = [] - for i, jobname in enumerate(jobnames): - try: - msg = compose_mail(from_, to_s[i], texts[i], subjects[i], - open(attachments[i], 'rb'), - attachment_name = attachment_names[i]) - efrom, eto = map(extract_email_address, (from_, to_s[i])) - eto = [eto] - sendmail(msg, efrom, eto, localhost=None, - verbose=opts.verbose, - timeout=self.timeout, relay=opts.relay_host, - username=opts.relay_username, - password=unhexlify(opts.relay_password), port=opts.relay_port, - encryption=opts.encryption) - results.append([jobname, None, None]) - except Exception, e: - results.append([jobname, e, traceback.format_exc()]) - callback(results) - - # }}} class DeviceMixin(object): # {{{ @@ -656,8 +593,6 @@ class DeviceMixin(object): # {{{ self.device_error_dialog = error_dialog(self, _('Error'), _('Error communicating with device'), ' ') self.device_error_dialog.setModal(Qt.NonModal) - self.emailer = Emailer() - self.emailer.start() self.device_manager = DeviceManager(Dispatcher(self.device_detected), self.job_manager, Dispatcher(self.status_bar.show_message)) self.device_manager.start() @@ -911,124 +846,6 @@ class DeviceMixin(object): # {{{ fmts = [x.strip().lower() for x in fmts.split(',')] self.send_by_mail(to, fmts, delete) - def send_by_mail(self, to, fmts, delete_from_library, 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) - 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) - - 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(u'%s:%s'%(id, t)) - attachments.append(f) - subjects.append(_('E-book:')+ ' '+t) - 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()) - prefix = ascii_filename(t+' - '+a) - if not isinstance(prefix, unicode): - 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: - self.emailer.send_mails(jobnames, - Dispatcher(partial(self.emails_sent, remove=remove)), - attachments, to_s, subjects, texts, attachment_names) - self.status_bar.show_message(_('Sending email to')+' '+to, 3000) - - auto = [] - if _auto_ids != []: - for id in _auto_ids: - if specific_format == None: - formats = [f.lower() for f in self.library_view.model().db.formats(id, index_is_id=True).split(',')] - formats = formats if formats != None else [] - if list(set(formats).intersection(available_input_formats())) != [] and list(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 before sending via ' - 'email?'), autos): - self.iactions['Convert Books'].auto_convert_mail(to, fmts, delete_from_library, auto, format) - - 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 emails_sent(self, results, remove=[]): - errors, good = [], [] - for jobname, exception, tb in results: - title = jobname.partition(':')[-1] - if exception is not None: - errors.append(list(map(force_unicode, [title, exception, tb]))) - else: - good.append(title) - if errors: - errors = u'\n'.join([ - u'%s\n\n%s\n%s\n' % - (title, e, tb) for \ - title, e, tb in errors - ]) - error_dialog(self, _('Failed to email books'), - _('Failed to email the following books:'), - '%s'%errors, show=True - ) - else: - self.status_bar.show_message(_('Sent by email:') + ', '.join(good), - 5000) - if remove: - try: - self.library_view.model().delete_books_by_id(remove) - except: - # Probably the user deleted the files, in any case, failing - # to delete the book is not catastrophic - traceback.print_exc() - - def cover_to_thumbnail(self, data): ht = self.device_manager.device.THUMBNAIL_HEIGHT \ if self.device_manager else DevicePlugin.THUMBNAIL_HEIGHT @@ -1037,36 +854,6 @@ class DeviceMixin(object): # {{{ except: pass - def email_news(self, id): - 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 account, fmts in accounts: - files, auto = self.library_view.model().\ - get_preferred_formats_from_ids([id], fmts) - files = [f for f in files if f is not None] - if not files: - continue - attachment = files[0] - mi = self.library_view.model().db.get_metadata(id, - index_is_id=True) - to_s = [account] - subjects = [_('News:')+' '+mi.title] - texts = [_('Attached is the')+' '+mi.title] - attachment_names = [ascii_filename(mi.title)+os.path.splitext(attachment)[1]] - attachments = [attachment] - jobnames = ['%s:%s'%(id, mi.title)] - remove = [id] if config['delete_news_from_library_on_upload']\ - else [] - self.emailer.send_mails(jobnames, - Dispatcher(partial(self.emails_sent, remove=remove)), - attachments, to_s, subjects, texts, attachment_names) - sent_mails.append(to_s[0]) - if sent_mails: - self.status_bar.show_message(_('Sent news to')+' '+\ - ', '.join(sent_mails), 3000) - def sync_catalogs(self, send_ids=None, do_auto_convert=True): if self.device_connected: settings = self.device_manager.device.settings() diff --git a/src/calibre/gui2/email.py b/src/calibre/gui2/email.py new file mode 100644 index 0000000000..bfd9f76470 --- /dev/null +++ b/src/calibre/gui2/email.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import print_function + +__license__ = 'GPL v3' +__copyright__ = '2010, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import os, socket, time, cStringIO +from threading import Thread +from Queue import Queue +from binascii import unhexlify +from functools import partial +from itertools import repeat + +from calibre.utils.smtp import compose_mail, sendmail, extract_email_address, \ + config as email_config +from calibre.utils.filenames import ascii_filename +from calibre.utils.ipc.job import BaseJob +from calibre.ptempfile import PersistentTemporaryFile +from calibre.customize.ui import available_input_formats, available_output_formats +from calibre.ebooks.metadata import authors_to_string +from calibre.constants import preferred_encoding +from calibre.gui2 import config, Dispatcher, warning_dialog + +class EmailJob(BaseJob): # {{{ + + def __init__(self, callback, description, attachment, aname, to, subject, text, job_manager): + BaseJob.__init__(self, description) + self.exception = None + self.job_manager = job_manager + self.email_args = (attachment, aname, to, subject, text) + self.email_sent_callback = callback + self.log_path = None + self._log_file = cStringIO.StringIO() + self._log_file.write(self.description.encode('utf-8') + '\n') + + @property + def log_file(self): + if self.log_path is not None: + return open(self.log_path, 'rb') + return cStringIO.StringIO(self._log_file.getvalue()) + + def start_work(self): + self.start_time = time.time() + self.job_manager.changed_queue.put(self) + + def job_done(self): + self.duration = time.time() - self.start_time + self.percent = 1 + # Dump log onto disk + lf = PersistentTemporaryFile('email_log') + lf.write(self._log_file.getvalue()) + lf.close() + self.log_path = lf.name + self._log_file.close() + self._log_file = None + + self.job_manager.changed_queue.put(self) + + def log_write(self, what): + self._log_file.write(what) + +# }}} + +class Emailer(Thread): # {{{ + + MAX_RETRIES = 1 + RATE_LIMIT = 65 # seconds between connections to the SMTP server + + def __init__(self, job_manager): + Thread.__init__(self) + self.daemon = True + self.jobs = Queue() + self.job_manager = job_manager + self._run = True + self.last_send_time = time.time() - self.RATE_LIMIT + + def stop(self): + self._run = False + self.jobs.put(None) + + def run(self): + while self._run: + try: + job = self.jobs.get() + except: + break + if job is None or not self._run: + break + try_count = 0 + failed, exc = False, None + job.start_work() + if job.kill_on_start: + job.log_write('Aborted\n') + job.failed = failed + job.killed = True + job.job_done() + continue + + while try_count <= self.MAX_RETRIES: + failed = False + if try_count > 0: + job.log_write('\nRetrying in %d seconds...\n' % + self.RATE_LIMIT) + try: + self.sendmail(job) + break + except Exception, e: + if not self._run: + return + import traceback + failed = True + exc = e + job.log_write('\nSending failed...\n') + job.log_write(traceback.format_exc()) + + try_count += 1 + + if not self._run: + break + + job.failed = failed + job.exception = exc + job.job_done() + job.email_sent_callback(job) + + def send_mails(self, jobnames, callback, attachments, to_s, subjects, + texts, attachment_names): + for name, attachment, to, subject, text, aname in zip(jobnames, + attachments, to_s, subjects, texts, attachment_names): + description = _('Email %s to %s') % (name, to) + job = EmailJob(callback, description, attachment, aname, to, + subject, text, self.job_manager) + self.job_manager.add_job(job) + self.jobs.put(job) + + def sendmail(self, job): + while time.time() - self.last_send_time <= self.RATE_LIMIT: + time.sleep(1) + try: + opts = email_config().parse() + from_ = opts.from_ + if not from_: + from_ = 'calibre ' + attachment, aname, to, subject, text = job.email_args + msg = compose_mail(from_, to, text, subject, open(attachment, 'rb'), + aname) + efrom, eto = map(extract_email_address, (from_, to)) + eto = [eto] + sendmail(msg, efrom, eto, localhost=None, + verbose=1, + relay=opts.relay_host, + username=opts.relay_username, + password=unhexlify(opts.relay_password), port=opts.relay_port, + encryption=opts.encryption, + debug_output=partial(print, file=job._log_file)) + finally: + self.last_send_time = time.time() + + def email_news(self, mi, remove, get_fmts, done): + 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 + attachment = files[0] + to_s = [account] + subjects = [_('News:')+' '+mi.title] + texts = [ + _('Attached is the %s periodical downloaded by calibre.') + % (mi.title,) + ] + attachment_names = [ascii_filename(mi.title)+os.path.splitext(attachment)[1]] + attachments = [attachment] + jobnames = [mi.title] + do_remove = [] + if i == len(accounts) - 1: + do_remove = remove + self.send_mails(jobnames, + Dispatcher(partial(done, remove=do_remove)), + attachments, to_s, subjects, texts, attachment_names) + sent_mails.append(to_s[0]) + return sent_mails + + +# }}} + + +class EmailMixin(object): # {{{ + + def __init__(self): + self.emailer = Emailer(self.job_manager) + self.emailer.start() + + def send_by_mail(self, to, fmts, delete_from_library, 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) + 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) + + 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) + subjects.append(_('E-book:')+ ' '+t) + 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()) + prefix = ascii_filename(t+' - '+a) + if not isinstance(prefix, unicode): + 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: + self.emailer.send_mails(jobnames, + Dispatcher(partial(self.email_sent, remove=remove)), + attachments, to_s, subjects, texts, attachment_names) + self.status_bar.show_message(_('Sending email to')+' '+to, 3000) + + auto = [] + if _auto_ids != []: + for id in _auto_ids: + if specific_format == None: + formats = [f.lower() for f in self.library_view.model().db.formats(id, index_is_id=True).split(',')] + formats = formats if formats != None else [] + if list(set(formats).intersection(available_input_formats())) != [] and list(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 before sending via ' + 'email?'), autos): + self.iactions['Convert Books'].auto_convert_mail(to, fmts, delete_from_library, auto, format) + + 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: + self.library_view.model().delete_books_by_id(remove) + 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) + return files + sent_mails = self.emailer.email_news(mi, remove, + get_fmts, self.email_sent) + if sent_mails: + self.status_bar.show_message(_('Sent news to')+' '+\ + ', '.join(sent_mails), 3000) + +# }}} + diff --git a/src/calibre/gui2/jobs.py b/src/calibre/gui2/jobs.py index 8a63b6fac5..a2bd8e4492 100644 --- a/src/calibre/gui2/jobs.py +++ b/src/calibre/gui2/jobs.py @@ -221,16 +221,27 @@ class JobManager(QAbstractTableModel): if job.duration is not None: return error_dialog(view, _('Cannot kill job'), _('Job has already run')).exec_() - self.server.kill_job(job) + if isinstance(job, ParallelJob): + self.server.kill_job(job) + else: + job.kill_on_start = True def kill_all_jobs(self): for job in self.jobs: if isinstance(job, DeviceJob) or job.duration is not None: continue - self.server.kill_job(job) + if isinstance(job, ParallelJob): + self.server.kill_job(job) + else: + job.kill_on_start = True def terminate_all_jobs(self): self.server.killall() + for job in self.jobs: + if isinstance(job, DeviceJob) or job.duration is not None: + continue + if not isinstance(job, ParallelJob): + job.kill_on_start = True class ProgressBarDelegate(QAbstractItemDelegate): diff --git a/src/calibre/gui2/ui.py b/src/calibre/gui2/ui.py index 13bba4f27c..bd0743e819 100644 --- a/src/calibre/gui2/ui.py +++ b/src/calibre/gui2/ui.py @@ -34,6 +34,7 @@ from calibre.gui2.update import UpdateMixin from calibre.gui2.main_window import MainWindow from calibre.gui2.layout import MainWindowMixin from calibre.gui2.device import DeviceMixin +from calibre.gui2.email import EmailMixin from calibre.gui2.jobs import JobManager, JobsDialog, JobsButton from calibre.gui2.init import LibraryViewMixin, LayoutMixin from calibre.gui2.search_box import SearchBoxMixin, SavedSearchBoxMixin @@ -88,7 +89,7 @@ class SystemTrayIcon(QSystemTrayIcon): # {{{ # }}} -class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{ +class Main(MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ TagBrowserMixin, CoverFlowMixin, LibraryViewMixin, SearchBoxMixin, SavedSearchBoxMixin, SearchRestrictionMixin, LayoutMixin, UpdateMixin ): @@ -141,6 +142,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{ # }}} LayoutMixin.__init__(self) + EmailMixin.__init__(self) DeviceMixin.__init__(self) self.restriction_count_of_books_in_view = 0 @@ -434,7 +436,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{ - def job_exception(self, job): + def job_exception(self, job, dialog_title=_('Conversion Error')): if not hasattr(self, '_modeless_dialogs'): self._modeless_dialogs = [] minz = self.is_minimized_to_tray @@ -475,7 +477,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{ except: pass if not minz: - d = error_dialog(self, _('Conversion Error'), + d = error_dialog(self, dialog_title, _('Failed')+': '+unicode(job.description), det_msg=job.details) d.setModal(False) diff --git a/src/calibre/utils/smtp.py b/src/calibre/utils/smtp.py index 4b7ec3f0a3..69d790177f 100644 --- a/src/calibre/utils/smtp.py +++ b/src/calibre/utils/smtp.py @@ -58,11 +58,15 @@ def get_mx(host, verbose=0): int(getattr(y, 'preference', sys.maxint)))) return [str(x.exchange) for x in answers if hasattr(x, 'exchange')] -def sendmail_direct(from_, to, msg, timeout, localhost, verbose): - import smtplib +def sendmail_direct(from_, to, msg, timeout, localhost, verbose, + debug_output=None): + import calibre.utils.smtplib as smtplib hosts = get_mx(to.split('@')[-1].strip(), verbose) timeout=None # Non blocking sockets sometimes don't work - s = smtplib.SMTP(timeout=timeout, local_hostname=localhost) + kwargs = dict(timeout=timeout, local_hostname=localhost) + if debug_output is not None: + kwargs['debug_to'] = debug_output + s = smtplib.SMTP(**kwargs) s.set_debuglevel(verbose) if not hosts: raise ValueError('No mail server found for address: %s'%to) @@ -79,17 +83,20 @@ def sendmail_direct(from_, to, msg, timeout, localhost, verbose): raise IOError('Failed to send mail: '+repr(last_error)) -def sendmail(msg, from_, to, localhost=None, verbose=0, timeout=30, +def sendmail(msg, from_, to, localhost=None, verbose=0, timeout=None, relay=None, username=None, password=None, encryption='TLS', - port=-1): + port=-1, debug_output=None): if relay is None: for x in to: return sendmail_direct(from_, x, msg, timeout, localhost, verbose) - import smtplib + import calibre.utils.smtplib as smtplib cls = smtplib.SMTP if encryption == 'TLS' else smtplib.SMTP_SSL timeout = None # Non-blocking sockets sometimes don't work port = int(port) - s = cls(timeout=timeout, local_hostname=localhost) + kwargs = dict(timeout=timeout, local_hostname=localhost) + if debug_output is not None: + kwargs['debug_to'] = debug_output + s = cls(**kwargs) s.set_debuglevel(verbose) if port < 0: port = 25 if encryption == 'TLS' else 465 diff --git a/src/calibre/utils/smtplib.py b/src/calibre/utils/smtplib.py new file mode 100755 index 0000000000..d6f3fb0b69 --- /dev/null +++ b/src/calibre/utils/smtplib.py @@ -0,0 +1,826 @@ +from __future__ import print_function + +'''SMTP/ESMTP client class. + +This should follow RFC 821 (SMTP), RFC 1869 (ESMTP), RFC 2554 (SMTP +Authentication) and RFC 2487 (Secure SMTP over TLS). + +Notes: + +Please remember, when doing ESMTP, that the names of the SMTP service +extensions are NOT the same thing as the option keywords for the RCPT +and MAIL commands! + +Example: + + >>> import smtplib + >>> s=smtplib.SMTP("localhost") + >>> print s.help() + This is Sendmail version 8.8.4 + Topics: + HELO EHLO MAIL RCPT DATA + RSET NOOP QUIT HELP VRFY + EXPN VERB ETRN DSN + For more info use "HELP ". + To report bugs in the implementation send email to + sendmail-bugs@sendmail.org. + For local information send email to Postmaster at your site. + End of HELP info + >>> s.putcmd("vrfy","someone@here") + >>> s.getreply() + (250, "Somebody OverHere ") + >>> s.quit() +''' + +# Author: The Dragon De Monsyne +# ESMTP support, test code and doc fixes added by +# Eric S. Raymond +# Better RFC 821 compliance (MAIL and RCPT, and CRLF in data) +# by Carey Evans , for picky mail servers. +# RFC 2554 (authentication) support by Gerhard Haering . +# Enhanced debugging support by Kovid Goyal +# +# This was modified from the Python 1.5 library HTTP lib. + +import socket +import re +import email.utils +import base64 +import hmac +import sys +from email.base64mime import encode as encode_base64 +from functools import partial + +__all__ = ["SMTPException","SMTPServerDisconnected","SMTPResponseException", + "SMTPSenderRefused","SMTPRecipientsRefused","SMTPDataError", + "SMTPConnectError","SMTPHeloError","SMTPAuthenticationError", + "quoteaddr","quotedata","SMTP"] + +SMTP_PORT = 25 +SMTP_SSL_PORT = 465 +CRLF="\r\n" + +OLDSTYLE_AUTH = re.compile(r"auth=(.*)", re.I) + +# Exception classes used by this module. +class SMTPException(Exception): + """Base class for all exceptions raised by this module.""" + +class SMTPServerDisconnected(SMTPException): + """Not connected to any SMTP server. + + This exception is raised when the server unexpectedly disconnects, + or when an attempt is made to use the SMTP instance before + connecting it to a server. + """ + +class SMTPResponseException(SMTPException): + """Base class for all exceptions that include an SMTP error code. + + These exceptions are generated in some instances when the SMTP + server returns an error code. The error code is stored in the + `smtp_code' attribute of the error, and the `smtp_error' attribute + is set to the error message. + """ + + def __init__(self, code, msg): + self.smtp_code = code + self.smtp_error = msg + self.args = (code, msg) + +class SMTPSenderRefused(SMTPResponseException): + """Sender address refused. + + In addition to the attributes set by on all SMTPResponseException + exceptions, this sets `sender' to the string that the SMTP refused. + """ + + def __init__(self, code, msg, sender): + self.smtp_code = code + self.smtp_error = msg + self.sender = sender + self.args = (code, msg, sender) + +class SMTPRecipientsRefused(SMTPException): + """All recipient addresses refused. + + The errors for each recipient are accessible through the attribute + 'recipients', which is a dictionary of exactly the same sort as + SMTP.sendmail() returns. + """ + + def __init__(self, recipients): + self.recipients = recipients + self.args = ( recipients,) + + +class SMTPDataError(SMTPResponseException): + """The SMTP server didn't accept the data.""" + +class SMTPConnectError(SMTPResponseException): + """Error during connection establishment.""" + +class SMTPHeloError(SMTPResponseException): + """The server refused our HELO reply.""" + +class SMTPAuthenticationError(SMTPResponseException): + """Authentication error. + + Most probably the server didn't accept the username/password + combination provided. + """ + +def quoteaddr(addr): + """Quote a subset of the email addresses defined by RFC 821. + + Should be able to handle anything rfc822.parseaddr can handle. + """ + m = (None, None) + try: + m = email.utils.parseaddr(addr)[1] + except AttributeError: + pass + if m == (None, None): # Indicates parse failure or AttributeError + # something weird here.. punt -ddm + return "<%s>" % addr + elif m is None: + # the sender wants an empty return address + return "<>" + else: + return "<%s>" % m + +def quotedata(data): + """Quote data for email. + + Double leading '.', and change Unix newline '\\n', or Mac '\\r' into + Internet CRLF end-of-line. + """ + return re.sub(r'(?m)^\.', '..', + re.sub(r'(?:\r\n|\n|\r(?!\n))', CRLF, data)) + + +try: + import ssl +except ImportError: + _have_ssl = False +else: + class SSLFakeFile: + """A fake file like object that really wraps a SSLObject. + + It only supports what is needed in smtplib. + """ + def __init__(self, sslobj): + self.sslobj = sslobj + + def readline(self): + str = "" + chr = None + while chr != "\n": + chr = self.sslobj.read(1) + if not chr: break + str += chr + return str + + def close(self): + pass + + _have_ssl = True + +class SMTP: + """This class manages a connection to an SMTP or ESMTP server. + SMTP Objects: + SMTP objects have the following attributes: + helo_resp + This is the message given by the server in response to the + most recent HELO command. + + ehlo_resp + This is the message given by the server in response to the + most recent EHLO command. This is usually multiline. + + does_esmtp + This is a True value _after you do an EHLO command_, if the + server supports ESMTP. + + esmtp_features + This is a dictionary, which, if the server supports ESMTP, + will _after you do an EHLO command_, contain the names of the + SMTP service extensions this server supports, and their + parameters (if any). + + Note, all extension names are mapped to lower case in the + dictionary. + + See each method's docstrings for details. In general, there is a + method of the same name to perform each SMTP command. There is also a + method called 'sendmail' that will do an entire mail transaction. + """ + debuglevel = 0 + file = None + helo_resp = None + ehlo_msg = "ehlo" + ehlo_resp = None + does_esmtp = 0 + + def __init__(self, host='', port=0, local_hostname=None, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + debug_to=partial(print, file=sys.stderr)): + """Initialize a new instance. + + If specified, `host' is the name of the remote host to which to + connect. If specified, `port' specifies the port to which to connect. + By default, smtplib.SMTP_PORT is used. An SMTPConnectError is raised + if the specified `host' doesn't respond correctly. If specified, + `local_hostname` is used as the FQDN of the local host. By default, + the local hostname is found using socket.getfqdn(). `debug_to` + specifies where debug output is written to. By default it is written to + sys.stderr. You should pass in a print function of your own to control + where debug output is written. + """ + self.timeout = timeout + self.debug = debug_to + self.esmtp_features = {} + self.default_port = SMTP_PORT + if host: + (code, msg) = self.connect(host, port) + if code != 220: + raise SMTPConnectError(code, msg) + if local_hostname is not None: + self.local_hostname = local_hostname + else: + # RFC 2821 says we should use the fqdn in the EHLO/HELO verb, and + # if that can't be calculated, that we should use a domain literal + # instead (essentially an encoded IP address like [A.B.C.D]). + fqdn = socket.getfqdn() + if '.' in fqdn: + self.local_hostname = fqdn + else: + # We can't find an fqdn hostname, so use a domain literal + addr = '127.0.0.1' + try: + addr = socket.gethostbyname(socket.gethostname()) + except socket.gaierror: + pass + self.local_hostname = '[%s]' % addr + + def set_debuglevel(self, debuglevel): + """Set the debug output level. + + A value of 0 means no debug logging. A value of 1 means all interaction + with the server is logged except that long lines are truncated to 100 + characters and AUTH messages are censored. A value of 2 or higher means + the complete session is logged. + + """ + self.debuglevel = debuglevel + + def _get_socket(self, port, host, timeout): + # This makes it simpler for SMTP_SSL to use the SMTP connect code + # and just alter the socket connection bit. + if self.debuglevel > 0: self.debug('connect:', (host, port)) + return socket.create_connection((port, host), timeout) + + def connect(self, host='localhost', port = 0): + """Connect to a host on a given port. + + If the hostname ends with a colon (`:') followed by a number, and + there is no port specified, that suffix will be stripped off and the + number interpreted as the port number to use. + + Note: This method is automatically invoked by __init__, if a host is + specified during instantiation. + + """ + if not port and (host.find(':') == host.rfind(':')): + i = host.rfind(':') + if i >= 0: + host, port = host[:i], host[i+1:] + try: port = int(port) + except ValueError: + raise socket.error, "nonnumeric port" + if not port: port = self.default_port + if self.debuglevel > 0: self.debug('connect:', (host, port)) + self.sock = self._get_socket(host, port, self.timeout) + (code, msg) = self.getreply() + if self.debuglevel > 0: self.debug("connect:", msg) + return (code, msg) + + def send(self, str): + """Send `str' to the server.""" + if self.debuglevel > 0: + raw = repr(str) + if self.debuglevel < 2: + if len(raw) > 100: + raw = raw[:100] + '...' + if 'AUTH' in raw: + raw = 'AUTH ' + self.debug('send:', raw) + if hasattr(self, 'sock') and self.sock: + try: + self.sock.sendall(str) + except socket.error: + self.close() + raise SMTPServerDisconnected('Server not connected') + else: + raise SMTPServerDisconnected('please run connect() first') + + def putcmd(self, cmd, args=""): + """Send a command to the server.""" + if args == "": + str = '%s%s' % (cmd, CRLF) + else: + str = '%s %s%s' % (cmd, args, CRLF) + self.send(str) + + def getreply(self): + """Get a reply from the server. + + Returns a tuple consisting of: + + - server response code (e.g. '250', or such, if all goes well) + Note: returns -1 if it can't read response code. + + - server response string corresponding to response code (multiline + responses are converted to a single, multiline string). + + Raises SMTPServerDisconnected if end-of-file is reached. + """ + resp=[] + if self.file is None: + self.file = self.sock.makefile('rb') + while 1: + try: + line = self.file.readline() + except socket.error: + line = '' + if line == '': + self.close() + raise SMTPServerDisconnected("Connection unexpectedly closed") + if self.debuglevel > 0: self.debug('reply:', repr(line)) + resp.append(line[4:].strip()) + code=line[:3] + # Check that the error code is syntactically correct. + # Don't attempt to read a continuation line if it is broken. + try: + errcode = int(code) + except ValueError: + errcode = -1 + break + # Check if multiline response. + if line[3:4]!="-": + break + + errmsg = "\n".join(resp) + if self.debuglevel > 0: + self.debug('reply: retcode (%s); Msg: %s' % (errcode,errmsg)) + return errcode, errmsg + + def docmd(self, cmd, args=""): + """Send a command, and return its response code.""" + self.putcmd(cmd,args) + return self.getreply() + + # std smtp commands + def helo(self, name=''): + """SMTP 'helo' command. + Hostname to send for this command defaults to the FQDN of the local + host. + """ + self.putcmd("helo", name or self.local_hostname) + (code,msg)=self.getreply() + self.helo_resp=msg + return (code,msg) + + def ehlo(self, name=''): + """ SMTP 'ehlo' command. + Hostname to send for this command defaults to the FQDN of the local + host. + """ + self.esmtp_features = {} + self.putcmd(self.ehlo_msg, name or self.local_hostname) + (code,msg)=self.getreply() + # According to RFC1869 some (badly written) + # MTA's will disconnect on an ehlo. Toss an exception if + # that happens -ddm + if code == -1 and len(msg) == 0: + self.close() + raise SMTPServerDisconnected("Server not connected") + self.ehlo_resp=msg + if code != 250: + return (code,msg) + self.does_esmtp=1 + #parse the ehlo response -ddm + resp=self.ehlo_resp.split('\n') + del resp[0] + for each in resp: + # To be able to communicate with as many SMTP servers as possible, + # we have to take the old-style auth advertisement into account, + # because: + # 1) Else our SMTP feature parser gets confused. + # 2) There are some servers that only advertise the auth methods we + # support using the old style. + auth_match = OLDSTYLE_AUTH.match(each) + if auth_match: + # This doesn't remove duplicates, but that's no problem + self.esmtp_features["auth"] = self.esmtp_features.get("auth", "") \ + + " " + auth_match.groups(0)[0] + continue + + # RFC 1869 requires a space between ehlo keyword and parameters. + # It's actually stricter, in that only spaces are allowed between + # parameters, but were not going to check for that here. Note + # that the space isn't present if there are no parameters. + m=re.match(r'(?P[A-Za-z0-9][A-Za-z0-9\-]*) ?',each) + if m: + feature=m.group("feature").lower() + params=m.string[m.end("feature"):].strip() + if feature == "auth": + self.esmtp_features[feature] = self.esmtp_features.get(feature, "") \ + + " " + params + else: + self.esmtp_features[feature]=params + return (code,msg) + + def has_extn(self, opt): + """Does the server support a given SMTP service extension?""" + return opt.lower() in self.esmtp_features + + def help(self, args=''): + """SMTP 'help' command. + Returns help text from server.""" + self.putcmd("help", args) + return self.getreply()[1] + + def rset(self): + """SMTP 'rset' command -- resets session.""" + return self.docmd("rset") + + def noop(self): + """SMTP 'noop' command -- doesn't do anything :>""" + return self.docmd("noop") + + def mail(self,sender,options=[]): + """SMTP 'mail' command -- begins mail xfer session.""" + optionlist = '' + if options and self.does_esmtp: + optionlist = ' ' + ' '.join(options) + self.putcmd("mail", "FROM:%s%s" % (quoteaddr(sender) ,optionlist)) + return self.getreply() + + def rcpt(self,recip,options=[]): + """SMTP 'rcpt' command -- indicates 1 recipient for this mail.""" + optionlist = '' + if options and self.does_esmtp: + optionlist = ' ' + ' '.join(options) + self.putcmd("rcpt","TO:%s%s" % (quoteaddr(recip),optionlist)) + return self.getreply() + + def data(self,msg): + """SMTP 'DATA' command -- sends message data to server. + + Automatically quotes lines beginning with a period per rfc821. + Raises SMTPDataError if there is an unexpected reply to the + DATA command; the return value from this method is the final + response code received when the all data is sent. + """ + self.putcmd("data") + (code,repl)=self.getreply() + if self.debuglevel >0 : self.debug("data:", (code,repl)) + if code != 354: + raise SMTPDataError(code,repl) + else: + q = quotedata(msg) + if q[-2:] != CRLF: + q = q + CRLF + q = q + "." + CRLF + self.send(q) + (code,msg)=self.getreply() + if self.debuglevel > 0 : + self.debug("data:", (code,msg)) + return (code,msg) + + def verify(self, address): + """SMTP 'verify' command -- checks for address validity.""" + self.putcmd("vrfy", quoteaddr(address)) + return self.getreply() + # a.k.a. + vrfy=verify + + def expn(self, address): + """SMTP 'expn' command -- expands a mailing list.""" + self.putcmd("expn", quoteaddr(address)) + return self.getreply() + + # some useful methods + + def ehlo_or_helo_if_needed(self): + """Call self.ehlo() and/or self.helo() if needed. + + If there has been no previous EHLO or HELO command this session, this + method tries ESMTP EHLO first. + + This method may raise the following exceptions: + + SMTPHeloError The server didn't reply properly to + the helo greeting. + """ + if self.helo_resp is None and self.ehlo_resp is None: + if not (200 <= self.ehlo()[0] <= 299): + (code, resp) = self.helo() + if not (200 <= code <= 299): + raise SMTPHeloError(code, resp) + + def login(self, user, password): + """Log in on an SMTP server that requires authentication. + + The arguments are: + - user: The user name to authenticate with. + - password: The password for the authentication. + + If there has been no previous EHLO or HELO command this session, this + method tries ESMTP EHLO first. + + This method will return normally if the authentication was successful. + + This method may raise the following exceptions: + + SMTPHeloError The server didn't reply properly to + the helo greeting. + SMTPAuthenticationError The server didn't accept the username/ + password combination. + SMTPException No suitable authentication method was + found. + """ + + def encode_cram_md5(challenge, user, password): + challenge = base64.decodestring(challenge) + response = user + " " + hmac.HMAC(password, challenge).hexdigest() + return encode_base64(response, eol="") + + def encode_plain(user, password): + return encode_base64("\0%s\0%s" % (user, password), eol="") + + + AUTH_PLAIN = "PLAIN" + AUTH_CRAM_MD5 = "CRAM-MD5" + AUTH_LOGIN = "LOGIN" + + self.ehlo_or_helo_if_needed() + + if not self.has_extn("auth"): + raise SMTPException("SMTP AUTH extension not supported by server.") + + # Authentication methods the server supports: + authlist = self.esmtp_features["auth"].split() + + # List of authentication methods we support: from preferred to + # less preferred methods. Except for the purpose of testing the weaker + # ones, we prefer stronger methods like CRAM-MD5: + preferred_auths = [AUTH_CRAM_MD5, AUTH_PLAIN, AUTH_LOGIN] + + # Determine the authentication method we'll use + authmethod = None + for method in preferred_auths: + if method in authlist: + authmethod = method + break + + if authmethod == AUTH_CRAM_MD5: + (code, resp) = self.docmd("AUTH", AUTH_CRAM_MD5) + if code == 503: + # 503 == 'Error: already authenticated' + return (code, resp) + (code, resp) = self.docmd(encode_cram_md5(resp, user, password)) + elif authmethod == AUTH_PLAIN: + (code, resp) = self.docmd("AUTH", + AUTH_PLAIN + " " + encode_plain(user, password)) + elif authmethod == AUTH_LOGIN: + (code, resp) = self.docmd("AUTH", + "%s %s" % (AUTH_LOGIN, encode_base64(user, eol=""))) + if code != 334: + raise SMTPAuthenticationError(code, resp) + (code, resp) = self.docmd(encode_base64(password, eol="")) + elif authmethod is None: + raise SMTPException("No suitable authentication method found.") + if code not in (235, 503): + # 235 == 'Authentication successful' + # 503 == 'Error: already authenticated' + raise SMTPAuthenticationError(code, resp) + return (code, resp) + + def starttls(self, keyfile = None, certfile = None): + """Puts the connection to the SMTP server into TLS mode. + + If there has been no previous EHLO or HELO command this session, this + method tries ESMTP EHLO first. + + If the server supports TLS, this will encrypt the rest of the SMTP + session. If you provide the keyfile and certfile parameters, + the identity of the SMTP server and client can be checked. This, + however, depends on whether the socket module really checks the + certificates. + + This method may raise the following exceptions: + + SMTPHeloError The server didn't reply properly to + the helo greeting. + """ + self.ehlo_or_helo_if_needed() + if not self.has_extn("starttls"): + raise SMTPException("STARTTLS extension not supported by server.") + (resp, reply) = self.docmd("STARTTLS") + if resp == 220: + if not _have_ssl: + raise RuntimeError("No SSL support included in this Python") + self.sock = ssl.wrap_socket(self.sock, keyfile, certfile) + self.file = SSLFakeFile(self.sock) + # RFC 3207: + # The client MUST discard any knowledge obtained from + # the server, such as the list of SMTP service extensions, + # which was not obtained from the TLS negotiation itself. + self.helo_resp = None + self.ehlo_resp = None + self.esmtp_features = {} + self.does_esmtp = 0 + return (resp, reply) + + def sendmail(self, from_addr, to_addrs, msg, mail_options=[], + rcpt_options=[]): + """This command performs an entire mail transaction. + + The arguments are: + - from_addr : The address sending this mail. + - to_addrs : A list of addresses to send this mail to. A bare + string will be treated as a list with 1 address. + - msg : The message to send. + - mail_options : List of ESMTP options (such as 8bitmime) for the + mail command. + - rcpt_options : List of ESMTP options (such as DSN commands) for + all the rcpt commands. + + If there has been no previous EHLO or HELO command this session, this + method tries ESMTP EHLO first. If the server does ESMTP, message size + and each of the specified options will be passed to it. If EHLO + fails, HELO will be tried and ESMTP options suppressed. + + This method will return normally if the mail is accepted for at least + one recipient. It returns a dictionary, with one entry for each + recipient that was refused. Each entry contains a tuple of the SMTP + error code and the accompanying error message sent by the server. + + This method may raise the following exceptions: + + SMTPHeloError The server didn't reply properly to + the helo greeting. + SMTPRecipientsRefused The server rejected ALL recipients + (no mail was sent). + SMTPSenderRefused The server didn't accept the from_addr. + SMTPDataError The server replied with an unexpected + error code (other than a refusal of + a recipient). + + Note: the connection will be open even after an exception is raised. + + Example: + + >>> import smtplib + >>> s=smtplib.SMTP("localhost") + >>> tolist=["one@one.org","two@two.org","three@three.org","four@four.org"] + >>> msg = '''\\ + ... From: Me@my.org + ... Subject: testin'... + ... + ... This is a test ''' + >>> s.sendmail("me@my.org",tolist,msg) + { "three@three.org" : ( 550 ,"User unknown" ) } + >>> s.quit() + + In the above example, the message was accepted for delivery to three + of the four addresses, and one was rejected, with the error code + 550. If all addresses are accepted, then the method will return an + empty dictionary. + + """ + self.ehlo_or_helo_if_needed() + esmtp_opts = [] + if self.does_esmtp: + # Hmmm? what's this? -ddm + # self.esmtp_features['7bit']="" + if self.has_extn('size'): + esmtp_opts.append("size=%d" % len(msg)) + for option in mail_options: + esmtp_opts.append(option) + + (code,resp) = self.mail(from_addr, esmtp_opts) + if code != 250: + self.rset() + raise SMTPSenderRefused(code, resp, from_addr) + senderrs={} + if isinstance(to_addrs, basestring): + to_addrs = [to_addrs] + for each in to_addrs: + (code,resp)=self.rcpt(each, rcpt_options) + if (code != 250) and (code != 251): + senderrs[each]=(code,resp) + if len(senderrs)==len(to_addrs): + # the server refused all our recipients + self.rset() + raise SMTPRecipientsRefused(senderrs) + (code,resp) = self.data(msg) + if code != 250: + self.rset() + raise SMTPDataError(code, resp) + #if we got here then somebody got our mail + return senderrs + + + def close(self): + """Close the connection to the SMTP server.""" + if self.file: + self.file.close() + self.file = None + if self.sock: + self.sock.close() + self.sock = None + + + def quit(self): + """Terminate the SMTP session.""" + res = self.docmd("quit") + self.close() + return res + +if _have_ssl: + + class SMTP_SSL(SMTP): + """ This is a subclass derived from SMTP that connects over an SSL encrypted + socket (to use this class you need a socket module that was compiled with SSL + support). If host is not specified, '' (the local host) is used. If port is + omitted, the standard SMTP-over-SSL port (465) is used. keyfile and certfile + are also optional - they can contain a PEM formatted private key and + certificate chain file for the SSL connection. + """ + def __init__(self, host='', port=0, local_hostname=None, + keyfile=None, certfile=None, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + debug_to=partial(print, file=sys.stderr)): + self.keyfile = keyfile + self.certfile = certfile + SMTP.__init__(self, host, port, local_hostname, timeout, + debug_to=debug_to) + self.default_port = SMTP_SSL_PORT + + def _get_socket(self, host, port, timeout): + if self.debuglevel > 0: self.debug('connect:', (host, port)) + new_socket = socket.create_connection((host, port), timeout) + new_socket = ssl.wrap_socket(new_socket, self.keyfile, self.certfile) + self.file = SSLFakeFile(new_socket) + return new_socket + + __all__.append("SMTP_SSL") + +# +# LMTP extension +# +LMTP_PORT = 2003 + +class LMTP(SMTP): + """LMTP - Local Mail Transfer Protocol + + The LMTP protocol, which is very similar to ESMTP, is heavily based + on the standard SMTP client. It's common to use Unix sockets for LMTP, + so our connect() method must support that as well as a regular + host:port server. To specify a Unix socket, you must use an absolute + path as the host, starting with a '/'. + + Authentication is supported, using the regular SMTP mechanism. When + using a Unix socket, LMTP generally don't support or require any + authentication, but your mileage might vary.""" + + ehlo_msg = "lhlo" + + def __init__(self, host = '', port = LMTP_PORT, local_hostname = None): + """Initialize a new instance.""" + SMTP.__init__(self, host, port, local_hostname) + + def connect(self, host = 'localhost', port = 0): + """Connect to the LMTP daemon, on either a Unix or a TCP socket.""" + if host[0] != '/': + return SMTP.connect(self, host, port) + + # Handle Unix-domain sockets. + try: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(host) + except socket.error, msg: + if self.debuglevel > 0: self.debug('connect fail:', host) + if self.sock: + self.sock.close() + self.sock = None + raise socket.error, msg + (code, msg) = self.getreply() + if self.debuglevel > 0: self.debug("connect:", msg) + return (code, msg) + + +