diff --git a/emails/backend/response.py b/emails/backend/response.py new file mode 100644 index 0000000..983c4b8 --- /dev/null +++ b/emails/backend/response.py @@ -0,0 +1,57 @@ +# encoding: utf-8 + + +class Response(object): + + def __init__(self, exception=None, backend=None): + self.backend = backend + self.set_exception(exception) + self.from_addr = None + self.to_addrs = None + self._finished = False + + def set_exception(self, exc): + self._exc = exc + + def raise_if_needed(self): + if self._exc: + raise self._exc + + @property + def error(self): + return self._exc + + @property + def success(self): + return self._finished + + +class SMTPResponse(Response): + + def __init__(self, exception=None, backend=None): + + super(SMTPResponse, self).__init__(exception=exception, backend=backend) + + self.responses = [] + + self.esmtp_opts = None + self.rcpt_options = None + + self.status_code = None + self.status_text = None + self.last_command = None + + def set_status(self, command, code, text, **kwargs): + self.responses.append([command, code, text, kwargs]) + self.status_code = code + self.status_text = text + self.last_command = command + + @property + def success(self): + return self._finished and self.status_code and self.status_code == 250 + + def __repr__(self): + return "" % (self.status_code.__repr__(), + self.status_text.__repr__()) + diff --git a/emails/backend/smtp/backend.py b/emails/backend/smtp/backend.py index 618590c..86b7e82 100644 --- a/emails/backend/smtp/backend.py +++ b/emails/backend/smtp/backend.py @@ -1,14 +1,14 @@ # encoding: utf-8 from __future__ import unicode_literals -__all__ = ['SMTPBackend'] +__all__ = ['SMTPBackend', ] import smtplib import logging from functools import wraps - -from .client import SMTPResponse, SMTPClientWithResponse, SMTPClientWithResponse_SSL -from ...compat import to_bytes +from ..response import SMTPResponse +from .client import SMTPClientWithResponse, SMTPClientWithResponse_SSL +from ...utils import DNS_NAME logger = logging.getLogger(__name__) @@ -16,11 +16,7 @@ logger = logging.getLogger(__name__) class SMTPBackend: """ - SMTPSender is a wrapper for smtplib.SMTP class. - Differences are: - a) it transparently uses SSL or no-SSL connection - b) sendmail method sends only one message, but returns more information - about server response (i.e. response code) + SMTPBackend manages a smtp connection. """ DEFAULT_SOCKET_TIMEOUT = 5 @@ -29,11 +25,7 @@ class SMTPBackend: connection_ssl_cls = SMTPClientWithResponse_SSL response_cls = SMTPResponse - - def __init__(self, - ssl=False, - fail_silently=True, - **kwargs): + def __init__(self, ssl=False, fail_silently=True, **kwargs): self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls @@ -44,82 +36,83 @@ class SMTPBackend: "ssl/tls are mutually exclusive, so only set " "one of those settings to True.") - if 'timeout' not in kwargs: - kwargs['timeout'] = self.DEFAULT_SOCKET_TIMEOUT + kwargs.setdefault('timeout', self.DEFAULT_SOCKET_TIMEOUT) + kwargs.setdefault('local_hostname', DNS_NAME.get_fqdn()) + self.smtp_cls_kwargs = kwargs self.host = kwargs.get('host') self.port = kwargs.get('port') self.fail_silently = fail_silently - self.connection = None - #self.local_hostname=DNS_NAME.get_fqdn() - def open(self): - if self.connection is None: - self.connection = self.smtp_cls(parent=self, **self.smtp_cls_kwargs) - self.connection.initialize() - return self.connection + self._client = None + + def get_client(self): + if self._client is None: + self._client = self.smtp_cls(parent=self, **self.smtp_cls_kwargs) + return self._client def close(self): - """Closes the connection to the email server.""" - if self.connection is None: - return + """ + Closes the connection to the email server. + """ - try: - self.connection.close() - except: - if self.fail_silently: - return - raise - finally: - self.connection = None + if self._client: + try: + self._client.close() + except: + if self.fail_silently: + return + raise + finally: + self._client = None def make_response(self, exception=None): - return self.response_cls(host=self.host, port=self.port, exception=exception) + return self.response_cls(backend=self, exception=exception) def retry_on_disconnect(self, func): @wraps(func) def wrapper(*args, **kwargs): try: return func(*args, **kwargs) - except smtplib.SMTPServerDisconnected as e: - # If server disconected, just connect again + except smtplib.SMTPServerDisconnected: + # If server disconected, clear old client logging.debug('SMTPServerDisconnected, retry once') self.close() - self.open() return func(*args, **kwargs) return wrapper - def _sendmail_on_connection(self, *args, **kwargs): - return self.connection.sendmail(*args, **kwargs) + def _send(self, **kwargs): - def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]): + try: + client = self.get_client() + except (IOError, smtplib.SMTPException) as exc: + logger.exception("Error connecting smtp server") + response = self.make_response(exception=exc) + if not self.fail_silently: + response.raise_if_needed() + return response + + return client.sendmail(**kwargs) + + def sendmail(self, from_addr, to_addrs, msg, mail_options=None, rcpt_options=None): if not to_addrs: - return [] + return None if not isinstance(to_addrs, (list, tuple)): to_addrs = [to_addrs, ] - try: - self.open() - except (IOError, smtplib.SMTPException) as e: - logger.exception("Error connecting smtp server") - response = self.make_response(exception=e) - if not self.fail_silently: - response.raise_if_needed() - return [response, ] + send = self.retry_on_disconnect(self._send) - _sendmail = self.retry_on_disconnect(self._sendmail_on_connection) - - response = _sendmail(from_addr=from_addr, - to_addrs=to_addrs, - msg=to_bytes(msg.as_string(), 'utf-8'), - mail_options=mail_options, - rcpt_options=rcpt_options) + response = send(from_addr=from_addr, + to_addrs=to_addrs, + msg=msg.as_string(), + mail_options=mail_options, + rcpt_options=rcpt_options) if not self.fail_silently: - [r.raise_if_needed() for r in response] + response.raise_if_needed() return response diff --git a/emails/backend/smtp/client.py b/emails/backend/smtp/client.py index ea093a7..c0be7a8 100644 --- a/emails/backend/smtp/client.py +++ b/emails/backend/smtp/client.py @@ -1,91 +1,63 @@ # encoding: utf-8 -__all__ = ['SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL'] -from smtplib import _have_ssl, SMTP +__all__ = ['SMTPClientWithResponse', 'SMTPClientWithResponse_SSL'] + import smtplib +from smtplib import _have_ssl, SMTP import logging + logger = logging.getLogger(__name__) -class SMTPResponse(object): - - def __init__(self, host=None, port=None, ssl=None, exception=None): - self.host = host - self.port = port - self.ssl = ssl - self.responses = [] - self.exception = exception - self.success = None - self.from_addr = None - self.esmtp_opts = None - self.rcpt_options = None - self.to_addr = None - self.status_code = None - self.status_text = None - self.last_command = None - - def set_status(self, command, code, text): - self.responses.append([command, code, text]) - self.status_code = code - self.status_text = text - self.last_command = command - - def set_exception(self, exc): - self.exception = exc - - def raise_if_needed(self): - if self.exception: - raise self.exception - - @property - def error(self): - return self.exception - - def __repr__(self): - return "" % (self.status_code.__repr__(), - self.status_text.__repr__()) - class SMTPClientWithResponse(SMTP): def __init__(self, parent, **kwargs): + + self._initialized = False + self.parent = parent self.make_response = parent.make_response - self._last_smtp_response = (None, None) self.tls = kwargs.pop('tls', False) self.ssl = kwargs.pop('ssl', False) self.debug = kwargs.pop('debug', 0) - self.set_debuglevel(self.debug) self.user = kwargs.pop('user', None) self.password = kwargs.pop('password', None) + SMTP.__init__(self, **kwargs) + self.initialize() def initialize(self): - if self.tls: - self.starttls() - if self.user: - self.login(user=self.user, password=self.password) - self.ehlo_or_helo_if_needed() - return self + if not self._initialized: + self.set_debuglevel(self.debug) + if self.tls: + self.starttls() + if self.user: + self.login(user=self.user, password=self.password) + self.ehlo_or_helo_if_needed() + self.initialized = True def quit(self): """Closes the connection to the email server.""" try: SMTP.quit(self) except (smtplib.SMTPServerDisconnected, ): - # This happens when calling quit() on a TLS connection - # sometimes, or when the connection was already disconnected - # by the server. self.close() - def data(self, msg): - (code, msg) = SMTP.data(self, msg) - self._last_smtp_response = (code, msg) - return code, msg + def _rset(self): + try: + self.rset() + except smtplib.SMTPServerDisconnected: + pass - def _send_one_mail(self, from_addr, to_addr, msg, mail_options=[], rcpt_options=[]): + def sendmail(self, from_addr, to_addrs, msg, mail_options=None, rcpt_options=None): + if not to_addrs: + return None + + rcpt_options = rcpt_options or [] + mail_options = mail_options or [] esmtp_opts = [] if self.does_esmtp: if self.has_extn('size'): @@ -102,48 +74,42 @@ class SMTPClientWithResponse(SMTP): response.set_status('mail', code, resp) if code != 250: - self.rset() + self._rset() exc = smtplib.SMTPSenderRefused(code, resp, from_addr) response.set_exception(exc) return response - response.to_addr = to_addr + if not isinstance(to_addrs, (list, tuple)): + to_addrs = [to_addrs] + + response.to_addrs = to_addrs response.rcpt_options = rcpt_options[:] + response.refused_recipients = {} - (code, resp) = self.rcpt(to_addr, rcpt_options) - response.set_status('rcpt', code, resp) + for a in to_addrs: + (code, resp) = self.rcpt(a, rcpt_options) + response.set_status('rcpt', code, resp, recipient=a) + if (code != 250) and (code != 251): + response.refused_recipients[a] = (code, resp) - if (code != 250) and (code != 251): - self.rset() - exc = smtplib.SMTPRecipientsRefused(to_addr) + if len(response.refused_recipients) == len(to_addrs): + # the server refused all our recipients + self._rset() + exc = smtplib.SMTPRecipientsRefused(response.refused_recipient) response.set_exception(exc) return response (code, resp) = self.data(msg) response.set_status('data', code, resp) if code != 250: - self.rset() + self._rset() exc = smtplib.SMTPDataError(code, resp) response.set_exception(exc) return response - response.success = True + response._finished = True return response - def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]): - # Send one email and returns one response - if not to_addrs: - return [] - - assert isinstance(to_addrs, (list, tuple)) - - if len(to_addrs)>1: - logger.warning('Beware: emails.smtp.client.SMTPClientWithResponse.sendmail sends full message to each email') - - return [self._send_one_mail(from_addr, to_addr, msg, mail_options, rcpt_options) \ - for to_addr in to_addrs] - - if _have_ssl: @@ -160,10 +126,11 @@ if _have_ssl: SMTP_SSL.__init__(self, **args) SMTPClientWithResponse.__init__(self, **kw) - def data(self, msg): - (code, msg) = SMTP.data(self, msg) - self._last_smtp_response = (code, msg) - return code, msg + def _rset(self): + try: + self.rset() + except (ssl.SSLError, smtplib.SMTPServerDisconnected): + pass def quit(self): """Closes the connection to the email server.""" diff --git a/emails/message.py b/emails/message.py index 21f5b9d..532cf44 100644 --- a/emails/message.py +++ b/emails/message.py @@ -365,17 +365,10 @@ class MessageSendMixin(object): if not from_addr: raise ValueError('No "from" addr') - params = dict(from_addr=from_addr, - to_addrs=[to_addr, ], - msg=self) - if smtp_mail_options: - params['mail_options'] = smtp_mail_options + params = dict(from_addr=from_addr, to_addrs=[to_addr, ], msg=self, + mail_options=smtp_mail_options, rcpt_options=smtp_rcpt_options) - if smtp_rcpt_options: - params['rcpt_options'] = smtp_rcpt_options - - response = smtp.sendmail(**params) - return response[0] + return smtp.sendmail(**params) class MessageTransformerMixin(object): diff --git a/emails/testsuite/smtp/test_smtp_backend.py b/emails/testsuite/smtp/test_smtp_backend.py index 17360e2..8e24503 100644 --- a/emails/testsuite/smtp/test_smtp_backend.py +++ b/emails/testsuite/smtp/test_smtp_backend.py @@ -10,44 +10,46 @@ from emails.backend.smtp import SMTPBackend TRAVIS_CI = os.environ.get('TRAVIS') HAS_INTERNET_CONNECTION = not TRAVIS_CI - -def test_send_to_unknow_host(): - server = SMTPBackend(host='invalid-server.invalid-domain-42.com', port=25) - response = server.sendmail(to_addrs='s@lavr.me', from_addr='s@lavr.me', msg='...')[0] - server.close() - assert response.status_code is None - assert response.error is not None - assert isinstance(response.error, IOError) - print("response.error.errno=", response.error.errno) - if HAS_INTERNET_CONNECTION: - # IOError: [Errno 8] nodename nor servname provided, or not known - assert response.error.errno==8 - - SAMPLE_MESSAGE = {'html': '

Test from python-emails', 'mail_from': 's@lavr.me', 'mail_to': 'sergei-nko@yandex.ru', 'subject': 'Test from python-emails'} +def test_send_to_unknown_host(): + server = SMTPBackend(host='invalid-server.invalid-domain-42.com', port=25) + response = server.sendmail(to_addrs='s@lavr.me', from_addr='s@lavr.me', msg=emails.html(**SAMPLE_MESSAGE)) + server.close() + assert response.status_code is None + assert response.error is not None + assert isinstance(response.error, IOError) + assert not response.success + print("response.error.errno=", response.error.errno) + if HAS_INTERNET_CONNECTION: + # IOError: [Errno 8] nodename nor servname provided, or not known + assert response.error.errno == 8 + + def test_smtp_reconnect(smtp_server): # Simulate server disconnection # Check that SMTPBackend will reconnect server = SMTPBackend(host=smtp_server.host, port=smtp_server.port, debug=1) - server.open() + client = server.get_client() logging.debug('simulate socket disconnect') - server.connection.sock.close() # simulate disconnect + client.sock.close() # simulate disconnect response = server.sendmail(to_addrs='s@lavr.me', from_addr='s@lavr.me', msg=emails.html(**SAMPLE_MESSAGE)) server.close() + assert response.success print(response) def test_smtp_init_error(smtp_server): + # test error when ssl and tls arguments both set with pytest.raises(ValueError): SMTPBackend(host=smtp_server.host, port=smtp_server.port, @@ -67,12 +69,14 @@ def test_smtp_dict1(smtp_server): response = emails.html(**SAMPLE_MESSAGE).send(smtp=smtp_server.as_dict()) print(response) assert response.status_code == 250 + assert response.success def test_smtp_dict2(smtp_server_with_auth): response = emails.html(**SAMPLE_MESSAGE).send(smtp=smtp_server_with_auth.as_dict()) print(response) assert response.status_code == 250 + assert response.success def test_smtp_dict2(smtp_server_with_ssl): smtp = smtp_server_with_ssl.as_dict() @@ -80,4 +84,5 @@ def test_smtp_dict2(smtp_server_with_ssl): response = message.send(smtp=smtp) print(response) assert response.status_code == 250 - message.smtp_pool[smtp].connection.quit() + assert response.success + message.smtp_pool[smtp].get_client().quit()