smtp backend refactoring

This commit is contained in:
Sergey Lavrinenko 2015-03-28 21:32:57 +03:00
parent 7618f71175
commit 37d0b67dd0
5 changed files with 183 additions and 168 deletions

View File

@ -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 "<emails.backend.SMTPResponse status_code=%s status_text=%s>" % (self.status_code.__repr__(),
self.status_text.__repr__())

View File

@ -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

View File

@ -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 "<emails.smtp.SMTPResponse status_code=%s status_text=%s>" % (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."""

View File

@ -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):

View File

@ -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': '<p>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()