aiosmtpd-1.1/0000775000175000017500000000000013127451604013460 5ustar barrybarry00000000000000aiosmtpd-1.1/examples/0000775000175000017500000000000013127451604015276 5ustar barrybarry00000000000000aiosmtpd-1.1/examples/server.py0000664000175000017500000000066413015130134017150 0ustar barrybarry00000000000000import asyncio import logging from aiosmtpd.controller import Controller from aiosmtpd.handlers import Sink async def amain(loop): cont = Controller(Sink(), hostname='::0', port=8025) cont.start() if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() loop.create_task(amain(loop=loop)) try: loop.run_forever() except KeyboardInterrupt: pass aiosmtpd-1.1/examples/__init__.py0000664000175000017500000000000013015130134017361 0ustar barrybarry00000000000000aiosmtpd-1.1/examples/client.py0000664000175000017500000000030413015130134017107 0ustar barrybarry00000000000000from smtplib import SMTP s = SMTP('localhost', 8025) s.sendmail('anne@example.com', ['bart@example.com'], """\ From: anne@example.com To: bart@example.com Subject: A test testing """) s.quit() aiosmtpd-1.1/PKG-INFO0000664000175000017500000000125513127451604014560 0ustar barrybarry00000000000000Metadata-Version: 1.1 Name: aiosmtpd Version: 1.1 Summary: aiosmtpd - asyncio based SMTP server Home-page: http://aiosmtpd.readthedocs.io/ Author: UNKNOWN Author-email: UNKNOWN License: http://www.apache.org/licenses/LICENSE-2.0 Description: This is a server for SMTP and related protocols, similar in utility to the standard library's smtpd.py module, but rewritten to be based on asyncio for Python 3. Keywords: email Platform: UNKNOWN Classifier: License :: OSI Approved Classifier: Intended Audience :: Developers Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Communications :: Email :: Mail Transport Agents Classifier: Framework :: AsyncIO aiosmtpd-1.1/.travis.yml0000664000175000017500000000062613114056512015570 0ustar barrybarry00000000000000language: python install: - pip install tox - python3 setup.py egg_info - pip install -r aiosmtpd.egg-info/requires.txt matrix: include: - python: "3.5" env: INTERP=py35 PYTHONASYNCIODEBUG=1 - python: "3.6" env: INTERP=py36 PYTHONASYNCIODEBUG=1 script: - tox -e $INTERP-nocov,$INTERP-cov,qa,docs - 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then tox -e $INTERP-diffcov; fi' aiosmtpd-1.1/conf.py0000664000175000017500000002114513015130134014746 0ustar barrybarry00000000000000# -*- coding: utf-8 -*- # # aiosmtpd documentation build configuration file, created by # sphinx-quickstart on Fri Oct 16 12:18:52 2015. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'README' # General information about the project. project = u'aiosmtpd' copyright = u'2015-2016, aiosmtpd hackers' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '1.0' # The full version, including alpha/beta/rc tags. from setup_helpers import get_version release = get_version('aiosmtpd/smtp.py') # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build', '.tox/*', '.git*'] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'aiosmtpddoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'aiosmtpd.tex', u'aiosmtpd Documentation', u'aiosmtpd hackers', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'aiosmtpd', u'aiosmtpd Documentation', [u'aiosmtpd hackers'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'aiosmtpd', u'aiosmtpd Documentation', u'aiosmtpd hackers', 'aiosmtpd', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False def index_html(): import errno cwd = os.getcwd() try: try: os.makedirs('build/sphinx/html') except OSError as error: if error.errno != errno.EEXIST: raise os.chdir('build/sphinx/html') try: os.symlink('README.html', 'index.html') print('index.html -> README.html') except OSError as error: if error.errno != errno.EEXIST: raise finally: os.chdir(cwd) import atexit atexit.register(index_html) aiosmtpd-1.1/setup_helpers.py0000664000175000017500000001174713015130134016712 0ustar barrybarry00000000000000# Copyright (C) 2009-2015 Barry A. Warsaw # # This file is part of setup_helpers.py # # setup_helpers.py is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # setup_helpers.py is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License # for more details. # # You should have received a copy of the GNU Lesser General Public License # along with setup_helpers.py. If not, see . """setup.py helper functions.""" __all__ = [ 'description', 'find_doctests', 'get_version', 'long_description', 'require_python', ] import os import re import sys DEFAULT_VERSION_RE = re.compile( r'(?P\d+\.\d+(?:\.\d+)?(?:(?:a|b|rc)\d+)?)') EMPTYSTRING = '' __version__ = '2.3' def require_python(minimum): """Require at least a minimum Python version. The version number is expressed in terms of `sys.hexversion`. E.g. to require a minimum of Python 2.6, use:: >>> require_python(0x206000f0) :param minimum: Minimum Python version supported. :type minimum: integer """ if sys.hexversion < minimum: hversion = hex(minimum)[2:] if len(hversion) % 2 != 0: hversion = '0' + hversion split = list(hversion) parts = [] while split: parts.append(int(''.join((split.pop(0), split.pop(0))), 16)) major, minor, micro, release = parts if release == 0xf0: print('Python {0}.{1}.{2} or better is required'.format( major, minor, micro)) else: print('Python {0}.{1}.{2} ({3}) or better is required'.format( major, minor, micro, hex(release)[2:])) sys.exit(1) def get_version(filename, pattern=None): """Extract the __version__ from a file without importing it. While you could get the __version__ by importing the module, the very act of importing can cause unintended consequences. For example, Distribute's automatic 2to3 support will break. Instead, this searches the file for a line that starts with __version__, and extract the version number by regular expression matching. By default, two or three dot-separated digits are recognized, but by passing a pattern parameter, you can recognize just about anything. Use the `version` group name to specify the match group. :param filename: The name of the file to search. :type filename: string :param pattern: Optional alternative regular expression pattern to use. :type pattern: string :return: The version that was extracted. :rtype: string """ if pattern is None: cre = DEFAULT_VERSION_RE else: cre = re.compile(pattern) with open(filename) as fp: for line in fp: if line.startswith('__version__'): mo = cre.search(line) assert mo, 'No valid __version__ string found' return mo.group('version') raise AssertionError('No __version__ assignment found') def find_doctests(start='.', extension='.rst'): """Find separate-file doctests in the package. This is useful for Distribute's automatic 2to3 conversion support. The `setup()` keyword argument `convert_2to3_doctests` requires file names, which may be difficult to track automatically as you add new doctests. :param start: Directory to start searching in (default is cwd) :type start: string :param extension: Doctest file extension (default is .txt) :type extension: string :return: The doctest files found. :rtype: list """ doctests = [] for dirpath, dirnames, filenames in os.walk(start): doctests.extend(os.path.join(dirpath, filename) for filename in filenames if filename.endswith(extension)) return doctests def long_description(*filenames): """Provide a long description.""" res = [''] for filename in filenames: with open(filename) as fp: for line in fp: res.append(' ' + line) res.append('') res.append('\n') return EMPTYSTRING.join(res) def description(filename): """Provide a short description.""" # This ends up in the Summary header for PKG-INFO and it should be a # one-liner. It will get rendered on the package page just below the # package version header but above the long_description, which ironically # gets stuff into the Description header. It should not include reST, so # pick out the first single line after the double header. with open(filename) as fp: for lineno, line in enumerate(fp): if lineno < 3: continue line = line.strip() if len(line) > 0: return line aiosmtpd-1.1/.coverage.ini0000664000175000017500000000044313114056512016026 0ustar barrybarry00000000000000[run] branch = true parallel = true omit = setup* aiosmtpd/testing/* aiosmtpd/tests/* .tox/*/lib/python3.*/site-packages/* [paths] source = aiosmtpd .tox/*/lib/python*/site-packages/aiosmtpd [report] exclude_lines = pragma: nocover pragma: no${PLATFORM} aiosmtpd-1.1/aiosmtpd/0000775000175000017500000000000013127451604015300 5ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/testing/0000775000175000017500000000000013127451604016755 5ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/testing/helpers.py0000664000175000017500000000270113101503533020760 0ustar barrybarry00000000000000"""Testing helpers.""" import sys import socket import struct import asyncio import logging import warnings from contextlib import ExitStack from unittest.mock import patch def reset_connection(client): # Close the connection with a TCP RST instead of a TCP FIN. client must # be a smtplib.SMTP instance. # # https://stackoverflow.com/a/6440364/1570972 # # socket(7) SO_LINGER option. # # struct linger { # int l_onoff; /* linger active */ # int l_linger; /* how many seconds to linger for */ # }; # # Is this correct for Windows/Cygwin and macOS? struct_format = 'hh' if sys.platform == 'win32' else 'ii' l_onoff = 1 l_linger = 0 client.sock.setsockopt( socket.SOL_SOCKET, socket.SO_LINGER, struct.pack(struct_format, l_onoff, l_linger)) client.close() # For integration with flufl.testing. def setup(testobj): testobj.globs['resources'] = ExitStack() def teardown(testobj): testobj.globs['resources'].close() def make_debug_loop(): loop = asyncio.get_event_loop() loop.set_debug(True) return loop def start(plugin): if plugin.stderr: # Turn on lots of debugging. patch('aiosmtpd.smtp.make_loop', make_debug_loop).start() logging.getLogger('asyncio').setLevel(logging.DEBUG) logging.getLogger('mail.log').setLevel(logging.DEBUG) warnings.filterwarnings('always', category=ResourceWarning) aiosmtpd-1.1/aiosmtpd/testing/__init__.py0000664000175000017500000000000013015130134021040 0ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/main.py0000664000175000017500000001266513127275524016615 0ustar barrybarry00000000000000import os import sys import signal import asyncio import logging from aiosmtpd.smtp import DATA_SIZE_DEFAULT, SMTP, __version__ from argparse import ArgumentParser from contextlib import suppress from functools import partial from importlib import import_module from public import public try: import pwd except ImportError: # pragma: nocover pwd = None DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8025 # Make the program name a little nicer, especially when `python3 -m aiosmtpd` # is used. PROGRAM = 'smtpd' if '__main__.py' in sys.argv[0] else sys.argv[0] def parseargs(args=None): parser = ArgumentParser( prog=PROGRAM, description='An RFC 5321 SMTP server with extensions.') parser.add_argument( '-v', '--version', action='version', version='%(prog)s {}'.format(__version__)) parser.add_argument( '-n', '--nosetuid', dest='setuid', default=True, action='store_false', help="""This program generally tries to setuid `nobody`, unless this flag is set. The setuid call will fail if this program is not run as root (in which case, use this flag).""") parser.add_argument( '-c', '--class', dest='classpath', default='aiosmtpd.handlers.Debugging', help="""Use the given class, as a Python dotted import path, as the handler class for SMTP events. This class can process received messages and do other actions during the SMTP dialog. Uses a debugging handler by default.""") parser.add_argument( '-s', '--size', type=int, help="""Restrict the total size of the incoming message to SIZE number of bytes via the RFC 1870 SIZE extension. Defaults to {} bytes.""".format(DATA_SIZE_DEFAULT)) parser.add_argument( '-u', '--smtputf8', default=False, action='store_true', help="""Enable the SMTPUTF8 extension and behave as an RFC 6531 SMTP proxy.""") parser.add_argument( '-d', '--debug', default=0, action='count', help="""Increase debugging output.""") parser.add_argument( '-l', '--listen', metavar='HOST:PORT', nargs='?', default=None, help="""Optional host and port to listen on. If the PORT part is not given, then port {port} is used. If only :PORT is given, then {host} is used for the hostname. If neither are given, {host}:{port} is used.""".format( host=DEFAULT_HOST, port=DEFAULT_PORT)) parser.add_argument( 'classargs', metavar='CLASSARGS', nargs='*', default=(), help="""Additional arguments passed to the handler CLASS.""") args = parser.parse_args(args) # Find the handler class. path, dot, name = args.classpath.rpartition('.') module = import_module(path) handler_class = getattr(module, name) if hasattr(handler_class, 'from_cli'): args.handler = handler_class.from_cli(parser, *args.classargs) else: if len(args.classargs) > 0: parser.error('Handler class {} takes no arguments'.format(path)) args.handler = handler_class() # Parse the host:port argument. if args.listen is None: args.host = DEFAULT_HOST args.port = DEFAULT_PORT else: host, colon, port = args.listen.rpartition(':') if len(colon) == 0: args.host = port args.port = DEFAULT_PORT else: args.host = DEFAULT_HOST if len(host) == 0 else host try: args.port = int(DEFAULT_PORT if len(port) == 0 else port) except ValueError: parser.error('Invalid port number: {}'.format(port)) return parser, args @public def main(args=None): parser, args = parseargs(args=args) if args.setuid: # pragma: nomswin if pwd is None: print('Cannot import module "pwd"; try running with -n option.', file=sys.stderr) sys.exit(1) nobody = pwd.getpwnam('nobody').pw_uid try: os.setuid(nobody) except PermissionError: print('Cannot setuid "nobody"; try running with -n option.', file=sys.stderr) sys.exit(1) factory = partial( SMTP, args.handler, data_size_limit=args.size, enable_SMTPUTF8=args.smtputf8) logging.basicConfig(level=logging.ERROR) log = logging.getLogger('mail.log') loop = asyncio.get_event_loop() if args.debug > 0: log.setLevel(logging.INFO) if args.debug > 1: log.setLevel(logging.DEBUG) if args.debug > 2: loop.set_debug(enabled=True) log.info('Server listening on %s:%s', args.host, args.port) server = loop.run_until_complete( loop.create_server(factory, host=args.host, port=args.port)) # Signal handlers are only supported on *nix, so just ignore the failure # to set this on Windows. with suppress(NotImplementedError): loop.add_signal_handler(signal.SIGINT, loop.stop) log.info('Starting asyncio loop') try: loop.run_forever() except KeyboardInterrupt: pass server.close() log.info('Completed asyncio loop') loop.run_until_complete(server.wait_closed()) loop.close() if __name__ == '__main__': # pragma: nocover main() aiosmtpd-1.1/aiosmtpd/smtp.py0000664000175000017500000006537213127444757016665 0ustar barrybarry00000000000000import ssl import socket import asyncio import logging import collections from asyncio import sslproto from email._header_value_parser import get_addr_spec, get_angle_addr from email.errors import HeaderParseError from public import public from warnings import warn __version__ = '1.1' __ident__ = 'Python SMTP {}'.format(__version__) log = logging.getLogger('mail.log') DATA_SIZE_DEFAULT = 33554432 EMPTYBYTES = b'' NEWLINE = '\n' MISSING = object() @public class Session: def __init__(self, loop): self.peer = None self.ssl = None self.host_name = None self.extended_smtp = False self.loop = loop @public class Envelope: def __init__(self): self.mail_from = None self.mail_options = [] self.smtp_utf8 = False self.content = None self.original_content = None self.rcpt_tos = [] self.rcpt_options = [] # This is here to enable debugging output when the -E option is given to the # unit test suite. In that case, this function is mocked to set the debug # level on the loop (as if PYTHONASYNCIODEBUG=1 were set). def make_loop(): return asyncio.get_event_loop() def syntax(text, extended=None, when=None): def decorator(f): f.__smtp_syntax__ = text f.__smtp_syntax_extended__ = extended f.__smtp_syntax_when__ = when return f return decorator @public class SMTP(asyncio.StreamReaderProtocol): command_size_limit = 512 command_size_limits = collections.defaultdict( lambda x=command_size_limit: x) def __init__(self, handler, *, data_size_limit=DATA_SIZE_DEFAULT, enable_SMTPUTF8=False, decode_data=False, hostname=None, tls_context=None, require_starttls=False, loop=None): self.__ident__ = __ident__ self.loop = loop if loop else make_loop() super().__init__( asyncio.StreamReader(loop=self.loop), client_connected_cb=self._client_connected_cb, loop=self.loop) self.event_handler = handler self.data_size_limit = data_size_limit self.enable_SMTPUTF8 = enable_SMTPUTF8 self._decode_data = decode_data self.command_size_limits.clear() if hostname: self.hostname = hostname else: self.hostname = socket.getfqdn() self.tls_context = tls_context if tls_context: # Through rfc3207 part 4.1 certificate checking is part of SMTP # protocol, not SSL layer. self.tls_context.check_hostname = False self.tls_context.verify_mode = ssl.CERT_NONE self.require_starttls = tls_context and require_starttls self._tls_handshake_okay = True self._tls_protocol = None self._original_transport = None self.session = None self.envelope = None self.transport = None self._handler_coroutine = None def _create_session(self): return Session(self.loop) def _create_envelope(self): return Envelope() async def _call_handler_hook(self, command, *args): hook = getattr(self.event_handler, 'handle_' + command, None) if hook is None: return MISSING status = await hook(self, self.session, self.envelope, *args) return status @property def max_command_size_limit(self): try: return max(self.command_size_limits.values()) except ValueError: return self.command_size_limit def connection_made(self, transport): # Reset state due to rfc3207 part 4.2. self._set_rset_state() self.session = self._create_session() self.session.peer = transport.get_extra_info('peername') seen_starttls = (self._original_transport is not None) if self.transport is not None and seen_starttls: # It is STARTTLS connection over normal connection. self._reader._transport = transport self._writer._transport = transport self.transport = transport # Do SSL certificate checking as rfc3207 part 4.1 says. Why is # _extra a protected attribute? self.session.ssl = self._tls_protocol._extra handler = getattr(self.event_handler, 'handle_STARTTLS', None) if handler is None: self._tls_handshake_okay = True else: self._tls_handshake_okay = handler( self, self.session, self.envelope) else: super().connection_made(transport) self.transport = transport log.info('Peer: %r', self.session.peer) # Process the client's requests. self._handler_coroutine = self.loop.create_task( self._handle_client()) def connection_lost(self, error): log.info('%r connection lost', self.session.peer) # If STARTTLS was issued, then our transport is the SSL protocol # transport, and we need to close the original transport explicitly, # otherwise an unexpected eof_received() will be called *after* the # connection_lost(). At that point the stream reader will already be # destroyed and we'll get a traceback in super().eof_received() below. if self._original_transport is not None: self._original_transport.close() super().connection_lost(error) self._handler_coroutine.cancel() self.transport = None def eof_received(self): log.info('%r EOF received', self.session.peer) self._handler_coroutine.cancel() if self.session.ssl is not None: # pragma: nomswin # If STARTTLS was issued, return False, because True has no effect # on an SSL transport and raises a warning. Our superclass has no # way of knowing we switched to SSL so it might return True. # # This entire method seems not to be called during any of the # starttls tests on Windows. I don't really know why, but it # causes these lines to fail coverage, hence the `nomswin` pragma # above. return False return super().eof_received() def _client_connected_cb(self, reader, writer): # This is redundant since we subclass StreamReaderProtocol, but I like # the shorter names. self._reader = reader self._writer = writer def _set_post_data_state(self): """Reset state variables to their post-DATA state.""" self.envelope = self._create_envelope() def _set_rset_state(self): """Reset all state variables except the greeting.""" self._set_post_data_state() async def push(self, status): response = bytes( status + '\r\n', 'utf-8' if self.enable_SMTPUTF8 else 'ascii') self._writer.write(response) log.debug(response) await self._writer.drain() async def handle_exception(self, error): if hasattr(self.event_handler, 'handle_exception'): status = await self.event_handler.handle_exception(error) return status else: log.exception('SMTP session exception') status = '500 Error: ({}) {}'.format( error.__class__.__name__, str(error)) return status async def _handle_client(self): log.info('%r handling connection', self.session.peer) await self.push('220 {} {}'.format(self.hostname, self.__ident__)) while self.transport is not None: # pragma: nobranch # XXX Put the line limit stuff into the StreamReader? try: line = await self._reader.readline() log.debug('_handle_client readline: %s', line) # XXX this rstrip may not completely preserve old behavior. line = line.rstrip(b'\r\n') log.info('%r Data: %s', self.session.peer, line) if not line: await self.push('500 Error: bad syntax') continue i = line.find(b' ') # Decode to string only the command name part, which must be # ASCII as per RFC. If there is an argument, it is decoded to # UTF-8/surrogateescape so that non-UTF-8 data can be # re-encoded back to the original bytes when the SMTP command # is handled. if i < 0: command = line.upper().decode(encoding='ascii') arg = None else: command = line[:i].upper().decode(encoding='ascii') arg = line[i+1:].strip() # Remote SMTP servers can send us UTF-8 content despite # whether they've declared to do so or not. Some old # servers can send 8-bit data. Use surrogateescape so # that the fidelity of the decoding is preserved, and the # original bytes can be retrieved. if self.enable_SMTPUTF8: arg = str( arg, encoding='utf-8', errors='surrogateescape') else: try: arg = str(arg, encoding='ascii', errors='strict') except UnicodeDecodeError: # This happens if enable_SMTPUTF8 is false, meaning # that the server explicitly does not want to # accept non-ASCII, but the client ignores that and # sends non-ASCII anyway. await self.push('500 Error: strict ASCII mode') # Should we await self.handle_exception()? continue max_sz = (self.command_size_limits[command] if self.session.extended_smtp else self.command_size_limit) if len(line) > max_sz: await self.push('500 Error: line too long') continue if not self._tls_handshake_okay and command != 'QUIT': await self.push( '554 Command refused due to lack of security') continue if (self.require_starttls and not self._tls_protocol and command not in ['EHLO', 'STARTTLS', 'QUIT']): # RFC3207 part 4 await self.push('530 Must issue a STARTTLS command first') continue method = getattr(self, 'smtp_' + command, None) if method is None: await self.push( '500 Error: command "%s" not recognized' % command) continue await method(arg) except asyncio.CancelledError: # The connection got reset during the DATA command. # XXX If handler method raises ConnectionResetError, we should # verify that it was actually self._reader that was reset. log.info('Connection lost during _handle_client()') self._writer.close() raise except Exception as error: try: status = await self.handle_exception(error) await self.push(status) except Exception as error: try: log.exception('Exception in handle_exception()') status = '500 Error: ({}) {}'.format( error.__class__.__name__, str(error)) except Exception: status = '500 Error: Cannot describe error' await self.push(status) # SMTP and ESMTP commands @syntax('HELO hostname') async def smtp_HELO(self, hostname): if not hostname: await self.push('501 Syntax: HELO hostname') return self._set_rset_state() self.session.extended_smtp = False status = await self._call_handler_hook('HELO', hostname) if status is MISSING: self.session.host_name = hostname status = '250 {}'.format(self.hostname) await self.push(status) @syntax('EHLO hostname') async def smtp_EHLO(self, hostname): if not hostname: await self.push('501 Syntax: EHLO hostname') return self._set_rset_state() self.session.extended_smtp = True await self.push('250-%s' % self.hostname) if self.data_size_limit: await self.push('250-SIZE %s' % self.data_size_limit) self.command_size_limits['MAIL'] += 26 if not self._decode_data: await self.push('250-8BITMIME') if self.enable_SMTPUTF8: await self.push('250-SMTPUTF8') self.command_size_limits['MAIL'] += 10 if self.tls_context and not self._tls_protocol: await self.push('250-STARTTLS') if hasattr(self, 'ehlo_hook'): warn('Use handler.handle_EHLO() instead of .ehlo_hook()', DeprecationWarning) await self.ehlo_hook() status = await self._call_handler_hook('EHLO', hostname) if status is MISSING: self.session.host_name = hostname status = '250 HELP' await self.push(status) @syntax('NOOP [ignored]') async def smtp_NOOP(self, arg): status = await self._call_handler_hook('NOOP', arg) await self.push('250 OK' if status is MISSING else status) @syntax('QUIT') async def smtp_QUIT(self, arg): if arg: await self.push('501 Syntax: QUIT') else: status = await self._call_handler_hook('QUIT') await self.push('221 Bye' if status is MISSING else status) self._handler_coroutine.cancel() self.transport.close() @syntax('STARTTLS', when='tls_context') async def smtp_STARTTLS(self, arg): log.info('%r STARTTLS', self.session.peer) if arg: await self.push('501 Syntax: STARTTLS') return if not self.tls_context: await self.push('454 TLS not available') return await self.push('220 Ready to start TLS') # Create SSL layer. self._tls_protocol = sslproto.SSLProtocol( self.loop, self, self.tls_context, None, server_side=True) # Reconfigure transport layer. Keep a reference to the original # transport so that we can close it explicitly when the connection is # lost. XXX BaseTransport.set_protocol() was added in Python 3.5.3 :( self._original_transport = self.transport self._original_transport._protocol = self._tls_protocol # Reconfigure the protocol layer. Why is the app transport a protected # property, if it MUST be used externally? self.transport = self._tls_protocol._app_transport self._tls_protocol.connection_made(self._original_transport) def _strip_command_keyword(self, keyword, arg): keylen = len(keyword) if arg[:keylen].upper() == keyword: return arg[keylen:].strip() return None def _getaddr(self, arg): if not arg: return '', '' if arg.lstrip().startswith('<'): address, rest = get_angle_addr(arg) else: address, rest = get_addr_spec(arg) try: address = address.addr_spec except IndexError: # Workaround http://bugs.python.org/issue27931 address = None return address, rest def _getparams(self, params): # Return params as dictionary. Return None if not all parameters # appear to be syntactically valid according to RFC 1869. result = {} for param in params: param, eq, value = param.partition('=') if not param.isalnum() or eq and not value: return None result[param] = value if eq else True return result def _syntax_available(self, method): if getattr(method, '__smtp_syntax__', None) is None: return False if method.__smtp_syntax_when__: return bool(getattr(self, method.__smtp_syntax_when__)) return True @syntax('HELP [command]') @asyncio.coroutine async def smtp_HELP(self, arg): code = 250 if arg: method = getattr(self, 'smtp_' + arg.upper(), None) if method and self._syntax_available(method): help_str = method.__smtp_syntax__ if (self.session.extended_smtp and method.__smtp_syntax_extended__): help_str += method.__smtp_syntax_extended__ await self.push('250 Syntax: ' + help_str) return code = 501 commands = [] for name in dir(self): if not name.startswith('smtp_'): continue method = getattr(self, name) if self._syntax_available(method): commands.append(name.lstrip('smtp_')) commands.sort() await self.push( '{} Supported commands: {}'.format(code, ' '.join(commands))) @syntax('VRFY
') async def smtp_VRFY(self, arg): if arg: try: address, params = self._getaddr(arg) except HeaderParseError: address = None if address is None: await self.push('502 Could not VRFY %s' % arg) else: status = await self._call_handler_hook('VRFY', address) await self.push( '252 Cannot VRFY user, but will accept message ' 'and attempt delivery' if status is MISSING else status) else: await self.push('501 Syntax: VRFY
') @syntax('MAIL FROM:
', extended=' [SP ]') async def smtp_MAIL(self, arg): if not self.session.host_name: await self.push('503 Error: send HELO first') return log.debug('===> MAIL %s', arg) syntaxerr = '501 Syntax: MAIL FROM:
' if self.session.extended_smtp: syntaxerr += ' [SP ]' if arg is None: await self.push(syntaxerr) return arg = self._strip_command_keyword('FROM:', arg) if arg is None: await self.push(syntaxerr) return address, params = self._getaddr(arg) if address is None: await self.push(syntaxerr) return if not self.session.extended_smtp and params: await self.push(syntaxerr) return if self.envelope.mail_from: await self.push('503 Error: nested MAIL command') return mail_options = params.upper().split() params = self._getparams(mail_options) if params is None: await self.push(syntaxerr) return if not self._decode_data: body = params.pop('BODY', '7BIT') if body not in ['7BIT', '8BITMIME']: await self.push( '501 Error: BODY can only be one of 7BIT, 8BITMIME') return smtputf8 = params.pop('SMTPUTF8', False) if not isinstance(smtputf8, bool): await self.push('501 Error: SMTPUTF8 takes no arguments') return if smtputf8 and not self.enable_SMTPUTF8: await self.push('501 Error: SMTPUTF8 disabled') return self.envelope.smtp_utf8 = smtputf8 size = params.pop('SIZE', None) if size: if isinstance(size, bool) or not size.isdigit(): await self.push(syntaxerr) return elif self.data_size_limit and int(size) > self.data_size_limit: await self.push( '552 Error: message size exceeds fixed maximum message ' 'size') return if len(params) > 0: await self.push( '555 MAIL FROM parameters not recognized or not implemented') return status = await self._call_handler_hook('MAIL', address, mail_options) if status is MISSING: self.envelope.mail_from = address self.envelope.mail_options.extend(mail_options) status = '250 OK' log.info('%r sender: %s', self.session.peer, address) await self.push(status) @syntax('RCPT TO:
', extended=' [SP ]') async def smtp_RCPT(self, arg): if not self.session.host_name: await self.push('503 Error: send HELO first') return log.debug('===> RCPT %s', arg) if not self.envelope.mail_from: await self.push('503 Error: need MAIL command') return syntaxerr = '501 Syntax: RCPT TO:
' if self.session.extended_smtp: syntaxerr += ' [SP ]' if arg is None: await self.push(syntaxerr) return arg = self._strip_command_keyword('TO:', arg) if arg is None: await self.push(syntaxerr) return address, params = self._getaddr(arg) if address is None: await self.push(syntaxerr) return if not address: await self.push(syntaxerr) return if not self.session.extended_smtp and params: await self.push(syntaxerr) return rcpt_options = params.upper().split() params = self._getparams(rcpt_options) if params is None: await self.push(syntaxerr) return # XXX currently there are no options we recognize. if len(params) > 0: await self.push( '555 RCPT TO parameters not recognized or not implemented') return status = await self._call_handler_hook('RCPT', address, rcpt_options) if status is MISSING: self.envelope.rcpt_tos.append(address) self.envelope.rcpt_options.extend(rcpt_options) status = '250 OK' log.info('%r recip: %s', self.session.peer, address) await self.push(status) @syntax('RSET') async def smtp_RSET(self, arg): if arg: await self.push('501 Syntax: RSET') return self._set_rset_state() if hasattr(self, 'rset_hook'): warn('Use handler.handle_RSET() instead of .rset_hook()', DeprecationWarning) await self.rset_hook() status = await self._call_handler_hook('RSET') await self.push('250 OK' if status is MISSING else status) @syntax('DATA') async def smtp_DATA(self, arg): if not self.session.host_name: await self.push('503 Error: send HELO first') return if not self.envelope.rcpt_tos: await self.push('503 Error: need RCPT command') return if arg: await self.push('501 Syntax: DATA') return await self.push('354 End data with .') data = [] num_bytes = 0 size_exceeded = False while self.transport is not None: # pragma: nobranch try: line = await self._reader.readline() log.debug('DATA readline: %s', line) except asyncio.CancelledError: # The connection got reset during the DATA command. log.info('Connection lost during DATA') self._writer.close() raise if line == b'.\r\n': if data: data[-1] = data[-1].rstrip(b'\r\n') break num_bytes += len(line) if (not size_exceeded and self.data_size_limit and num_bytes > self.data_size_limit): size_exceeded = True await self.push('552 Error: Too much mail data') if not size_exceeded: data.append(line) if size_exceeded: self._set_post_data_state() return # Remove extraneous carriage returns and de-transparency # according to RFC 5321, Section 4.5.2. for i in range(len(data)): text = data[i] if text and text[:1] == b'.': data[i] = text[1:] content = original_content = EMPTYBYTES.join(data) if self._decode_data: if self.enable_SMTPUTF8: content = original_content.decode( 'utf-8', errors='surrogateescape') else: try: content = original_content.decode('ascii', errors='strict') except UnicodeDecodeError: # This happens if enable_smtputf8 is false, meaning that # the server explicitly does not want to accept non-ascii, # but the client ignores that and sends non-ascii anyway. await self.push('500 Error: strict ASCII mode') return self.envelope.content = content self.envelope.original_content = original_content # Call the new API first if it's implemented. if hasattr(self.event_handler, 'handle_DATA'): status = await self._call_handler_hook('DATA') else: # Backward compatibility. status = MISSING if hasattr(self.event_handler, 'process_message'): warn('Use handler.handle_DATA() instead of .process_message()', DeprecationWarning) args = (self.session.peer, self.envelope.mail_from, self.envelope.rcpt_tos, self.envelope.content) if asyncio.iscoroutinefunction( self.event_handler.process_message): status = await self.event_handler.process_message(*args) else: status = self.event_handler.process_message(*args) # The deprecated API can return None which means, return the # default status. Don't worry about coverage for this case as # it's a deprecated API that will go away after 1.0. if status is None: # pragma: nocover status = MISSING self._set_post_data_state() await self.push('250 OK' if status is MISSING else status) # Commands that have not been implemented. async def smtp_EXPN(self, arg): await self.push('502 EXPN not implemented') aiosmtpd-1.1/aiosmtpd/lmtp.py0000664000175000017500000000113113127275524016627 0ustar barrybarry00000000000000from aiosmtpd.smtp import SMTP, syntax from public import public @public class LMTP(SMTP): @syntax('LHLO hostname') async def smtp_LHLO(self, arg): """The LMTP greeting, used instead of HELO/EHLO.""" await super().smtp_HELO(arg) self.show_smtp_greeting = False async def smtp_HELO(self, arg): """HELO is not a valid LMTP command.""" await self.push('500 Error: command "HELO" not recognized') async def smtp_EHLO(self, arg): """EHLO is not a valid LMTP command.""" await self.push('500 Error: command "EHLO" not recognized') aiosmtpd-1.1/aiosmtpd/__init__.py0000664000175000017500000000000013015130134017363 0ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/handlers.py0000664000175000017500000001606113114056513017452 0ustar barrybarry00000000000000"""Handlers which provide custom processing at various events. At certain times in the SMTP protocol, various events can be processed. These events include the SMTP commands, and at the completion of the data receipt. Pass in an instance of one of these classes, or derive your own, to provide your own handling of messages. Implement only the methods you care about. """ import re import sys import asyncio import logging import mailbox import smtplib from email import message_from_bytes, message_from_string from public import public EMPTYBYTES = b'' COMMASPACE = ', ' CRLF = b'\r\n' NLCRE = re.compile(br'\r\n|\r|\n') log = logging.getLogger('mail.debug') def _format_peer(peer): # This is a separate function mostly so the test suite can craft a # reproducible output. return 'X-Peer: {!r}'.format(peer) @public class Debugging: def __init__(self, stream=None): self.stream = sys.stdout if stream is None else stream @classmethod def from_cli(cls, parser, *args): error = False stream = None if len(args) == 0: pass elif len(args) > 1: error = True elif args[0] == 'stdout': stream = sys.stdout elif args[0] == 'stderr': stream = sys.stderr else: error = True if error: parser.error('Debugging usage: [stdout|stderr]') return cls(stream) def _print_message_content(self, peer, data): in_headers = True for line in data.splitlines(): # Dump the RFC 2822 headers first. if in_headers and not line: print(_format_peer(peer), file=self.stream) in_headers = False if isinstance(data, bytes): # Avoid spurious 'str on bytes instance' warning. line = line.decode('utf-8', 'replace') print(line, file=self.stream) async def handle_DATA(self, server, session, envelope): print('---------- MESSAGE FOLLOWS ----------', file=self.stream) # Yes, actually test for truthiness since it's possible for either the # keywords to be missing, or for their values to be empty lists. add_separator = False if envelope.mail_options: print('mail options:', envelope.mail_options, file=self.stream) add_separator = True # rcpt_options are not currently support by the SMTP class. rcpt_options = envelope.rcpt_options if any(rcpt_options): # pragma: nocover print('rcpt options:', rcpt_options, file=self.stream) add_separator = True if add_separator: print(file=self.stream) self._print_message_content(session.peer, envelope.content) print('------------ END MESSAGE ------------', file=self.stream) return '250 OK' @public class Proxy: def __init__(self, remote_hostname, remote_port): self._hostname = remote_hostname self._port = remote_port async def handle_DATA(self, server, session, envelope): if isinstance(envelope.content, str): content = envelope.original_content else: content = envelope.content lines = content.splitlines(keepends=True) # Look for the last header i = 0 ending = CRLF for line in lines: # pragma: nobranch if NLCRE.match(line): ending = line break i += 1 peer = session.peer[0].encode('ascii') lines.insert(i, b'X-Peer: %s%s' % (peer, ending)) data = EMPTYBYTES.join(lines) refused = self._deliver(envelope.mail_from, envelope.rcpt_tos, data) # TBD: what to do with refused addresses? log.info('we got some refusals: %s', refused) return '250 OK' def _deliver(self, mail_from, rcpt_tos, data): refused = {} try: s = smtplib.SMTP() s.connect(self._hostname, self._port) try: refused = s.sendmail(mail_from, rcpt_tos, data) finally: s.quit() except smtplib.SMTPRecipientsRefused as e: log.info('got SMTPRecipientsRefused') refused = e.recipients except (OSError, smtplib.SMTPException) as e: log.exception('got %s', e.__class__) # All recipients were refused. If the exception had an associated # error code, use it. Otherwise, fake it with a non-triggering # exception code. errcode = getattr(e, 'smtp_code', -1) errmsg = getattr(e, 'smtp_error', 'ignore') for r in rcpt_tos: refused[r] = (errcode, errmsg) return refused @public class Sink: @classmethod def from_cli(cls, parser, *args): if len(args) > 0: parser.error('Sink handler does not accept arguments') return cls() @public class Message: def __init__(self, message_class=None): self.message_class = message_class async def handle_DATA(self, server, session, envelope): envelope = self.prepare_message(session, envelope) self.handle_message(envelope) return '250 OK' def prepare_message(self, session, envelope): # If the server was created with decode_data True, then data will be a # str, otherwise it will be bytes. data = envelope.content if isinstance(data, bytes): message = message_from_bytes(data, self.message_class) else: assert isinstance(data, str), ( 'Expected str or bytes, got {}'.format(type(data))) message = message_from_string(data, self.message_class) message['X-Peer'] = str(session.peer) message['X-MailFrom'] = envelope.mail_from message['X-RcptTo'] = COMMASPACE.join(envelope.rcpt_tos) return message def handle_message(self, message): raise NotImplementedError # pragma: nocover @public class AsyncMessage(Message): def __init__(self, message_class=None, *, loop=None): super().__init__(message_class) self.loop = loop or asyncio.get_event_loop() async def handle_DATA(self, server, session, envelope): message = self.prepare_message(session, envelope) await self.handle_message(message) return '250 OK' async def handle_message(self, message): raise NotImplementedError # pragma: nocover @public class Mailbox(Message): def __init__(self, mail_dir, message_class=None): self.mailbox = mailbox.Maildir(mail_dir) self.mail_dir = mail_dir super().__init__(message_class) def handle_message(self, message): self.mailbox.add(message) def reset(self): self.mailbox.clear() @classmethod def from_cli(cls, parser, *args): if len(args) < 1: parser.error('The directory for the maildir is required') elif len(args) > 1: parser.error('Too many arguments for Mailbox handler') return cls(args[0]) aiosmtpd-1.1/aiosmtpd/__main__.py0000664000175000017500000000010713015130134017354 0ustar barrybarry00000000000000from aiosmtpd.main import main if __name__ == '__main__': main() aiosmtpd-1.1/aiosmtpd/controller.py0000664000175000017500000000441313127275524020044 0ustar barrybarry00000000000000import os import asyncio import threading from aiosmtpd.smtp import SMTP from public import public @public class Controller: def __init__(self, handler, loop=None, hostname=None, port=8025, *, ready_timeout=1.0, enable_SMTPUTF8=True, ssl_context=None): self.handler = handler self.hostname = '::1' if hostname is None else hostname self.port = port self.enable_SMTPUTF8 = enable_SMTPUTF8 self.ssl_context = ssl_context self.loop = asyncio.new_event_loop() if loop is None else loop self.server = None self._thread = None self._thread_exception = None self.ready_timeout = os.getenv( 'AIOSMTPD_CONTROLLER_TIMEOUT', ready_timeout) def factory(self): """Allow subclasses to customize the handler/server creation.""" return SMTP(self.handler, enable_SMTPUTF8=self.enable_SMTPUTF8) def _run(self, ready_event): asyncio.set_event_loop(self.loop) try: self.server = self.loop.run_until_complete( self.loop.create_server( self.factory, host=self.hostname, port=self.port, ssl=self.ssl_context)) except Exception as error: self._thread_exception = error return self.loop.call_soon(ready_event.set) self.loop.run_forever() self.server.close() self.loop.run_until_complete(self.server.wait_closed()) self.loop.close() self.server = None def start(self): assert self._thread is None, 'SMTP daemon already running' ready_event = threading.Event() self._thread = threading.Thread(target=self._run, args=(ready_event,)) self._thread.daemon = True self._thread.start() # Wait a while until the server is responding. ready_event.wait(self.ready_timeout) if self._thread_exception is not None: raise self._thread_exception def _stop(self): self.loop.stop() for task in asyncio.Task.all_tasks(self.loop): task.cancel() def stop(self): assert self._thread is not None, 'SMTP daemon not running' self.loop.call_soon_threadsafe(self._stop) self._thread.join() self._thread = None aiosmtpd-1.1/aiosmtpd/tests/0000775000175000017500000000000013127451604016442 5ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/tests/test_lmtp.py0000664000175000017500000000344013127275524021035 0ustar barrybarry00000000000000"""Test the LMTP protocol.""" import socket import unittest from aiosmtpd.controller import Controller from aiosmtpd.handlers import Sink from aiosmtpd.lmtp import LMTP from smtplib import SMTP class LMTPController(Controller): def factory(self): return LMTP(self.handler) class TestLMTP(unittest.TestCase): def setUp(self): controller = LMTPController(Sink) controller.start() self.address = (controller.hostname, controller.port) self.addCleanup(controller.stop) def test_lhlo(self): with SMTP(*self.address) as client: code, response = client.docmd('LHLO', 'example.com') self.assertEqual(code, 250) self.assertEqual(response, bytes(socket.getfqdn(), 'utf-8')) def test_helo(self): # HELO and EHLO are not valid LMTP commands. with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 500) self.assertEqual(response, b'Error: command "HELO" not recognized') def test_ehlo(self): # HELO and EHLO are not valid LMTP commands. with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 500) self.assertEqual(response, b'Error: command "EHLO" not recognized') def test_help(self): # https://github.com/aio-libs/aiosmtpd/issues/113 with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP') self.assertEqual(code, 250) self.assertEqual(response, b'Supported commands: DATA HELP LHLO MAIL ' b'NOOP QUIT RCPT RSET VRFY') aiosmtpd-1.1/aiosmtpd/tests/test_starttls.py0000664000175000017500000001557113127275524021751 0ustar barrybarry00000000000000import ssl import unittest import pkg_resources from aiosmtpd.controller import Controller as BaseController from aiosmtpd.handlers import Sink from aiosmtpd.smtp import SMTP as SMTPProtocol from email.mime.text import MIMEText from smtplib import SMTP class Controller(BaseController): def factory(self): return SMTPProtocol(self.handler) class ReceivingHandler: def __init__(self): self.box = [] async def handle_DATA(self, server, session, envelope): self.box.append(envelope) return '250 OK' def get_tls_context(): tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) tls_context.load_cert_chain( pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.crt'), pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.key')) return tls_context class TLSRequiredController(Controller): def factory(self): return SMTPProtocol( self.handler, decode_data=True, require_starttls=True, tls_context=get_tls_context()) class TLSController(Controller): def factory(self): return SMTPProtocol( self.handler, decode_data=True, require_starttls=False, tls_context=get_tls_context()) class HandshakeFailingHandler: def handle_STARTTLS(self, server, session, envelope): return False class TestStartTLS(unittest.TestCase): def test_starttls(self): handler = ReceivingHandler() controller = TLSController(handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) self.assertIn('starttls', client.esmtp_features) code, response = client.starttls() self.assertEqual(code, 220) client.send_message( MIMEText('hi'), 'sender@example.com', 'rcpt1@example.com') self.assertEqual(len(handler.box), 1) def test_failed_handshake(self): controller = TLSController(HandshakeFailingHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.starttls() self.assertEqual(code, 220) code, response = client.mail('sender@example.com') self.assertEqual(code, 554) code, response = client.rcpt('rcpt@example.com') self.assertEqual(code, 554) def test_disabled_tls(self): controller = Controller(Sink) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.docmd('STARTTLS') self.assertEqual(code, 454) def test_tls_bad_syntax(self): controller = TLSController(Sink) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.docmd('STARTTLS', 'TRUE') self.assertEqual(code, 501) def test_help_after_starttls(self): controller = TLSController(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP') self.assertEqual(code, 250) self.assertEqual(response, b'Supported commands: DATA EHLO HELO HELP MAIL ' b'NOOP QUIT RCPT RSET STARTTLS VRFY') class TestTLSForgetsSessionData(unittest.TestCase): def setUp(self): controller = TLSController(Sink) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_forget_ehlo(self): with SMTP(*self.address) as client: client.starttls() code, response = client.mail('sender@example.com') self.assertEqual(code, 503) self.assertEqual(response, b'Error: send HELO first') def test_forget_mail(self): with SMTP(*self.address) as client: client.ehlo('example.com') client.mail('sender@example.com') client.starttls() client.ehlo('example.com') code, response = client.rcpt('rcpt@example.com') self.assertEqual(code, 503) self.assertEqual(response, b'Error: need MAIL command') def test_forget_rcpt(self): with SMTP(*self.address) as client: client.ehlo('example.com') client.mail('sender@example.com') client.rcpt('rcpt@example.com') client.starttls() client.ehlo('example.com') client.mail('sender@example.com') code, response = client.docmd('DATA') self.assertEqual(code, 503) self.assertEqual(response, b'Error: need RCPT command') class TestRequireTLS(unittest.TestCase): def setUp(self): controller = TLSRequiredController(Sink) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_hello_fails(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 530) def test_help_fails(self): with SMTP(*self.address) as client: code, response = client.docmd('HELP', 'HELO') self.assertEqual(code, 530) def test_ehlo(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) self.assertIn('starttls', client.esmtp_features) def test_mail_fails(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.mail('sender@exapmle.com') self.assertEqual(code, 530) def test_rcpt_fails(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.rcpt('sender@exapmle.com') self.assertEqual(code, 530) def test_vrfy_fails(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.vrfy('sender@exapmle.com') self.assertEqual(code, 530) def test_data_fails(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd('DATA') self.assertEqual(code, 530) aiosmtpd-1.1/aiosmtpd/tests/certs/0000775000175000017500000000000013127451604017562 5ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/tests/certs/__init__.py0000644000175000017500000000000013065575331021664 0ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/tests/certs/server.crt0000644000175000017500000000270013065575331021604 0ustar barrybarry00000000000000-----BEGIN CERTIFICATE----- MIIEEzCCAvugAwIBAgIJANUfzx76nsWrMA0GCSqGSIb3DQEBCwUAMIGfMQswCQYD VQQGEwJSVTEZMBcGA1UECAwQU2FpbnQtUGV0ZXJzYnVyZzEZMBcGA1UEBwwQU2Fp bnQtUGV0ZXJzYnVyZzETMBEGA1UECgwKSW50ZXJtZWRpYTEQMA4GA1UECwwHRGV2 VGVhbTEMMAoGA1UEAwwDYWVzMSUwIwYJKoZIhvcNAQkBFhZrdm9sa292QGludGVy bWVkaWEubmV0MB4XDTE2MDgyMzEzMDE1NFoXDTE5MDUyMDEzMDE1NFowgZ8xCzAJ BgNVBAYTAlJVMRkwFwYDVQQIDBBTYWludC1QZXRlcnNidXJnMRkwFwYDVQQHDBBT YWludC1QZXRlcnNidXJnMRMwEQYDVQQKDApJbnRlcm1lZGlhMRAwDgYDVQQLDAdE ZXZUZWFtMQwwCgYDVQQDDANhZXMxJTAjBgkqhkiG9w0BCQEWFmt2b2xrb3ZAaW50 ZXJtZWRpYS5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0c3sW h3wlBuM21PhiF2AKlYniu697xCv3cvOyqg4ybq+Vd44ldQc+3twIyxtO+p1zgxTW bkxwV+s6qBU5i09m8RHX2sBW0e61Vx4dR8dEkGjmqy3hebJy33GZOWh5bp1yZoZp 9AsbGQ2dNPCBSc75hc/5+CMcyzoK3pXuC09kwXPNmnWgy/dJWk6FVRP3/3u2KkDo ZGKDY7+vnJ8hYLk+stGZGfu0C6qU7cguRnsuuH6nC6KIhbn3hJNVYMlXRBXF1tE4 UBjvdSYlFfyiwc1zJ77TVq8lSnn/9yiBfG+xUqGq7+KEHkg3SezmBFTFaXRc+RT3 e3wf/e5WJRHl4joxAgMBAAGjUDBOMB0GA1UdDgQWBBSR+2YlBnyuYLHm9xNL/dJw fn6RtjAfBgNVHSMEGDAWgBSR+2YlBnyuYLHm9xNL/dJwfn6RtjAMBgNVHRMEBTAD AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCJExJ/YpMJeWq/VsEBiQ9MevNUbhy4bDn1 8JkDazIAwcSALkqG+VKFp5JBJxS8BIMJ//31L26r0pjT8eOCivyEAf5jtBt594Jn v+IANbVXfGds3H0QtFgpMKDlvpwfYDXwNDRsClLhwgIzhkrtl0y1vIn6gNx2Np0p Xn4nRewPXpNfUXuE4mot0njMOp2Iyf0AuhaM9rqqK9TEwZCvpwpptjnBg0Z+vd+h U4rQNt6WaRMkYc1xZpOy6pESB98JkmTFJ6se33JLc7GXJbdLcQ+Zy6TWCGhUqZ/U kaKttZGpHTZfuMkwRwhPG6ou3SlvlARYN3wGTMy+Um9tk+J+k0Tw -----END CERTIFICATE----- aiosmtpd-1.1/aiosmtpd/tests/certs/server.key0000644000175000017500000000325013065575331021605 0ustar barrybarry00000000000000-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0c3sWh3wlBuM2 1PhiF2AKlYniu697xCv3cvOyqg4ybq+Vd44ldQc+3twIyxtO+p1zgxTWbkxwV+s6 qBU5i09m8RHX2sBW0e61Vx4dR8dEkGjmqy3hebJy33GZOWh5bp1yZoZp9AsbGQ2d NPCBSc75hc/5+CMcyzoK3pXuC09kwXPNmnWgy/dJWk6FVRP3/3u2KkDoZGKDY7+v nJ8hYLk+stGZGfu0C6qU7cguRnsuuH6nC6KIhbn3hJNVYMlXRBXF1tE4UBjvdSYl Ffyiwc1zJ77TVq8lSnn/9yiBfG+xUqGq7+KEHkg3SezmBFTFaXRc+RT3e3wf/e5W JRHl4joxAgMBAAECggEAFLHZv++x0R1FGZi7E6TSouQbeCFGMs+Aq1RHloniLu56 vI2Fg840EoXEfk2syBX90K2LyjvEEG5Ez+lO5daQOKIVBchUnqBc2/ctwPXmaHqX TTz8egtW582wXX4z+RkyfVg8uhH+5BCvewQDQRCR6BPskiJfBIJaGb0FPNOXO1qy vI0MupbfHU1J80M6PEzfszswdC+5Lgx0kFRphr8mSLn42dlFsqFVmFCVuaxg6bfn zowXppUcUM4lkBrsXpLKYTr2+u64wZkclL/GjMCYuyQ52xiBsw3JPDeDDFI3kFkw gOCqDedqyim60qM9Dtq1bf4EW4/AEZp5LM7bSOXTsQKBgQDtuUynfJyKsqdXCrAa Z+uVhSlAN7a4/n4s0wFomgXmQnYNaNAq5PF8Nplq95JiGkD6BX/FPKPUxHwpeT3F F98h03BafU/06RR/m3A8ACclTVM5vmqv3I+L2eqAPVP+mE5pPrQwMxhxwhcHrbtg LmUVegkahRZgR8WhrhRyQ6fL0wKBgQDCUvubeKtY09u6j1AuTgfGCNmjqFyNHsuZ PQhqmiIcsmKWja2ybiFk8hQf09scjQOGC9GunD1aB8KTnBYOr/WcNLRjLuKWL3WO xKDfIrOJ6StooU6+/hYz+RBcYn87d4sSVzZIgTPdN1OyQKls9QhP4Ds9j4wWNmnM EWEjzCczawKBgQCcNP6hr8hNe0dqcqN1NoQfI/kPMYzn0pKmcaCjU1I9E77u4Mio 5venX1lAaJ3PyOCZabOjr00YKmRL/FcSg7UjTQSu8Vjw3ZeSolkFlDQk1sKxVuZT 2OKaSv9EdQgUa5Bap9FPOsP9PERVz1sowFO74QzKWFlzurWqn/DfhIVl8QKBgEmN a1rvk7uthQ/aSvkb4+lbVDWT9mQb8ehwp4ziBmNiSdq+ia5t7QnubxuU7uyhm2HT e2xiCv7WzRleDSNGCuszL8wS5QT/tblyR4nt8pMSxLF3zPyR5AmMDltJlOsHVoZ8 qDlNXjovROjFfNuW66yALSwh9144/laVhXUtQvE9AoGBANvLJfJ7b3QxvQduFfRB 667/l4/2zkVvPkKABgf+v+/GH+oQq4K3ZX+LZDQb1PcliaUNtE1l3maQNUTa1iar WMYYYhs05mnIWCYu9H8Dd1LzpNmZVqTK6cJSTrbOAkStopf95l0QGUxG+hIbewmh HJa7IfPvASHjtu/R/HZr/n5W -----END PRIVATE KEY----- aiosmtpd-1.1/aiosmtpd/tests/__init__.py0000664000175000017500000000000013015130134020525 0ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/tests/test_server.py0000664000175000017500000000310013104731556021355 0ustar barrybarry00000000000000"""Test other aspects of the server implementation.""" import socket import unittest from aiosmtpd.controller import Controller from aiosmtpd.handlers import Sink from aiosmtpd.smtp import SMTP as Server from smtplib import SMTP class TestServer(unittest.TestCase): def test_smtp_utf8(self): controller = Controller(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) self.assertIn(b'SMTPUTF8', response.splitlines()) def test_default_max_command_size_limit(self): server = Server(Sink()) self.assertEqual(server.max_command_size_limit, 512) def test_special_max_command_size_limit(self): server = Server(Sink()) server.command_size_limits['DATA'] = 1024 self.assertEqual(server.max_command_size_limit, 1024) def test_socket_error(self): # Testing starting a server with a port already in use s1 = Controller(Sink(), port=8025) s2 = Controller(Sink(), port=8025) self.addCleanup(s1.stop) self.addCleanup(s2.stop) s1.start() self.assertRaises(socket.error, s2.start) def test_server_attribute(self): controller = Controller(Sink()) self.assertIsNone(controller.server) try: controller.start() self.assertIsNotNone(controller.server) finally: controller.stop() self.assertIsNone(controller.server) aiosmtpd-1.1/aiosmtpd/tests/test_smtps.py0000664000175000017500000000372413127275524021234 0ustar barrybarry00000000000000"""Test SMTP over SSL/TLS.""" import ssl import socket import unittest import pkg_resources from aiosmtpd.controller import Controller as BaseController from aiosmtpd.smtp import SMTP as SMTPProtocol from email.mime.text import MIMEText from smtplib import SMTP_SSL class Controller(BaseController): def factory(self): return SMTPProtocol(self.handler) class ReceivingHandler: def __init__(self): self.box = [] async def handle_DATA(self, server, session, envelope): self.box.append(envelope) return '250 OK' def get_server_context(): tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) tls_context.load_cert_chain( pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.crt'), pkg_resources.resource_filename('aiosmtpd.tests.certs', 'server.key')) return tls_context def get_client_context(): context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) context.check_hostname = False context.load_verify_locations( cafile=pkg_resources.resource_filename( 'aiosmtpd.tests.certs', 'server.crt')) return context class TestSMTPS(unittest.TestCase): def setUp(self): self.handler = ReceivingHandler() controller = Controller(self.handler, ssl_context=get_server_context()) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_smtps(self): with SMTP_SSL(*self.address, context=get_client_context()) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) self.assertEqual(response, socket.getfqdn().encode('utf-8')) client.send_message( MIMEText('hi'), 'sender@example.com', 'rcpt1@example.com') self.assertEqual(len(self.handler.box), 1) envelope = self.handler.box[0] self.assertEqual(envelope.mail_from, 'sender@example.com') aiosmtpd-1.1/aiosmtpd/tests/test_main.py0000664000175000017500000003123513127444433021005 0ustar barrybarry00000000000000import os import signal import asyncio import logging import unittest from aiosmtpd.handlers import Debugging from aiosmtpd.main import main, parseargs from aiosmtpd.smtp import SMTP, __version__ from contextlib import ExitStack from functools import partial from io import StringIO from unittest.mock import patch try: import pwd except ImportError: pwd = None has_setuid = hasattr(os, 'setuid') log = logging.getLogger('mail.log') class TestHandler1: def __init__(self, called): self.called = called @classmethod def from_cli(cls, parser, *args): return cls(*args) class TestHandler2: pass class TestMain(unittest.TestCase): def setUp(self): old_log_level = log.getEffectiveLevel() self.addCleanup(log.setLevel, old_log_level) self.resources = ExitStack() # Create a new event loop, and arrange for that loop to end almost # immediately. This will allow the calls to main() in these tests to # also exit almost immediately. Otherwise, the foreground test # process will hang. # # I think this introduces a race condition. It depends on whether the # call_later() can possibly run before the run_forever() does, or could # cause it to not complete all its tasks. In that case, you'd likely # get an error or warning on stderr, which may or may not cause the # test to fail. I've only seen this happen once and don't have enough # information to know for sure. default_loop = asyncio.get_event_loop() loop = asyncio.new_event_loop() loop.call_later(0.1, loop.stop) self.resources.callback(asyncio.set_event_loop, default_loop) asyncio.set_event_loop(loop) self.addCleanup(self.resources.close) @unittest.skipIf(pwd is None, 'No pwd module available') def test_setuid(self): with patch('os.setuid') as mock: main(args=()) mock.assert_called_with(pwd.getpwnam('nobody').pw_uid) @unittest.skipIf(pwd is None, 'No pwd module available') def test_setuid_permission_error(self): mock = self.resources.enter_context( patch('os.setuid', side_effect=PermissionError)) stderr = StringIO() self.resources.enter_context(patch('sys.stderr', stderr)) with self.assertRaises(SystemExit) as cm: main(args=()) self.assertEqual(cm.exception.code, 1) mock.assert_called_with(pwd.getpwnam('nobody').pw_uid) self.assertEqual( stderr.getvalue(), 'Cannot setuid "nobody"; try running with -n option.\n') @unittest.skipIf(pwd is None, 'No pwd module available') def test_setuid_no_pwd_module(self): self.resources.enter_context(patch('aiosmtpd.main.pwd', None)) stderr = StringIO() self.resources.enter_context(patch('sys.stderr', stderr)) with self.assertRaises(SystemExit) as cm: main(args=()) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), 'Cannot import module "pwd"; try running with -n option.\n') @unittest.skipUnless(has_setuid, 'setuid is unvailable') def test_n(self): self.resources.enter_context(patch('aiosmtpd.main.pwd', None)) self.resources.enter_context( patch('os.setuid', side_effect=PermissionError)) # Just to short-circuit the main() function. self.resources.enter_context( patch('aiosmtpd.main.partial', side_effect=RuntimeError)) # Getting the RuntimeError means that a SystemExit was never # triggered in the setuid section. self.assertRaises(RuntimeError, main, ('-n',)) @unittest.skipUnless(has_setuid, 'setuid is unvailable') def test_nosetuid(self): self.resources.enter_context(patch('aiosmtpd.main.pwd', None)) self.resources.enter_context( patch('os.setuid', side_effect=PermissionError)) # Just to short-circuit the main() function. self.resources.enter_context( patch('aiosmtpd.main.partial', side_effect=RuntimeError)) # Getting the RuntimeError means that a SystemExit was never # triggered in the setuid section. self.assertRaises(RuntimeError, main, ('--nosetuid',)) def test_debug_0(self): # For this test, the runner will have already set the log level so it # may not be logging.ERROR. log = logging.getLogger('mail.log') default_level = log.getEffectiveLevel() with patch.object(log, 'info'): main(('-n',)) self.assertEqual(log.getEffectiveLevel(), default_level) def test_debug_1(self): # Mock the logger to eliminate console noise. with patch.object(logging.getLogger('mail.log'), 'info'): main(('-n', '-d')) self.assertEqual(log.getEffectiveLevel(), logging.INFO) def test_debug_2(self): # Mock the logger to eliminate console noise. with patch.object(logging.getLogger('mail.log'), 'info'): main(('-n', '-dd')) self.assertEqual(log.getEffectiveLevel(), logging.DEBUG) def test_debug_3(self): # Mock the logger to eliminate console noise. with patch.object(logging.getLogger('mail.log'), 'info'): main(('-n', '-ddd')) self.assertEqual(log.getEffectiveLevel(), logging.DEBUG) self.assertTrue(asyncio.get_event_loop().get_debug()) class TestLoop(unittest.TestCase): def setUp(self): # We mock out so much of this, is it even worthwhile testing? Well, it # does give us coverage. self.loop = asyncio.get_event_loop() pfunc = partial(patch.object, self.loop) resources = ExitStack() self.addCleanup(resources.close) self.create_server = resources.enter_context(pfunc('create_server')) self.run_until_complete = resources.enter_context( pfunc('run_until_complete')) self.add_signal_handler = resources.enter_context( pfunc('add_signal_handler')) resources.enter_context( patch.object(logging.getLogger('mail.log'), 'info')) self.run_forever = resources.enter_context(pfunc('run_forever')) def test_loop(self): main(('-n',)) # create_server() is called with a partial as the factory, and a # socket object. self.assertEqual(self.create_server.call_count, 1) positional, keywords = self.create_server.call_args self.assertEqual(positional[0].func, SMTP) self.assertEqual(len(positional[0].args), 1) self.assertIsInstance(positional[0].args[0], Debugging) self.assertEqual(positional[0].keywords, dict( data_size_limit=None, enable_SMTPUTF8=False)) self.assertEqual(sorted(keywords), ['host', 'port']) # run_until_complete() was called once. The argument isn't important. self.assertTrue(self.run_until_complete.called) # add_signal_handler() is called with two arguments. self.assertEqual(self.add_signal_handler.call_count, 1) signal_number, callback = self.add_signal_handler.call_args[0] self.assertEqual(signal_number, signal.SIGINT) self.assertEqual(callback, self.loop.stop) # run_forever() was called once. self.assertEqual(self.run_forever.call_count, 1) def test_loop_keyboard_interrupt(self): # We mock out so much of this, is it even a worthwhile test? Well, it # does give us coverage. self.run_forever.side_effect = KeyboardInterrupt main(('-n',)) # loop.run_until_complete() was still executed. self.assertTrue(self.run_until_complete.called) def test_s(self): # We mock out so much of this, is it even a worthwhile test? Well, it # does give us coverage. main(('-n', '-s', '3000')) positional, keywords = self.create_server.call_args self.assertEqual(positional[0].keywords, dict( data_size_limit=3000, enable_SMTPUTF8=False)) def test_size(self): # We mock out so much of this, is it even a worthwhile test? Well, it # does give us coverage. main(('-n', '--size', '3000')) positional, keywords = self.create_server.call_args self.assertEqual(positional[0].keywords, dict( data_size_limit=3000, enable_SMTPUTF8=False)) def test_u(self): # We mock out so much of this, is it even a worthwhile test? Well, it # does give us coverage. main(('-n', '-u')) positional, keywords = self.create_server.call_args self.assertEqual(positional[0].keywords, dict( data_size_limit=None, enable_SMTPUTF8=True)) def test_smtputf8(self): # We mock out so much of this, is it even a worthwhile test? Well, it # does give us coverage. main(('-n', '--smtputf8')) positional, keywords = self.create_server.call_args self.assertEqual(positional[0].keywords, dict( data_size_limit=None, enable_SMTPUTF8=True)) class TestParseArgs(unittest.TestCase): def test_handler_from_cli(self): # Ignore the host:port positional argument. parser, args = parseargs( ('-c', 'aiosmtpd.tests.test_main.TestHandler1', '--', 'FOO')) self.assertIsInstance(args.handler, TestHandler1) self.assertEqual(args.handler.called, 'FOO') def test_handler_no_from_cli(self): # Ignore the host:port positional argument. parser, args = parseargs( ('-c', 'aiosmtpd.tests.test_main.TestHandler2')) self.assertIsInstance(args.handler, TestHandler2) def test_handler_from_cli_exception(self): self.assertRaises(TypeError, parseargs, ('-c', 'aiosmtpd.tests.test_main.TestHandler1', 'FOO', 'BAR')) def test_handler_no_from_cli_exception(self): stderr = StringIO() with patch('sys.stderr', stderr): with self.assertRaises(SystemExit) as cm: parseargs( ('-c', 'aiosmtpd.tests.test_main.TestHandler2', 'FOO', 'BAR')) self.assertEqual(cm.exception.code, 2) usage_lines = stderr.getvalue().splitlines() self.assertEqual( usage_lines[-1][-57:], 'Handler class aiosmtpd.tests.test_main takes no arguments') def test_default_host_port(self): parser, args = parseargs(args=()) self.assertEqual(args.host, 'localhost') self.assertEqual(args.port, 8025) def test_l(self): parser, args = parseargs(args=('-l', 'foo:25')) self.assertEqual(args.host, 'foo') self.assertEqual(args.port, 25) def test_listen(self): parser, args = parseargs(args=('--listen', 'foo:25')) self.assertEqual(args.host, 'foo') self.assertEqual(args.port, 25) def test_host_no_port(self): parser, args = parseargs(args=('-l', 'foo')) self.assertEqual(args.host, 'foo') self.assertEqual(args.port, 8025) def test_host_no_host(self): parser, args = parseargs(args=('-l', ':25')) self.assertEqual(args.host, 'localhost') self.assertEqual(args.port, 25) def test_ipv6_host_port(self): parser, args = parseargs(args=('-l', '::0:25')) self.assertEqual(args.host, '::0') self.assertEqual(args.port, 25) def test_bad_port_number(self): stderr = StringIO() with patch('sys.stderr', stderr): with self.assertRaises(SystemExit) as cm: parseargs(('-l', ':foo')) self.assertEqual(cm.exception.code, 2) usage_lines = stderr.getvalue().splitlines() self.assertEqual(usage_lines[-1][-24:], 'Invalid port number: foo') def test_version(self): stdout = StringIO() with ExitStack() as resources: resources.enter_context(patch('sys.stdout', stdout)) resources.enter_context(patch('aiosmtpd.main.PROGRAM', 'smtpd')) cm = resources.enter_context(self.assertRaises(SystemExit)) parseargs(('--version',)) self.assertEqual(cm.exception.code, 0) self.assertEqual(stdout.getvalue(), 'smtpd {}\n'.format(__version__)) def test_v(self): stdout = StringIO() with ExitStack() as resources: resources.enter_context(patch('sys.stdout', stdout)) resources.enter_context(patch('aiosmtpd.main.PROGRAM', 'smtpd')) cm = resources.enter_context(self.assertRaises(SystemExit)) parseargs(('-v',)) self.assertEqual(cm.exception.code, 0) self.assertEqual(stdout.getvalue(), 'smtpd {}\n'.format(__version__)) aiosmtpd-1.1/aiosmtpd/tests/test_handlers.py0000664000175000017500000006013713114056513021656 0ustar barrybarry00000000000000import os import sys import unittest from aiosmtpd.controller import Controller from aiosmtpd.handlers import AsyncMessage, Debugging, Mailbox, Proxy, Sink from aiosmtpd.smtp import SMTP as Server from contextlib import ExitStack from io import StringIO from mailbox import Maildir from operator import itemgetter from smtplib import SMTP, SMTPDataError, SMTPRecipientsRefused from tempfile import TemporaryDirectory from unittest.mock import call, patch CRLF = '\r\n' class DecodingController(Controller): def factory(self): return Server(self.handler, decode_data=True) class DataHandler: def __init__(self): self.content = None self.original_content = None async def handle_DATA(self, server, session, envelope): self.content = envelope.content self.original_content = envelope.original_content return '250 OK' class TestDebugging(unittest.TestCase): def setUp(self): self.stream = StringIO() handler = Debugging(self.stream) controller = DecodingController(handler) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_debugging(self): with ExitStack() as resources: client = resources.enter_context(SMTP(*self.address)) peer = client.sock.getsockname() client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Testing """) text = self.stream.getvalue() self.assertMultiLineEqual(text, """\ ---------- MESSAGE FOLLOWS ---------- mail options: ['SIZE=102'] From: Anne Person To: Bart Person Subject: A test X-Peer: {!r} Testing ------------ END MESSAGE ------------ """.format(peer)) class TestDebuggingBytes(unittest.TestCase): def setUp(self): self.stream = StringIO() handler = Debugging(self.stream) controller = Controller(handler) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_debugging(self): with ExitStack() as resources: client = resources.enter_context(SMTP(*self.address)) peer = client.sock.getsockname() client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Testing """) text = self.stream.getvalue() self.assertMultiLineEqual(text, """\ ---------- MESSAGE FOLLOWS ---------- mail options: ['SIZE=102'] From: Anne Person To: Bart Person Subject: A test X-Peer: {!r} Testing ------------ END MESSAGE ------------ """.format(peer)) class TestDebuggingOptions(unittest.TestCase): def setUp(self): self.stream = StringIO() handler = Debugging(self.stream) controller = Controller(handler) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_debugging_without_options(self): with SMTP(*self.address) as client: # Prevent ESMTP options. client.helo() peer = client.sock.getsockname() client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Testing """) text = self.stream.getvalue() self.assertMultiLineEqual(text, """\ ---------- MESSAGE FOLLOWS ---------- From: Anne Person To: Bart Person Subject: A test X-Peer: {!r} Testing ------------ END MESSAGE ------------ """.format(peer)) def test_debugging_with_options(self): with SMTP(*self.address) as client: peer = client.sock.getsockname() client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Testing """, mail_options=['BODY=7BIT']) text = self.stream.getvalue() self.assertMultiLineEqual(text, """\ ---------- MESSAGE FOLLOWS ---------- mail options: ['SIZE=102', 'BODY=7BIT'] From: Anne Person To: Bart Person Subject: A test X-Peer: {!r} Testing ------------ END MESSAGE ------------ """.format(peer)) class TestMessage(unittest.TestCase): def test_message(self): # In this test, the message content comes in as a bytes. handler = DataHandler() controller = Controller(handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Message-ID: Testing """) # The content is not converted, so it's bytes. self.assertEqual(handler.content, handler.original_content) self.assertIsInstance(handler.content, bytes) self.assertIsInstance(handler.original_content, bytes) def test_message_decoded(self): # In this test, the message content comes in as a string. handler = DataHandler() controller = DecodingController(handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Message-ID: Testing """) self.assertNotEqual(handler.content, handler.original_content) self.assertIsInstance(handler.content, str) self.assertIsInstance(handler.original_content, bytes) class TestAsyncMessage(unittest.TestCase): def setUp(self): self.handled_message = None class MessageHandler(AsyncMessage): async def handle_message(handler_self, message): self.handled_message = message self.handler = MessageHandler() def test_message(self): # In this test, the message data comes in as bytes. controller = Controller(self.handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Message-ID: Testing """) self.assertEqual(self.handled_message['subject'], 'A test') self.assertEqual(self.handled_message['message-id'], '') self.assertIsNotNone(self.handled_message['X-Peer']) self.assertEqual( self.handled_message['X-MailFrom'], 'anne@example.com') self.assertEqual(self.handled_message['X-RcptTo'], 'bart@example.com') def test_message_decoded(self): # With a server that decodes the data, the messages come in as # strings. There's no difference in the message seen by the # handler's handle_message() method, but internally this gives full # coverage. controller = DecodingController(self.handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Message-ID: Testing """) self.assertEqual(self.handled_message['subject'], 'A test') self.assertEqual(self.handled_message['message-id'], '') self.assertIsNotNone(self.handled_message['X-Peer']) self.assertEqual( self.handled_message['X-MailFrom'], 'anne@example.com') self.assertEqual(self.handled_message['X-RcptTo'], 'bart@example.com') class TestMailbox(unittest.TestCase): def setUp(self): self.tempdir = TemporaryDirectory() self.addCleanup(self.tempdir.cleanup) self.maildir_path = os.path.join(self.tempdir.name, 'maildir') self.handler = handler = Mailbox(self.maildir_path) controller = Controller(handler) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_mailbox(self): with SMTP(*self.address) as client: client.sendmail( 'aperson@example.com', ['bperson@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Message-ID: Hi Bart, this is Anne. """) client.sendmail( 'cperson@example.com', ['dperson@example.com'], """\ From: Cate Person To: Dave Person Subject: A test Message-ID: Hi Dave, this is Cate. """) client.sendmail( 'eperson@example.com', ['fperson@example.com'], """\ From: Elle Person To: Fred Person Subject: A test Message-ID: Hi Fred, this is Elle. """) # Check the messages in the mailbox. mailbox = Maildir(self.maildir_path) messages = sorted(mailbox, key=itemgetter('message-id')) self.assertEqual( list(message['message-id'] for message in messages), ['', '', '']) def test_mailbox_reset(self): with SMTP(*self.address) as client: client.sendmail( 'aperson@example.com', ['bperson@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Message-ID: Hi Bart, this is Anne. """) self.handler.reset() mailbox = Maildir(self.maildir_path) self.assertEqual(list(mailbox), []) class FakeParser: def __init__(self): self.message = None def error(self, message): self.message = message raise SystemExit class TestCLI(unittest.TestCase): def setUp(self): self.parser = FakeParser() def test_debugging_cli_no_args(self): handler = Debugging.from_cli(self.parser) self.assertIsNone(self.parser.message) self.assertEqual(handler.stream, sys.stdout) def test_debugging_cli_two_args(self): self.assertRaises( SystemExit, Debugging.from_cli, self.parser, 'foo', 'bar') self.assertEqual( self.parser.message, 'Debugging usage: [stdout|stderr]') def test_debugging_cli_stdout(self): handler = Debugging.from_cli(self.parser, 'stdout') self.assertIsNone(self.parser.message) self.assertEqual(handler.stream, sys.stdout) def test_debugging_cli_stderr(self): handler = Debugging.from_cli(self.parser, 'stderr') self.assertIsNone(self.parser.message) self.assertEqual(handler.stream, sys.stderr) def test_debugging_cli_bad_argument(self): self.assertRaises( SystemExit, Debugging.from_cli, self.parser, 'stdfoo') self.assertEqual( self.parser.message, 'Debugging usage: [stdout|stderr]') def test_sink_cli_no_args(self): handler = Sink.from_cli(self.parser) self.assertIsNone(self.parser.message) self.assertIsInstance(handler, Sink) def test_sink_cli_any_args(self): self.assertRaises( SystemExit, Sink.from_cli, self.parser, 'foo') self.assertEqual( self.parser.message, 'Sink handler does not accept arguments') def test_mailbox_cli_no_args(self): self.assertRaises(SystemExit, Mailbox.from_cli, self.parser) self.assertEqual( self.parser.message, 'The directory for the maildir is required') def test_mailbox_cli_too_many_args(self): self.assertRaises(SystemExit, Mailbox.from_cli, self.parser, 'foo', 'bar', 'baz') self.assertEqual( self.parser.message, 'Too many arguments for Mailbox handler') def test_mailbox_cli(self): with TemporaryDirectory() as tmpdir: handler = Mailbox.from_cli(self.parser, tmpdir) self.assertIsInstance(handler.mailbox, Maildir) self.assertEqual(handler.mail_dir, tmpdir) class TestProxy(unittest.TestCase): def setUp(self): # There are two controllers and two SMTPd's running here. The # "upstream" one listens on port 9025 and is connected to a "data # handler" which captures the messages it receives. The second -and # the one under test here- listens on port 9024 and proxies to the one # on port 9025. Because we need to set the decode_data flag # differently for each different test, the controller of the proxy is # created in the individual tests, not in the setup. self.upstream = DataHandler() upstream_controller = Controller(self.upstream, port=9025) upstream_controller.start() self.addCleanup(upstream_controller.stop) self.proxy = Proxy(upstream_controller.hostname, 9025) self.source = """\ From: Anne Person To: Bart Person Subject: A test Testing """ # The upstream SMTPd will always receive the content as bytes # delimited with CRLF. self.expected = CRLF.join([ 'From: Anne Person ', 'To: Bart Person ', 'Subject: A test', 'X-Peer: ::1', '', 'Testing']).encode('ascii') def test_deliver_bytes(self): with ExitStack() as resources: controller = Controller(self.proxy, port=9024) controller.start() resources.callback(controller.stop) client = resources.enter_context( SMTP(*(controller.hostname, controller.port))) client.sendmail( 'anne@example.com', ['bart@example.com'], self.source) client.quit() self.assertEqual(self.upstream.content, self.expected) self.assertEqual(self.upstream.original_content, self.expected) def test_deliver_str(self): with ExitStack() as resources: controller = DecodingController(self.proxy, port=9024) controller.start() resources.callback(controller.stop) client = resources.enter_context( SMTP(*(controller.hostname, controller.port))) client.sendmail( 'anne@example.com', ['bart@example.com'], self.source) client.quit() self.assertEqual(self.upstream.content, self.expected) self.assertEqual(self.upstream.original_content, self.expected) class TestProxyMocked(unittest.TestCase): def setUp(self): handler = Proxy('localhost', 9025) controller = DecodingController(handler) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) self.source = """\ From: Anne Person To: Bart Person Subject: A test Testing """ def test_recipients_refused(self): with ExitStack() as resources: log_mock = resources.enter_context(patch('aiosmtpd.handlers.log')) mock = resources.enter_context( patch('aiosmtpd.handlers.smtplib.SMTP')) mock().sendmail.side_effect = SMTPRecipientsRefused({ 'bart@example.com': (500, 'Bad Bart'), }) client = resources.enter_context(SMTP(*self.address)) client.sendmail( 'anne@example.com', ['bart@example.com'], self.source) client.quit() # The log contains information about what happened in the proxy. self.assertEqual( log_mock.info.call_args_list, [ call('got SMTPRecipientsRefused'), call('we got some refusals: %s', {'bart@example.com': (500, 'Bad Bart')})] ) def test_oserror(self): with ExitStack() as resources: log_mock = resources.enter_context(patch('aiosmtpd.handlers.log')) mock = resources.enter_context( patch('aiosmtpd.handlers.smtplib.SMTP')) mock().sendmail.side_effect = OSError client = resources.enter_context(SMTP(*self.address)) client.sendmail( 'anne@example.com', ['bart@example.com'], self.source) client.quit() # The log contains information about what happened in the proxy. self.assertEqual( log_mock.info.call_args_list, [ call('we got some refusals: %s', {'bart@example.com': (-1, 'ignore')}), ] ) class HELOHandler: async def handle_HELO(self, server, session, envelope, hostname): return '250 geddy.example.com' class EHLOHandler: async def handle_EHLO(self, server, session, envelope, hostname): return '250 alex.example.com' class MAILHandler: async def handle_MAIL(self, server, session, envelope, address, options): envelope.mail_options.extend(options) return '250 Yeah, sure' class RCPTHandler: async def handle_RCPT(self, server, session, envelope, address, options): envelope.rcpt_options.extend(options) if address == 'bart@example.com': return '550 Rejected' envelope.rcpt_tos.append(address) return '250 OK' class DATAHandler: async def handle_DATA(self, server, session, envelope): return '599 Not today' class NoHooksHandler: pass class TestHooks(unittest.TestCase): def test_rcpt_hook(self): controller = Controller(RCPTHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: with self.assertRaises(SMTPRecipientsRefused) as cm: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: anne@example.com To: bart@example.com Subject: Test """) self.assertEqual(cm.exception.recipients, { 'bart@example.com': (550, b'Rejected'), }) def test_helo_hook(self): controller = Controller(HELOHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.helo('me') self.assertEqual(code, 250) self.assertEqual(response, b'geddy.example.com') def test_ehlo_hook(self): controller = Controller(EHLOHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.ehlo('me') self.assertEqual(code, 250) lines = response.decode('utf-8').splitlines() self.assertEqual(lines[-1], 'alex.example.com') def test_mail_hook(self): controller = Controller(MAILHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.helo('me') code, response = client.mail('anne@example.com') self.assertEqual(code, 250) self.assertEqual(response, b'Yeah, sure') def test_data_hook(self): controller = Controller(DATAHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: with self.assertRaises(SMTPDataError) as cm: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: anne@example.com To: bart@example.com Subject: Test Yikes """) self.assertEqual(cm.exception.smtp_code, 599) self.assertEqual(cm.exception.smtp_error, b'Not today') def test_no_hooks(self): controller = Controller(NoHooksHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.helo('me') client.mail('anne@example.com') client.rcpt(['bart@example.com']) code, response = client.data("""\ From: anne@example.com To: bart@example.com Subject: Test """) self.assertEqual(code, 250) class CapturingServer(Server): def __init__(self, *args, **kws): self.warnings = None super().__init__(*args, **kws) async def smtp_DATA(self, arg): with patch('aiosmtpd.smtp.warn') as mock: await super().smtp_DATA(arg) self.warnings = mock.call_args_list class CapturingController(Controller): def factory(self): self.smtpd = CapturingServer(self.handler) return self.smtpd class DeprecatedHandler: def process_message(self, peer, mailfrom, rcpttos, data, **kws): pass class AsyncDeprecatedHandler: async def process_message(self, peer, mailfrom, rcpttos, data, **kws): pass class DeprecatedHookServer(Server): def __init__(self, *args, **kws): self.warnings = None super().__init__(*args, **kws) async def smtp_EHLO(self, arg): with patch('aiosmtpd.smtp.warn') as mock: await super().smtp_EHLO(arg) self.warnings = mock.call_args_list async def smtp_RSET(self, arg): with patch('aiosmtpd.smtp.warn') as mock: await super().smtp_RSET(arg) self.warnings = mock.call_args_list async def ehlo_hook(self): pass async def rset_hook(self): pass class DeprecatedHookController(Controller): def factory(self): self.smtpd = DeprecatedHookServer(self.handler) return self.smtpd class TestDeprecation(unittest.TestCase): # handler.process_message() is deprecated. def test_deprecation(self): controller = CapturingController(DeprecatedHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Testing """) self.assertEqual(len(controller.smtpd.warnings), 1) self.assertEqual( controller.smtpd.warnings[0], call('Use handler.handle_DATA() instead of .process_message()', DeprecationWarning)) def test_deprecation_async(self): controller = CapturingController(AsyncDeprecatedHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: Anne Person To: Bart Person Subject: A test Testing """) self.assertEqual(len(controller.smtpd.warnings), 1) self.assertEqual( controller.smtpd.warnings[0], call('Use handler.handle_DATA() instead of .process_message()', DeprecationWarning)) def test_ehlo_hook_deprecation(self): controller = DeprecatedHookController(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') self.assertEqual(len(controller.smtpd.warnings), 1) self.assertEqual( controller.smtpd.warnings[0], call('Use handler.handle_EHLO() instead of .ehlo_hook()', DeprecationWarning)) def test_rset_hook_deprecation(self): controller = DeprecatedHookController(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.rset() self.assertEqual(len(controller.smtpd.warnings), 1) self.assertEqual( controller.smtpd.warnings[0], call('Use handler.handle_RSET() instead of .rset_hook()', DeprecationWarning)) aiosmtpd-1.1/aiosmtpd/tests/test_smtp.py0000664000175000017500000013205613127275524021052 0ustar barrybarry00000000000000"""Test the SMTP protocol.""" import socket import asyncio import unittest from aiosmtpd.controller import Controller from aiosmtpd.handlers import Sink from aiosmtpd.smtp import SMTP as Server, __ident__ as GREETING from aiosmtpd.testing.helpers import reset_connection from contextlib import ExitStack from smtplib import ( SMTP, SMTPDataError, SMTPResponseException, SMTPServerDisconnected) from unittest.mock import Mock, PropertyMock, patch CRLF = '\r\n' BCRLF = b'\r\n' class DecodingController(Controller): def factory(self): return Server(self.handler, decode_data=True, enable_SMTPUTF8=True) class NoDecodeController(Controller): def factory(self): return Server(self.handler, decode_data=False) class ReceivingHandler: box = None def __init__(self): self.box = [] async def handle_DATA(self, server, session, envelope): self.box.append(envelope) return '250 OK' class StoreEnvelopeOnVRFYHandler: """Saves envelope for later inspection when handling VRFY.""" envelope = None async def handle_VRFY(self, server, session, envelope, addr): self.envelope = envelope return '250 OK' class SizedController(Controller): def __init__(self, handler, size): self.size = size super().__init__(handler) def factory(self): return Server(self.handler, data_size_limit=self.size) class StrictASCIIController(Controller): def factory(self): return Server(self.handler, enable_SMTPUTF8=False, decode_data=True) class CustomHostnameController(Controller): def factory(self): return Server(self.handler, hostname='custom.localhost') class CustomIdentController(Controller): def factory(self): server = Server(self.handler) server.__ident__ = 'Identifying SMTP v2112' return server class ErroringHandler: error = None async def handle_DATA(self, server, session, envelope): return '499 Could not accept the message' async def handle_exception(self, error): self.error = error return '500 ErroringHandler handling error' class ErroringHandlerCustomResponse: error = None async def handle_exception(self, error): self.error = error return '451 Temporary error: ({}) {}'.format( error.__class__.__name__, str(error)) class ErroringErrorHandler: error = None async def handle_exception(self, error): self.error = error raise ValueError('ErroringErrorHandler test') class UndescribableError(Exception): def __str__(self): raise Exception() class UndescribableErrorHandler: error = None async def handle_exception(self, error): self.error = error raise UndescribableError() class ErrorSMTP(Server): async def smtp_HELO(self, hostname): raise ValueError('test') class ErrorController(Controller): def factory(self): return ErrorSMTP(self.handler) class SleepingHeloHandler: async def handle_HELO(self, server, session, envelope, hostname): await asyncio.sleep(0.01) session.host_name = hostname return '250 {}'.format(server.hostname) class TestProtocol(unittest.TestCase): def setUp(self): self.transport = Mock() self.transport.write = self._write self.responses = [] self._old_loop = asyncio.get_event_loop() self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) def tearDown(self): self.loop.close() asyncio.set_event_loop(self._old_loop) def _write(self, data): self.responses.append(data) def _get_protocol(self, *args, **kwargs): protocol = Server(*args, loop=self.loop, **kwargs) protocol.connection_made(self.transport) return protocol def test_honors_mail_delimeters(self): handler = ReceivingHandler() data = b'test\r\nmail\rdelimeters\nsaved' protocol = self._get_protocol(handler) protocol.data_received(BCRLF.join([ b'HELO example.org', b'MAIL FROM: ', b'RCPT TO: ', b'DATA', data + b'\r\n.', b'QUIT\r\n' ])) try: self.loop.run_until_complete(protocol._handler_coroutine) except asyncio.CancelledError: pass self.assertEqual(len(handler.box), 1) self.assertEqual(handler.box[0].content, data) def test_empty_email(self): handler = ReceivingHandler() protocol = self._get_protocol(handler) protocol.data_received(BCRLF.join([ b'HELO example.org', b'MAIL FROM: ', b'RCPT TO: ', b'DATA', b'.', b'QUIT\r\n' ])) try: self.loop.run_until_complete(protocol._handler_coroutine) except asyncio.CancelledError: pass self.assertEqual(self.responses[5], b'250 OK\r\n') self.assertEqual(len(handler.box), 1) self.assertEqual(handler.box[0].content, b'') class TestSMTP(unittest.TestCase): def setUp(self): controller = DecodingController(Sink) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_helo(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) self.assertEqual(response, bytes(socket.getfqdn(), 'utf-8')) def test_helo_no_hostname(self): with SMTP(*self.address) as client: # smtplib substitutes .local_hostname if the argument is falsey. client.local_hostname = '' code, response = client.helo('') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: HELO hostname') def test_helo_duplicate(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.helo('example.org') self.assertEqual(code, 250) def test_ehlo(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) lines = response.splitlines() self.assertEqual(lines[0], bytes(socket.getfqdn(), 'utf-8')) self.assertEqual(lines[1], b'SIZE 33554432') self.assertEqual(lines[2], b'SMTPUTF8') self.assertEqual(lines[3], b'HELP') def test_ehlo_duplicate(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.ehlo('example.org') self.assertEqual(code, 250) def test_ehlo_no_hostname(self): with SMTP(*self.address) as client: # smtplib substitutes .local_hostname if the argument is falsey. client.local_hostname = '' code, response = client.ehlo('') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: EHLO hostname') def test_helo_then_ehlo(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.ehlo('example.org') self.assertEqual(code, 250) def test_ehlo_then_helo(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.helo('example.org') self.assertEqual(code, 250) def test_noop(self): with SMTP(*self.address) as client: code, response = client.noop() self.assertEqual(code, 250) def test_noop_with_arg(self): with SMTP(*self.address) as client: # .noop() doesn't accept arguments. code, response = client.docmd('NOOP', 'ok') self.assertEqual(code, 250) def test_quit(self): client = SMTP(*self.address) code, response = client.quit() self.assertEqual(code, 221) self.assertEqual(response, b'Bye') def test_quit_with_arg(self): client = SMTP(*self.address) code, response = client.docmd('QUIT', 'oops') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: QUIT') def test_help(self): with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP') self.assertEqual(code, 250) self.assertEqual(response, b'Supported commands: DATA EHLO HELO HELP MAIL ' b'NOOP QUIT RCPT RSET VRFY') def test_help_helo(self): with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP', 'HELO') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: HELO hostname') def test_help_ehlo(self): with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP', 'EHLO') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: EHLO hostname') def test_help_mail(self): with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP', 'MAIL') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: MAIL FROM:
') def test_help_mail_esmtp(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('HELP', 'MAIL') self.assertEqual(code, 250) self.assertEqual( response, b'Syntax: MAIL FROM:
[SP ]') def test_help_rcpt(self): with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP', 'RCPT') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: RCPT TO:
') def test_help_rcpt_esmtp(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('HELP', 'RCPT') self.assertEqual(code, 250) self.assertEqual( response, b'Syntax: RCPT TO:
[SP ]') def test_help_data(self): with SMTP(*self.address) as client: code, response = client.docmd('HELP', 'DATA') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: DATA') def test_help_rset(self): with SMTP(*self.address) as client: code, response = client.docmd('HELP', 'RSET') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: RSET') def test_help_noop(self): with SMTP(*self.address) as client: code, response = client.docmd('HELP', 'NOOP') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: NOOP [ignored]') def test_help_quit(self): with SMTP(*self.address) as client: code, response = client.docmd('HELP', 'QUIT') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: QUIT') def test_help_vrfy(self): with SMTP(*self.address) as client: code, response = client.docmd('HELP', 'VRFY') self.assertEqual(code, 250) self.assertEqual(response, b'Syntax: VRFY
') def test_help_bad_arg(self): with SMTP(*self.address) as client: # Don't get tricked by smtplib processing of the response. code, response = client.docmd('HELP me!') self.assertEqual(code, 501) self.assertEqual(response, b'Supported commands: DATA EHLO HELO HELP MAIL ' b'NOOP QUIT RCPT RSET VRFY') def test_expn(self): with SMTP(*self.address) as client: code, response = client.expn('anne@example.com') self.assertEqual(code, 502) self.assertEqual(response, b'EXPN not implemented') def test_mail_no_helo(self): with SMTP(*self.address) as client: code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 503) self.assertEqual(response, b'Error: send HELO first') def test_mail_no_arg(self): with SMTP(*self.address) as client: client.helo('example.com') code, response = client.docmd('MAIL') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: MAIL FROM:
') def test_mail_no_from(self): with SMTP(*self.address) as client: client.helo('example.com') code, response = client.docmd('MAIL ') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: MAIL FROM:
') def test_mail_params_no_esmtp(self): with SMTP(*self.address) as client: client.helo('example.com') code, response = client.docmd( 'MAIL FROM: SIZE=10000') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: MAIL FROM:
') def test_mail_params_esmtp(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: SIZE=10000') self.assertEqual(code, 250) self.assertEqual(response, b'OK') def test_mail_from_twice(self): with SMTP(*self.address) as client: client.helo('example.com') code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) self.assertEqual(response, b'OK') code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 503) self.assertEqual(response, b'Error: nested MAIL command') def test_mail_from_malformed(self): with SMTP(*self.address) as client: client.helo('example.com') code, response = client.docmd('MAIL FROM: Anne ') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: MAIL FROM:
') def test_mail_malformed_params_esmtp(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: SIZE 10000') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: MAIL FROM:
[SP ]') def test_mail_missing_params_esmtp(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd('MAIL FROM: SIZE') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: MAIL FROM:
[SP ]') def test_mail_unrecognized_params_esmtp(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: FOO=BAR') self.assertEqual(code, 555) self.assertEqual( response, b'MAIL FROM parameters not recognized or not implemented') def test_mail_params_bad_syntax_esmtp(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: #$%=!@#') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: MAIL FROM:
[SP ]') # Test the workaround http://bugs.python.org/issue27931 @patch('email._header_value_parser.AngleAddr.addr_spec', side_effect=IndexError, new_callable=PropertyMock) def test_mail_fail_parse_email(self, addr_spec): with SMTP(*self.address) as client: client.helo('example.com') code, response = client.docmd('MAIL FROM: <""@example.com>') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: MAIL FROM:
') def test_rcpt_no_helo(self): with SMTP(*self.address) as client: code, response = client.docmd('RCPT TO: ') self.assertEqual(code, 503) self.assertEqual(response, b'Error: send HELO first') def test_rcpt_no_mail(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.docmd('RCPT TO: ') self.assertEqual(code, 503) self.assertEqual(response, b'Error: need MAIL command') def test_rcpt_no_arg(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd('RCPT') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: RCPT TO:
') def test_rcpt_no_to(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd('RCPT ') def test_rcpt_no_arg_esmtp(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd('RCPT') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: RCPT TO:
[SP ]') def test_rcpt_no_address(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd('RCPT TO:') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: RCPT TO:
[SP ]') def test_rcpt_with_params_no_esmtp(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd( 'RCPT TO: SIZE=1000') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: RCPT TO:
') def test_rcpt_with_bad_params(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd( 'RCPT TO: #$%=!@#') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: RCPT TO:
[SP ]') def test_rcpt_with_unknown_params(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd( 'RCPT TO: FOOBAR') self.assertEqual(code, 555) self.assertEqual( response, b'RCPT TO parameters not recognized or not implemented') # Test the workaround http://bugs.python.org/issue27931 @patch('email._header_value_parser.AngleAddr.addr_spec', new_callable=PropertyMock) def test_rcpt_fail_parse_email(self, addr_spec): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) addr_spec.side_effect = IndexError code, response = client.docmd('RCPT TO: <""@example.com>') self.assertEqual(code, 501) self.assertEqual( response, b'Syntax: RCPT TO:
[SP ]') def test_rset(self): with SMTP(*self.address) as client: code, response = client.rset() self.assertEqual(code, 250) self.assertEqual(response, b'OK') def test_rset_with_arg(self): with SMTP(*self.address) as client: code, response = client.docmd('RSET FOO') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: RSET') def test_vrfy(self): with SMTP(*self.address) as client: code, response = client.docmd('VRFY ') self.assertEqual(code, 252) self.assertEqual( response, b'Cannot VRFY user, but will accept message and attempt delivery' ) def test_vrfy_no_arg(self): with SMTP(*self.address) as client: code, response = client.docmd('VRFY') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: VRFY
') def test_vrfy_not_an_address(self): with SMTP(*self.address) as client: code, response = client.docmd('VRFY @@') self.assertEqual(code, 502) self.assertEqual(response, b'Could not VRFY @@') def test_data_no_helo(self): with SMTP(*self.address) as client: code, response = client.docmd('DATA') self.assertEqual(code, 503) self.assertEqual(response, b'Error: send HELO first') def test_data_no_rcpt(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.docmd('DATA') self.assertEqual(code, 503) self.assertEqual(response, b'Error: need RCPT command') def test_data_invalid_params(self): with SMTP(*self.address) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd('RCPT TO: ') self.assertEqual(code, 250) code, response = client.docmd('DATA FOOBAR') self.assertEqual(code, 501) self.assertEqual(response, b'Syntax: DATA') def test_empty_command(self): with SMTP(*self.address) as client: code, response = client.docmd('') self.assertEqual(code, 500) self.assertEqual(response, b'Error: bad syntax') def test_too_long_command(self): with SMTP(*self.address) as client: code, response = client.docmd('a' * 513) self.assertEqual(code, 500) self.assertEqual(response, b'Error: line too long') def test_unknown_command(self): with SMTP(*self.address) as client: code, response = client.docmd('FOOBAR') self.assertEqual(code, 500) self.assertEqual( response, b'Error: command "FOOBAR" not recognized') class TestResetCommands(unittest.TestCase): """Test that sender and recipients are reset on RSET, HELO, and EHLO. The tests below issue each command twice with different addresses and verify that mail_from and rcpt_tos have been replacecd. """ expected_envelope_data = [ # Pre-RSET/HELO/EHLO envelope data. dict( mail_from='anne@example.com', rcpt_tos=['bart@example.com', 'cate@example.com'], ), dict( mail_from='dave@example.com', rcpt_tos=['elle@example.com', 'fred@example.com'], ), ] def setUp(self): self._handler = StoreEnvelopeOnVRFYHandler() self._controller = DecodingController(self._handler) self._controller.start() self._address = (self._controller.hostname, self._controller.port) self.addCleanup(self._controller.stop) def _send_envelope_data(self, client, mail_from, rcpt_tos): client.mail(mail_from) for rcpt in rcpt_tos: client.rcpt(rcpt) def test_helo(self): with SMTP(*self._address) as client: # Each time through the loop, the HELO will reset the envelope. for data in self.expected_envelope_data: client.helo('example.com') # Save the envelope in the handler. client.vrfy('zuzu@example.com') self.assertIsNone(self._handler.envelope.mail_from) self.assertEqual(len(self._handler.envelope.rcpt_tos), 0) self._send_envelope_data(client, **data) client.vrfy('zuzu@example.com') self.assertEqual( self._handler.envelope.mail_from, data['mail_from']) self.assertEqual( self._handler.envelope.rcpt_tos, data['rcpt_tos']) def test_ehlo(self): with SMTP(*self._address) as client: # Each time through the loop, the EHLO will reset the envelope. for data in self.expected_envelope_data: client.ehlo('example.com') # Save the envelope in the handler. client.vrfy('zuzu@example.com') self.assertIsNone(self._handler.envelope.mail_from) self.assertEqual(len(self._handler.envelope.rcpt_tos), 0) self._send_envelope_data(client, **data) client.vrfy('zuzu@example.com') self.assertEqual( self._handler.envelope.mail_from, data['mail_from']) self.assertEqual( self._handler.envelope.rcpt_tos, data['rcpt_tos']) def test_rset(self): with SMTP(*self._address) as client: client.helo('example.com') # Each time through the loop, the RSET will reset the envelope. for data in self.expected_envelope_data: self._send_envelope_data(client, **data) # Save the envelope in the handler. client.vrfy('zuzu@example.com') self.assertEqual( self._handler.envelope.mail_from, data['mail_from']) self.assertEqual( self._handler.envelope.rcpt_tos, data['rcpt_tos']) # Reset the envelope explicitly. client.rset() client.vrfy('zuzu@example.com') self.assertIsNone(self._handler.envelope.mail_from) self.assertEqual(len(self._handler.envelope.rcpt_tos), 0) class TestSMTPWithController(unittest.TestCase): def test_mail_with_size_too_large(self): controller = SizedController(Sink(), 9999) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: SIZE=10000') self.assertEqual(code, 552) self.assertEqual( response, b'Error: message size exceeds fixed maximum message size') def test_mail_with_compatible_smtputf8(self): handler = ReceivingHandler() controller = Controller(handler) controller.start() self.addCleanup(controller.stop) recipient = 'bart\xCB@example.com' sender = 'anne\xCB@example.com' with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') client.send(bytes( 'MAIL FROM: <' + sender + '> SMTPUTF8\r\n', encoding='utf-8')) code, response = client.getreply() self.assertEqual(code, 250) self.assertEqual(response, b'OK') client.send(bytes( 'RCPT TO: <' + recipient + '>\r\n', encoding='utf-8')) code, response = client.getreply() self.assertEqual(code, 250) self.assertEqual(response, b'OK') code, response = client.data('') self.assertEqual(code, 250) self.assertEqual(response, b'OK') self.assertEqual(handler.box[0].rcpt_tos[0], recipient) self.assertEqual(handler.box[0].mail_from, sender) def test_mail_with_unrequited_smtputf8(self): controller = Controller(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.docmd('MAIL FROM: ') self.assertEqual(code, 250) self.assertEqual(response, b'OK') def test_mail_with_incompatible_smtputf8(self): controller = Controller(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: SMTPUTF8=YES') self.assertEqual(code, 501) self.assertEqual(response, b'Error: SMTPUTF8 takes no arguments') def test_mail_invalid_body(self): controller = Controller(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: BODY 9BIT') self.assertEqual(code, 501) self.assertEqual(response, b'Error: BODY can only be one of 7BIT, 8BITMIME') def test_esmtp_no_size_limit(self): controller = SizedController(Sink(), size=None) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) for line in response.splitlines(): self.assertNotEqual(line[:4], b'SIZE') def test_process_message_error(self): controller = Controller(ErroringHandler()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) with self.assertRaises(SMTPDataError) as cm: client.sendmail('anne@example.com', ['bart@example.com'], """\ From: anne@example.com To: bart@example.com Subject: A test Testing """) self.assertEqual(cm.exception.smtp_code, 499) self.assertEqual(cm.exception.smtp_error, b'Could not accept the message') def test_too_long_message_body(self): controller = SizedController(Sink(), size=100) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.helo('example.com') mail = '\r\n'.join(['z' * 20] * 10) with self.assertRaises(SMTPResponseException) as cm: client.sendmail('anne@example.com', ['bart@example.com'], mail) self.assertEqual(cm.exception.smtp_code, 552) self.assertEqual(cm.exception.smtp_error, b'Error: Too much mail data') def test_dots_escaped(self): handler = ReceivingHandler() controller = DecodingController(handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.helo('example.com') mail = CRLF.join(['Test', '.', 'mail']) client.sendmail('anne@example.com', ['bart@example.com'], mail) self.assertEqual(len(handler.box), 1) self.assertEqual(handler.box[0].content, 'Test\r\n.\r\nmail') def test_unexpected_errors(self): handler = ErroringHandler() controller = ErrorController(handler) controller.start() self.addCleanup(controller.stop) with ExitStack() as resources: # Suppress logging to the console during the tests. Depending on # timing, the exception may or may not be logged. resources.enter_context(patch('aiosmtpd.smtp.log.exception')) client = resources.enter_context( SMTP(controller.hostname, controller.port)) code, response = client.helo('example.com') self.assertEqual(code, 500) self.assertEqual(response, b'ErroringHandler handling error') self.assertIsInstance(handler.error, ValueError) def test_unexpected_errors_unhandled(self): handler = Sink() handler.error = None controller = ErrorController(handler) controller.start() self.addCleanup(controller.stop) with ExitStack() as resources: # Suppress logging to the console during the tests. Depending on # timing, the exception may or may not be logged. resources.enter_context(patch('aiosmtpd.smtp.log.exception')) client = resources.enter_context( SMTP(controller.hostname, controller.port)) code, response = client.helo('example.com') self.assertEqual(code, 500) self.assertEqual(response, b'Error: (ValueError) test') # handler.error did not change because the handler does not have a # handle_exception() method. self.assertIsNone(handler.error) def test_unexpected_errors_custom_response(self): handler = ErroringHandlerCustomResponse() controller = ErrorController(handler) controller.start() self.addCleanup(controller.stop) with ExitStack() as resources: # Suppress logging to the console during the tests. Depending on # timing, the exception may or may not be logged. resources.enter_context(patch('aiosmtpd.smtp.log.exception')) client = resources.enter_context( SMTP(controller.hostname, controller.port)) code, response = client.helo('example.com') self.assertEqual(code, 451) self.assertEqual(response, b'Temporary error: (ValueError) test') self.assertIsInstance(handler.error, ValueError) def test_exception_handler_exception(self): handler = ErroringErrorHandler() controller = ErrorController(handler) controller.start() self.addCleanup(controller.stop) with ExitStack() as resources: # Suppress logging to the console during the tests. Depending on # timing, the exception may or may not be logged. resources.enter_context(patch('aiosmtpd.smtp.log.exception')) client = resources.enter_context( SMTP(controller.hostname, controller.port)) code, response = client.helo('example.com') self.assertEqual(code, 500) self.assertEqual(response, b'Error: (ValueError) ErroringErrorHandler test') self.assertIsInstance(handler.error, ValueError) def test_exception_handler_undescribable(self): handler = UndescribableErrorHandler() controller = ErrorController(handler) controller.start() self.addCleanup(controller.stop) with ExitStack() as resources: # Suppress logging to the console during the tests. Depending on # timing, the exception may or may not be logged. resources.enter_context(patch('aiosmtpd.smtp.log.exception')) client = resources.enter_context( SMTP(controller.hostname, controller.port)) code, response = client.helo('example.com') self.assertEqual(code, 500) self.assertEqual(response, b'Error: Cannot describe error') self.assertIsInstance(handler.error, ValueError) def test_bad_encodings(self): handler = ReceivingHandler() controller = DecodingController(handler) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: client.helo('example.com') mail_from = b'anne\xFF@example.com' mail_to = b'bart\xFF@example.com' client.ehlo('test') client.send(b'MAIL FROM:' + mail_from + b'\r\n') code, response = client.getreply() self.assertEqual(code, 250) client.send(b'RCPT TO:' + mail_to + b'\r\n') code, response = client.getreply() self.assertEqual(code, 250) client.data('Test mail') self.assertEqual(len(handler.box), 1) envelope = handler.box[0] mail_from2 = envelope.mail_from.encode( 'utf-8', errors='surrogateescape') self.assertEqual(mail_from2, mail_from) mail_to2 = envelope.rcpt_tos[0].encode( 'utf-8', errors='surrogateescape') self.assertEqual(mail_to2, mail_to) class TestCustomizations(unittest.TestCase): def test_custom_hostname(self): controller = CustomHostnameController(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP(controller.hostname, controller.port) as client: code, response = client.helo('example.com') self.assertEqual(code, 250) self.assertEqual(response, bytes('custom.localhost', 'utf-8')) def test_custom_greeting(self): controller = CustomIdentController(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP() as client: code, msg = client.connect(controller.hostname, controller.port) self.assertEqual(code, 220) # The hostname prefix is unpredictable. self.assertEqual(msg[-22:], b'Identifying SMTP v2112') def test_default_greeting(self): controller = Controller(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP() as client: code, msg = client.connect(controller.hostname, controller.port) self.assertEqual(code, 220) # The hostname prefix is unpredictable. self.assertEqual(msg[-len(GREETING):], bytes(GREETING, 'utf-8')) def test_mail_invalid_body_param(self): controller = NoDecodeController(Sink()) controller.start() self.addCleanup(controller.stop) with SMTP() as client: code, msg = client.connect(controller.hostname, controller.port) client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: BODY=FOOBAR') self.assertEqual(code, 501) self.assertEqual( response, b'Error: BODY can only be one of 7BIT, 8BITMIME') class TestClientCrash(unittest.TestCase): # GH#62 - if the client crashes during the SMTP dialog we want to make # sure we don't get tracebacks where we call readline(). def setUp(self): controller = Controller(Sink) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_connection_reset_during_DATA(self): with SMTP(*self.address) as client: client.helo('example.com') client.docmd('MAIL FROM: ') client.docmd('RCPT TO: ') client.docmd('DATA') # Start sending the DATA but reset the connection before that # completes, i.e. before the .\r\n client.send(b'From: ') reset_connection(client) # The connection should be disconnected, so trying to do another # command from here will give us an exception. In GH#62, the # server just hung. self.assertRaises(SMTPServerDisconnected, client.noop) def test_connection_reset_during_command(self): with SMTP(*self.address) as client: client.helo('example.com') # Start sending a command but reset the connection before that # completes, i.e. before the \r\n client.send('MAIL FROM: ') self.assertEqual(code, 250) code, response = client.docmd('RCPT TO: ') self.assertEqual(code, 250) code, response = client.docmd('DATA') self.assertEqual(code, 354) # Don't include the CRLF. client.send('FOO') client.close() class TestStrictASCII(unittest.TestCase): def setUp(self): controller = StrictASCIIController(Sink()) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_ehlo(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) lines = response.splitlines() self.assertNotIn(b'SMTPUTF8', lines) def test_bad_encoded_param(self): with SMTP(*self.address) as client: client.ehlo('example.com') client.send(b'MAIL FROM: \r\n') code, response = client.getreply() self.assertEqual(code, 500) self.assertIn(b'Error: strict ASCII mode', response) def test_mail_param(self): with SMTP(*self.address) as client: client.ehlo('example.com') code, response = client.docmd( 'MAIL FROM: SMTPUTF8') self.assertEqual(code, 501) self.assertEqual(response, b'Error: SMTPUTF8 disabled') def test_data(self): with SMTP(*self.address) as client: code, response = client.ehlo('example.com') self.assertEqual(code, 250) with self.assertRaises(SMTPDataError) as cm: client.sendmail('anne@example.com', ['bart@example.com'], b"""\ From: anne@example.com To: bart@example.com Subject: A test Testing\xFF """) self.assertEqual(cm.exception.smtp_code, 500) self.assertIn(b'Error: strict ASCII mode', cm.exception.smtp_error) class TestSleepingHandler(unittest.TestCase): def setUp(self): controller = NoDecodeController(SleepingHeloHandler()) controller.start() self.addCleanup(controller.stop) self.address = (controller.hostname, controller.port) def test_close_after_helo(self): with SMTP(*self.address) as client: client.send('HELO example.com\r\n') client.sock.shutdown(socket.SHUT_WR) self.assertRaises(SMTPServerDisconnected, client.getreply) aiosmtpd-1.1/aiosmtpd/docs/0000775000175000017500000000000013127451604016230 5ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/docs/handlers.rst0000664000175000017500000002324713127446424020576 0ustar barrybarry00000000000000.. _handlers: ========== Handlers ========== Handlers are classes which can implement :ref:`hook methods ` that get called at various points in the SMTP dialog. Handlers can also be named on the :ref:`command line `, but if the class's constructor takes arguments, you must define a ``@classmethod`` that converts the positional arguments and returns a handler instance: ``from_cli(cls, parser, *args)`` Convert the positional arguments, as strings passed in on the command line, into a handler instance. ``parser`` is the ArgumentParser_ instance in use. If ``from_cli()`` is not defined, the handler can still be used on the command line, but its constructor cannot accept arguments. .. _hooks: Handler hooks ============= Handlers can implement hooks that get called during the SMTP dialog, or in exceptional cases. These *handler hooks* are all called asynchronously (i.e. they are coroutines) and they *must* return a status string, such as ``'250 OK'``. All handler hooks are optional and default behaviors are carried out by the ``SMTP`` class when a hook is omitted, so you only need to implement the ones you care about. When a handler hook is defined, it may have additional responsibilities as described below. All handler hooks take at least three arguments, the ``SMTP`` server instance, :ref:`a session instance, and an envelope instance `. Some methods take additional arguments. The following hooks are currently defined: ``handle_HELO(server, session, envelope, hostname)`` Called during ``HELO``. The ``hostname`` argument is the host name given by the client in the ``HELO`` command. If implemented, this hook must also set the ``session.host_name`` attribute before returning ``'250 {}'.format(server.hostname)`` as the status. ``handle_EHLO(server, session, envelope, hostname)`` Called during ``EHLO``. The ``hostname`` argument is the host name given by the client in the ``EHLO`` command. If implemented, this hook must also set the ``session.host_name`` attribute. This hook may push additional ``250-`` responses to the client by yielding from ``server.push(status)`` before returning ``250 HELP`` as the final response. ``handle_NOOP(server, session, envelope, arg)`` Called during ``NOOP``. ``handle_QUIT(server, session, envelope)`` Called during ``QUIT``. ``handle_VRFY(server, session, envelope, address)`` Called during ``VRFY``. The ``address`` argument is the parsed email address given by the client in the ``VRFY`` command. ``handle_MAIL(server, session, envelope, address, mail_options)`` Called during ``MAIL FROM``. The ``address`` argument is the parsed email address given by the client in the ``MAIL FROM`` command, and ``mail_options`` are any additional ESMTP mail options providing by the client. If implemented, this hook must also set the ``envelope.mail_from`` attribute and it may extend ``envelope.mail_options`` (which is always a Python list). ``handle_RCPT(server, session, envelope, address, rcpt_options)`` Called during ``RCPT TO``. The ``address`` argument is the parsed email address given by the client in the ``RCPT TO`` command, and ``rcpt_options`` are any additional ESMTP recipient options providing by the client. If implemented, this hook should append the address to ``envelope.rcpt_tos`` and may extend ``envelope.rcpt_options`` (both of which are always Python lists). ``handle_RSET(server, session, envelope)`` Called during ``RSET``. ``handle_DATA(server, session, envelope)`` Called during ``DATA`` after the entire message (`"SMTP content" `_ as described in RFC 5321) has been received. The content is available on the ``envelope`` object, but the values are dependent on whether the ``SMTP`` class was instantiated with ``decode_data=False`` (the default) or ``decode_data=True``. In the former case, both ``envelope.content`` and ``envelope.original_content`` will be the content bytes (normalized according to the transparency rules in `RFC 5321, ยง4.5.2 `_). In the latter case, ``envelope.original_content`` will be the normalized bytes, but ``envelope.content`` will be the UTF-8 decoded string of the original content. In addition to the SMTP command hooks, the following hooks can also be implemented by handlers. These have different APIs, and are called synchronously (i.e. they are **not** coroutines). ``handle_STARTTLS(server, session, envelope)`` If implemented, and if SSL is supported, this method gets called during the TLS handshake phase of ``connection_made()``. It should return True if the handshake succeeded, and False otherwise. ``handle_exception(error)`` If implemented, this method is called when any error occurs during the handling of a connection (e.g. if an ``smtp_()`` method raises an exception). The exception object is passed in. This method *must* return a status string, such as ``'542 Internal server error'``. If the method returns None or raises an exception, an exception will be logged, and a 500 code will be returned to the client. Built-in handlers ================= The following built-in handlers can be imported from ``aiosmtpd.handlers``: * ``Debugging`` - this class prints the contents of the received messages to a given output stream. Programmatically, you can pass the stream to print to into the constructor. When specified on the command line, the positional argument must either be the string ``stdout`` or ``stderr`` indicating which stream to use. * ``Proxy`` - this class is a relatively simple SMTP proxy; it forwards messages to a remote host and port. The constructor takes the host name and port as positional arguments. This class cannot be used on the command line. * ``Sink`` - this class just consumes and discards messages. It's essentially the "no op" handler. It can be used on the command line, but accepts no positional arguments. * ``Message`` - this class is a base class (it must be subclassed) which converts the message content into a message instance. The class used to create these instances can be passed to the constructor, and defaults to `email.message.Message`_. This message instance gains a few additional headers (e.g. ``X-Peer``, ``X-MailFrom``, and ``X-RcptTo``). You can override this behavior by overriding the ``prepare_message()`` method, which takes a session and an envelope. The message instance is then passed to the handler's ``handle_message()`` method. It is this method that must be implemented in the subclass. ``prepare_message()`` and ``handle_message()`` are both called *synchronously*. This handler cannot be used on the command line. * ``AsyncMessage`` - a subclass of the ``Message`` handler, with the only difference being that ``handle_message()`` is called *asynchronously*. This handler cannot be used on the command line. * ``Mailbox`` - a subclass of the ``Message`` handler which adds the messages to a Maildir_. See below for details. The Mailbox handler =================== A convenient handler is the ``Mailbox`` handler, which stores incoming messages into a maildir:: >>> import os >>> from aiosmtpd.controller import Controller >>> from aiosmtpd.handlers import Mailbox >>> from tempfile import TemporaryDirectory >>> # Clean up the temporary directory at the end of this doctest. >>> tempdir = resources.enter_context(TemporaryDirectory()) >>> maildir_path = os.path.join(tempdir, 'maildir') >>> controller = Controller(Mailbox(maildir_path)) >>> controller.start() >>> # Arrange for the controller to be stopped at the end of this doctest. >>> ignore = resources.callback(controller.stop) Now we can connect to the server and send it a message... >>> from smtplib import SMTP >>> client = SMTP(controller.hostname, controller.port) >>> client.sendmail('aperson@example.com', ['bperson@example.com'], """\ ... From: Anne Person ... To: Bart Person ... Subject: A test ... Message-ID: ... ... Hi Bart, this is Anne. ... """) {} ...and a second message... >>> client.sendmail('cperson@example.com', ['dperson@example.com'], """\ ... From: Cate Person ... To: Dave Person ... Subject: A test ... Message-ID: ... ... Hi Dave, this is Cate. ... """) {} ...and a third message. >>> client.sendmail('eperson@example.com', ['fperson@example.com'], """\ ... From: Elle Person ... To: Fred Person ... Subject: A test ... Message-ID: ... ... Hi Fred, this is Elle. ... """) {} We open up the mailbox again, and all three messages are waiting for us. >>> from mailbox import Maildir >>> from operator import itemgetter >>> mailbox = Maildir(maildir_path) >>> messages = sorted(mailbox, key=itemgetter('message-id')) >>> for message in messages: ... print(message['Message-ID'], message['From'], message['To']) Anne Person Bart Person Cate Person Dave Person Elle Person Fred Person .. _ArgumentParser: https://docs.python.org/3/library/argparse.html#argumentparser-objects .. _`email.message.Message`: https://docs.python.org/3/library/email.compat32-message.html#email.message.Message .. _Maildir: https://docs.python.org/3/library/mailbox.html#maildir aiosmtpd-1.1/aiosmtpd/docs/lmtp.rst0000664000175000017500000000116613106107202017727 0ustar barrybarry00000000000000.. _LMTP: ================ The LMTP class ================ `RFC 2033 `_ defines the Local Mail Transport Protocol. In many ways, this is very similar to SMTP, but with no guarantees of queuing. It is, in a sense, an alternative to ESMTP, and is often used for local mail routing (e.g. from a Mail Transport Agent to a local command or system) where the unreliability of internet connectivity is not an issue. The ``LMTP`` class subclasses the ``SMTP`` class and its only functional difference is that it implements the ``LHLO`` command, and prohibits the use of ``HELO`` and ``EHLO``. aiosmtpd-1.1/aiosmtpd/docs/smtp.rst0000664000175000017500000001640213127275524017755 0ustar barrybarry00000000000000.. _smtp: ================ The SMTP class ================ At the heart of this module is the ``SMTP`` class. This class implements the `RFC 5321 `_ Simple Mail Transport Protocol. Often you won't run an ``SMTP`` instance directly, but instead will use a :ref:`controller ` instance to run the server in a subthread. >>> from aiosmtpd.controller import Controller The ``SMTP`` class is itself a subclass of StreamReaderProtocol_. .. _subclass: Subclassing =========== While behavior for common SMTP commands can be specified using :ref:`handlers `, more complex specializations such as adding custom SMTP commands require subclassing the ``SMTP`` class. For example, let's say you wanted to add a new SMTP command called ``PING``. All methods implementing ``SMTP`` commands are prefixed with ``smtp_``; they must also be coroutines. Here's how you could implement this use case:: >>> import asyncio >>> from aiosmtpd.smtp import SMTP as Server, syntax >>> class MyServer(Server): ... @syntax('PING [ignored]') ... async def smtp_PING(self, arg): ... await self.push('259 Pong') Now let's run this server in a controller:: >>> from aiosmtpd.handlers import Sink >>> class MyController(Controller): ... def factory(self): ... return MyServer(self.handler) >>> controller = MyController(Sink()) >>> controller.start() .. >>> # Arrange for the controller to be stopped at the end of this doctest. >>> ignore = resources.callback(controller.stop) We can now connect to this server with an ``SMTP`` client. >>> from smtplib import SMTP as Client >>> client = Client(controller.hostname, controller.port) Let's ping the server. Since the ``PING`` command isn't an official ``SMTP`` command, we have to use the lower level interface to talk to it. >>> code, message = client.docmd('PING') >>> code 259 >>> message b'Pong' Because we prefixed the ``smtp_PING()`` method with the ``@syntax()`` decorator, the command shows up in the ``HELP`` output. >>> print(client.help().decode('utf-8')) Supported commands: DATA EHLO HELO HELP MAIL NOOP PING QUIT RCPT RSET VRFY And we can get more detailed help on the new command. >>> print(client.help('PING').decode('utf-8')) Syntax: PING [ignored] Server hooks ============ .. warning:: These methods are deprecated. See :ref:`handler hooks ` instead. The ``SMTP`` server class also implements some hooks which your subclass can override to provide additional responses. ``ehlo_hook()`` This hook makes it possible for subclasses to return additional ``EHLO`` responses. This method, called *asynchronously* and taking no arguments, can do whatever it wants, including (most commonly) pushing new ``250-`` responses to the client. This hook is called just before the standard ``250 HELP`` which ends the ``EHLO`` response from the server. ``rset_hook()`` This hook makes it possible to return additional ``RSET`` responses. This method, called *asynchronously* and taking no arguments, is called just before the standard ``250 OK`` which ends the ``RSET`` response from the server. SMTP API ======== .. class:: SMTP(handler, *, data_size_limit=33554432, enable_SMTPUTF8=False, decode_data=False, hostname=None, tls_context=None, require_starttls=False, loop=None) *handler* is an instance of a :ref:`handler ` class. *data_size_limit* is the limit in number of bytes that is accepted for client SMTP commands. It is returned to ESMTP clients in the ``250-SIZE`` response. The default is 33554432. *enable_SMTPUTF8* is a flag that when True causes the ESMTP ``SMTPUTF8`` option to be returned to the client, and allows for UTF-8 content to be accepted. The default is False. *decode_data* is a flag that when True, attempts to decode byte content in the ``DATA`` command, assigning the string value to the :ref:`envelope's ` ``content`` attribute. The default is False. *hostname* is the string returned in the ``220`` greeting response given to clients when they first connect to the server. If not given, the system's fully-qualified domain name is used. *tls_context* and *require_starttls* are related to the ESMTP ``STARTTLS`` command for secure connections to the server, based on `RFC 3207`_. *tls_context* is used as the SSL protocol context, and there is no default. *tls_context* must be given and *require_starttls* must be True for ``STARTTLS`` to be supported. *loop* is the asyncio event loop to use. If not given, :meth:`asyncio.new_event_loop()` is called to create the event loop. .. attribute:: event_handler The *handler* instance passed into the constructor. .. attribute:: data_size_limit The value of the *data_size_limit* argument passed into the constructor. .. attribute:: enable_SMTPUTF8 The value of the *enable_SMTPUTF8* argument passed into the constructor. .. attribute:: hostname The ``220`` greeting hostname. This will either be the value of the *hostname* argument passed into the constructor, or the system's fully qualified host name. .. attribute:: tls_context The value of the *tls_context* argument passed into the constructor. .. attribute:: require_starttls True if both the *tls_context* argument to the constructor was given **and** the *require_starttls* flag was True. .. attribute:: session The active :ref:`session ` object, if there is one, otherwise None. .. attribute:: envelope The active :ref:`envelope ` object, if there is one, otherwise None. .. attribute:: transport The active `asyncio transport`_ if there is one, otherwise None. .. attribute:: loop The event loop being used. This will either be the given *loop* argument, or the new event loop that was created. .. method:: _create_session() A method subclasses can override to return custom ``Session`` instances. .. method:: _create_envelope() A method subclasses can override to return custom ``Envelope`` instances. .. method:: push(status) The method that subclasses and handlers should use to return statuses to SMTP clients. This is a coroutine. *status* can be a bytes object, but for convenience it is more likely to be a string. If it's a string, it must be ASCII, unless *enable_SMTPUTF8* is True in which case it will be encoded as UTF-8. .. method:: smtp_(arg) Coroutine methods implementing the SMTP protocol commands. For example, ``smtp_HELO()`` implements the SMTP ``HELO`` command. Subclasses can override these, or add new command methods to implement custom extensions to the SMTP protocol. *arg* is the rest of the SMTP command given by the client, or None if nothing but the command was given. .. _StreamReaderProtocol: https://docs.python.org/3/library/asyncio-stream.html#streamreaderprotocol .. _`RFC 3207`: http://www.faqs.org/rfcs/rfc3207.html .. _`asyncio transport`: https://docs.python.org/3/library/asyncio-protocol.html#asyncio-transport aiosmtpd-1.1/aiosmtpd/docs/cli.rst0000664000175000017500000000532013106107202017516 0ustar barrybarry00000000000000.. _cli: ==================== Command line usage ==================== ``aiosmtpd`` provides a main entry point which can be used to run the server on the command line. There are two ways to run the server, depending on how the package has been installed. You can run the server by passing it to Python directly:: $ python3 -m aiosmtpd -n This starts a server on localhost, port 8025 without setting the uid to 'nobody' (i.e. because you aren't running it as root). Once you've done that, you can connect directly to the server using your favorite command line protocol tool. Type the ``QUIT`` command at the server once you see the greeting:: % telnet localhost 8025 Trying 127.0.0.1... Connected to localhost. Escape character is '^]'. 220 subdivisions Python SMTP ... QUIT 221 Bye Connection closed by foreign host. Of course, you could use Python's smtplib_ module, or any other SMTP client to talk to the server. Hit control-C at the server to stop it. The entry point may also be installed as the ``aiosmtpd`` command, so this is equivalent to the above ``python3`` invocation:: $ aiosmtpd -n Options ======= Optional arguments include: ``-h``, ``--help`` Show this help message and exit. ``-n``, ``--nosetuid`` This program generally tries to setuid ``nobody``, unless this flag is set. The setuid call will fail if this program is not run as root (in which case, use this flag). ``-c CLASSPATH``, ``--class CLASSPATH`` Use the given class, as a Python dotted import path, as the :ref:`handler class ` for SMTP events. This class can process received messages and do other actions during the SMTP dialog. Uses a debugging handler by default. ``-s SIZE``, ``--size SIZE`` Restrict the total size of the incoming message to ``SIZE`` number of bytes via the `RFC 1870`_ ``SIZE`` extension. Defaults to 33554432 bytes. ``-u``, ``--smtputf8`` Enable the SMTPUTF8 extension and behave as an `RFC 6531`_ SMTP proxy. ``-d``, ``--debug`` Increase debugging output. ``-l [HOST:PORT]``, ``--listen [HOST:PORT]`` Optional host and port to listen on. If the ``PORT`` part is not given, then port 8025 is used. If only ``:PORT`` is given, then ``localhost`` is used for the host name. If neither are given, ``localhost:8025`` is used. Optional positional arguments provide additional arguments to the handler class constructor named in the ``--class`` option. Provide as many of these as supported by the handler class's ``from_cli()`` class method, if provided. .. _smtplib: https://docs.python.org/3/library/smtplib.html .. _`RFC 1870`: http://www.faqs.org/rfcs/rfc1870.html .. _`RFC 6531`: http://www.faqs.org/rfcs/rfc6531.html aiosmtpd-1.1/aiosmtpd/docs/concepts.rst0000664000175000017500000001120113106107202020560 0ustar barrybarry00000000000000========== Concepts ========== There are two general ways you can run the SMTP server, via the :ref:`command line ` or :ref:`programmatically `. There are several dimensions in which you can extend the basic functionality of the SMTP server. You can implement an *event handler* which uses well defined :ref:`handler hooks ` that are called during the various steps in the SMTP dialog. If such a hook is implemented, it assumes responsibility for the status messages returned to the client. You can also :ref:`subclass ` the core ``SMTP`` class to implement new commands, or change the semantics of existing commands. For example, if you wanted to print the received message on the console, you could implement a handler that hooks into the ``DATA`` command. The contents of the message will be available on one of the hook's arguments, and your handler could print this content to stdout. On the other hand, if you wanted to implement an SMTP-like server that adds a new command called ``PING``, you would do this by subclassing ``SMTP``, adding a method that implements whatever semantics for ``PING`` that you want. .. _sessions_and_envelopes: Sessions and envelopes ====================== Two classes are used during the SMTP dialog with clients. Instances of these are passed to the handler hooks. Session ------- The session represents the state built up during a client's socket connection to the server. Each time a client connects to the server, a new session object is created. .. class:: Session() .. attribute:: peer Defaulting to None, this attribute will contain the transport's socket's peername_ value. .. attribute:: ssl Defaulting to None, this attribute will contain some extra information, as a dictionary, from the ``asyncio.sslproto.SSLProtocol`` instance. This dictionary provides additional information about the connection. It contains implementation-specific information so its contents may change, but it should roughly correspond to the information available through `this method`_. .. attribute:: host_name Defaulting to None, this attribute will contain the host name argument as seen in the ``HELO`` or ``EHLO`` (or for :ref:`LMTP `, the ``LHLO``) command. .. attribute:: extended_smtp Defaulting to False, this flag will be True when the ``EHLO`` greeting was seen, indicating ESMTP_. .. attribute:: loop This is the asyncio event loop instance. Envelope -------- The envelope represents state built up during the client's SMTP dialog. Each time the protocol state is reset, a new envelope is created. E.g. when the SMTP ``RSET`` command is sent, the state is reset and a new envelope is created. A new envelope is also created after the ``DATA`` command is completed, or in certain error conditions as mandated by `RFC 5321`_. .. class:: Envelope .. attribute:: mail_from Defaulting to None, this attribute holds the email address given in the ``MAIL FROM`` command. .. attribute:: mail_options Defaulting to None, this attribute contains a list of any ESMTP mail options provided by the client, such as those passed in by `the smtplib client`_. .. attribute:: content Defaulting to None, this attribute will contain the contents of the message as provided by the ``DATA`` command. If the ``decode_data`` parameter to the ``SMTP`` constructor was True, then this attribute will contain the UTF-8 decoded string, otherwise it will contain the raw bytes. .. attribute:: original_content Defaulting to None, this attribute will contain the contents of the message as provided by the ``DATA`` command. Unlike the ``content`` attribute, this attribute will always contain the raw bytes. .. attribute:: rcpt_tos Defaulting to the empty list, this attribute will contain a list of the email addresses provided in the ``RCPT TO`` commands. .. attribute:: rcpt_options Defaulting to the empty list, this attribute will contain the list of any recipient options provided by the client, such as those passed in by `the smtplib client`_. .. _peername: https://docs.python.org/3/library/asyncio-protocol.html?highlight=peername#asyncio.BaseTransport.get_extra_info .. _`this method`: https://docs.python.org/3/library/asyncio-protocol.html?highlight=get_extra_info#asyncio.BaseTransport.get_extra_info .. _ESMTP: http://www.faqs.org/rfcs/rfc1869.html .. _`the smtplib client`: https://docs.python.org/3/library/smtplib.html#smtplib.SMTP.sendmail .. _`RFC 5321`: http://www.faqs.org/rfcs/rfc5321.html aiosmtpd-1.1/aiosmtpd/docs/__init__.py0000664000175000017500000000000013015130134020313 0ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd/docs/intro.rst0000664000175000017500000000345113106107202020105 0ustar barrybarry00000000000000============== Introduction ============== This library provides an `asyncio `__ based implementation of a server for `RFC 5321 `__ - Simple Mail Transfer Protocol (SMTP) and `RFC 2033 `__ - Local Mail Transfer Protocol (LMTP). It is derived from `Python 3's smtpd.py `__ standard library module, and provides both a command line interface and an API for use in testing applications that send email. Inspiration for this library comes from several other packages: * `lazr.smtptest `__ * `benjamin-bader/aiosmtp `__ * `Mailman 3's LMTP server `__ ``aiosmtpd`` takes the best of these and consolidates them in one place. Relevant RFCs ============= * `RFC 5321 `__ - Simple Mail Transfer Protocol (SMTP) * `RFC 2033 `__ - Local Mail Transfer Protocol (LMTP) * `RFC 2034 `__ - SMTP Service Extension for Returning Enhanced Error Codes * `RFC 6531 `__ - SMTP Extension for Internationalized Email Other references ================ * `Wikipedia page on SMTP `__ * `asyncio module documentation `__ * `Developing with asyncio `__ * `Python issue #25508 `__ which started the whole thing. aiosmtpd-1.1/aiosmtpd/docs/manpage.rst0000664000175000017500000000352613127275524020405 0ustar barrybarry00000000000000========== aiosmtpd ========== ----------------------------------------------------- Provide a Simple Mail Transfer Procotol (SMTP) server ----------------------------------------------------- :Author: The aiosmtpd developers :Date: 2017-07-01 :Copyright: 2015-2017 The aiosmtpd developrs :Version: 1.1 :Manual section: 1 SYNOPSIS ======== aiosmtpd [options] Description =========== This program provides an RFC 5321 compliant SMTP server that supports customizable extensions. OPTIONS ======= -h, --help Show this help message and exit -v, --version Show program's version number and exit. -n, --nosetuid This program generally tries to setuid ``nobody``, unless this flag is set. The setuid call will fail if this program is not run as root (in which case, use this flag). -c CLASSPATH, --class CLASSPATH Use the given class (as a Python dotted import path) as the handler class for SMTP events. This class can process received messages and do other actions during the SMTP dialog. If not give, this uses a debugging handler by default. When given all remaining positional arguments are passed as arguments to the class's ``@classmethod from_cli()`` method, which should do any appropriate type conversion, and then return an instance of the handler class. -s SIZE, --size SIZE Restrict the total size of the incoming message to SIZE number of bytes via the RFC 1870 SIZE extension. Defaults to 33554432 bytes. -u, --smtputf8 Enable the SMTPUTF8 extension and behave as an RFC 6531 SMTP proxy. -d, --debug Increase debugging output. -l [HOST:PORT], --listen [HOST:PORT] Optional host and port to listen on. If the PORT part is not given, then port 8025 is used. If only :PORT is given, then localhost is used for the hostname. If neither are given, localhost:8025 is used. aiosmtpd-1.1/aiosmtpd/docs/NEWS.rst0000664000175000017500000001342513127444571017550 0ustar barrybarry00000000000000=================== NEWS for aiosmtpd =================== 1.1 (2017-07-06) ================ * Drop support for Python 3.4. * As per RFC 5321, ยง4.1.4, multiple ``HELO`` / ``EHLO`` commands in the same session are semantically equivalent to ``RSET``. (Closes #78) * As per RFC 5321, $4.1.1.9, ``NOOP`` takes an optional argument, which is ignored. **API BREAK** If you have a handler that implements ``handle_NOOP()``, it previously took zero arguments but now requires a single argument. (Closes #107) * The command line options ``--version`` / ``-v`` has been added to print the package's current version number. (Closes #111) * General improvements in the ``Controller`` class. (Closes #104) * When aiosmtpd handles a ``STARTTLS`` it must arrange for the original transport to be closed when the wrapped transport is closed. This fixes a hidden exception which occurs when an EOF is received on the original tranport after the connection is lost. (Closes #83) * Widen the catch of ``ConnectionResetError`` and ``CancelledError`` to also catch such errors from handler methods. (Closes #110) * Added a manpage for the ``aiosmtpd`` command line script. (Closes #116) * Added much better support for the ``HELP``. There's a new decorator called ``@syntax()`` which you can use in derived classes to decorate ``smtp_*()`` methods. These then show up in ``HELP`` responses. This also fixes ``HELP`` responses for the ``LMTP`` subclass. (Closes #113) * The ``Controller`` class now takes an optional keyword argument ``ssl_context`` which is passed directly to the asyncio ``create_server()`` call. 1.0 (2017-05-15) ================ * Release. 1.0rc1 (2017-05-12) =================== * Improved documentation. 1.0b1 (2017-05-07) ================== * The connection peer is displayed in all INFO level logging. * When running the test suite, you can include a ``-E`` option after the ``--`` separator to boost the debugging output. * The main SMTP readline loops are now more robust against connection resets and mid-read EOFs. (Closes #62) * ``Proxy`` handlers work with ``SMTP`` servers regardless of the value of the ``decode_data`` argument. * The command line script is now installed as ``aiosmtpd`` instead of ``smtpd``. * The ``SMTP`` class now does a better job of handling Unicode, when the client does not claim to support ``SMTPUTF8`` but sends non-ASCII anyway. The server forces ASCII-only handling when ``enable_SMTPUTF8=False`` (the default) is passed to the constructor. The command line arguments ``decode_data=True`` and ``enable_SMTPUTF8=True`` are no longer mutually exclusive. * Officially support Windows. (Closes #76) 1.0a5 (2017-04-06) ================== * A new handler hook API has been added which provides more flexibility but requires more responsibility (e.g. hooks must return a string status). Deprecate ``SMTP.ehlo_hook()`` and ``SMTP.rset_hook()``. * Deprecate handler ``process_message()`` methods. Use the new asynchronous ``handle_DATA()`` methods, which take a session and an envelope object. * Added the ``STARTTLS`` extension. Given by Konstantin Volkov. * Minor changes to the way the ``Debugging`` handler prints ``mail_options`` and ``rcpt_options`` (although the latter is still not support in ``SMTP``). * ``DATA`` method now respects original line endings, and passing size limits is now handled better. Given by Konstantin Volkov. * The ``Controller`` class has two new optional keyword arguments. - ``ready_timeout`` specifies a timeout in seconds that can be used to limit the amount of time it waits for the server to become ready. This can also be overridden with the environment variable ``AIOSMTPD_CONTROLLER_TIMEOUT``. (Closes #35) - ``enable_SMTPUTF8`` is passed through to the ``SMTP`` constructor in the default factory. If you override ``Controller.factory()`` you can pass ``self.enable_SMTPUTF8`` yourself. * Handlers can define a ``handle_tls_handshake()`` method, which takes a session object, and is called if SSL is enabled during the making of the connection. (Closes #48) * Better Windows compatibility. * Better Python 3.4 compatibility. * Use ``flufl.testing`` package for nose2 and flake8 plugins. * The test suite has achieved 100% code coverage. (Closes #2) 1.0a4 (2016-11-29) ================== * The SMTP server connection identifier can be changed by setting the ``__ident__`` attribute on the ``SMTP`` instance. (Closes #20) * Fixed a new incompatibility with the ``atpublic`` library. 1.0a3 (2016-11-24) ================== * Fix typo in ``Message.prepare_message()`` handler. The crafted ``X-RcptTos`` header is renamed to ``X-RcptTo`` for backward compatibility with older libraries. * Add a few hooks to make subclassing easier: * ``SMTP.ehlo_hook()`` is called just before the final, non-continuing 250 response to allow subclasses to add additional ``EHLO`` sub-responses. * ``SMTP.rset_hook()`` is called just before the final 250 command to allow subclasses to provide additional ``RSET`` functionality. * ``Controller.make_socket()`` allows subclasses to customize the creation of the socket before binding. 1.0a2 (2016-11-22) ================== * Officially support Python 3.6. * Fix support for both IPv4 and IPv6 based on the ``--listen`` option. Given by Jason Coombs. (Closes #3) * Correctly handle client disconnects. Given by Konstantin vz'One Enchant. * The SMTP class now takes an optional ``hostname`` argument. Use this if you want to avoid the use of ``socket.getfqdn()``. Given by Konstantin vz'One Enchant. * Close the transport and thus the connection on SMTP ``QUIT``. (Closes #11) * Added an ``AsyncMessage`` handler. Given by Konstantin vz'One Enchant. * Add an examples/ directory. * Flake8 clean. 1.0a1 (2015-10-19) ================== * Initial release. aiosmtpd-1.1/aiosmtpd/docs/migrating.rst0000664000175000017500000000414113114056512020736 0ustar barrybarry00000000000000.. _migrating: ================================== Migrating from smtpd to aiosmtpd ================================== aiosmtpd is designed to make it easy to migrate an existing application based on `smtpd `__ to aiosmtpd. Consider the following subclass of ``smtpd.SMTPServer``:: import smtpd import asyncore class CustomSMTPServer(smtpd.SMTPServer): def process_message(self, peer, mail_from, rcpt_tos, data): # Process message data... if error_occurred: return '500 Could not process your message' if __name__ == '__main__': server = CustomSMTPServer(('127.0.0.1', 10025), None) # Run the event loop in the current thread. asyncore.loop() To switch this application to using ``aiosmtpd``, implement a handler with the ``handle_DATA()`` method:: import asyncio from aiosmtpd.controller import Controller class CustomHandler: async def handle_DATA(self, server, session, envelope): peer = session.peer mail_from = envelope.mail_from rcpt_tos = envelope.rcpt_tos data = envelope.content # type: bytes # Process message data... if error_occurred: return '500 Could not process your message' return '250 OK' if __name__ == '__main__': handler = CustomHandler() controller = Controller(handler, hostname='127.0.0.1', port=10025) # Run the event loop in a separate thread. controller.start() # Wait for the user to press Return. input('SMTP server running. Press Return to stop server and exit.') controller.stop() Important differences to note: * Unlike ``process_message()`` in smtpd, ``handle_DATA()`` **must** return an SMTP response code for the sender such as ``"250 OK"``. * ``handle_DATA()`` must be a coroutine function, which means it must be declared with ``async def``. * ``controller.start()`` runs the SMTP server in a separate thread and can be stopped again by calling ``controller.stop()``. aiosmtpd-1.1/aiosmtpd/docs/controller.rst0000664000175000017500000002155513127275524021162 0ustar barrybarry00000000000000.. _controller: ==================== Programmatic usage ==================== If you already have an `asyncio event loop`_, you can `create a server`_ using the ``SMTP`` class as the *protocol factory*, and then run the loop forever. If you need to pass arguments to the ``SMTP`` constructor, use `functools.partial()`_ or write your own wrapper function. You might also want to add a signal handler so that the loop can be stopped, say when you hit control-C. It's probably easier to use a *controller* which runs the SMTP server in a separate thread with a dedicated event loop. The controller provides useful and reliable *start* and *stop* semantics so that the foreground thread doesn't block. Among other use cases, this makes it convenient to spin up an SMTP server for unit tests. In both cases, you need to pass a :ref:`handler ` to the ``SMTP`` constructor. Handlers respond to events that you care about during the SMTP dialog. Using the controller ==================== Say you want to receive email for ``example.com`` and print incoming mail data to the console. Start by implementing a handler as follows:: >>> import asyncio >>> class ExampleHandler: ... async def handle_RCPT(self, server, session, envelope, address, rcpt_options): ... if not address.endswith('@example.com'): ... return '550 not relaying to that domain' ... envelope.rcpt_tos.append(address) ... return '250 OK' ... ... async def handle_DATA(self, server, session, envelope): ... print('Message from %s' % envelope.mail_from) ... print('Message for %s' % envelope.rcpt_tos) ... print('Message data:\n') ... print(envelope.content.decode('utf8', errors='replace')) ... print('End of message') ... return '250 Message accepted for delivery' Pass an instance of your ``ExampleHandler`` class to the ``Controller``, and then start it:: >>> from aiosmtpd.controller import Controller >>> controller = Controller(ExampleHandler()) >>> controller.start() The SMTP thread might run into errors during its setup phase; to catch this the main thread will timeout when waiting for the SMTP server to become ready. By default the timeout is set to 1 second but can be changed either by using the ``AIOSMTPD_CONTROLLER_TIMEOUT`` environment variable or by passing a different ``ready_timeout`` duration to the Controller's constructor. Connect to the server and send a message, which then gets printed by ``ExampleHandler``:: >>> from smtplib import SMTP as Client >>> client = Client(controller.hostname, controller.port) >>> r = client.sendmail('a@example.com', ['b@example.com'], """\ ... From: Anne Person ... To: Bart Person ... Subject: A test ... Message-ID: ... ... Hi Bart, this is Anne. ... """) Message from a@example.com Message for ['b@example.com'] Message data: From: Anne Person To: Bart Person Subject: A test Message-ID: Hi Bart, this is Anne. End of message You'll notice that at the end of the ``DATA`` command, your handler's ``handle_DATA()`` method was called. The sender, recipients, and message contents were taken from the envelope, and printed at the console. The handler methods also returns a successful status message. The ``ExampleHandler`` class also implements a ``handle_RCPT()`` method. This gets called after the ``RCPT TO`` command is sanity checked. The method ensures that all recipients are local to the ``@example.com`` domain, returning an error status if not. It is the handler's responsibility to add valid recipients to the ``rcpt_tos`` attribute of the envelope and to return a successful status. Thus, if we try to send a message to a recipient not inside ``example.com``, it is rejected:: >>> client.sendmail('aperson@example.com', ['cperson@example.net'], """\ ... From: Anne Person ... To: Chris Person ... Subject: Another test ... Message-ID: ... ... Hi Chris, this is Anne. ... """) Traceback (most recent call last): ... smtplib.SMTPRecipientsRefused: {'cperson@example.net': (550, b'not relaying to that domain')} When you're done with the SMTP server, stop it via the controller. >>> controller.stop() The server is guaranteed to be stopped. >>> client.connect(controller.hostname, controller.port) Traceback (most recent call last): ... ConnectionRefusedError: ... There are a number of built-in :ref:`handler classes ` that you can use to do some common tasks, and it's easy to write your own handler. For a full overview of the methods that handler classes may implement, see the section on :ref:`handler hooks `. Enabling SMTPUTF8 ================= It's very common to want to enable the ``SMTPUTF8`` ESMTP option, therefore this is the default for the ``Controller`` constructor. For backward compatibility reasons, this is *not* the default for the ``SMTP`` class though. If you want to disable this in the ``Controller``, you can pass this argument into the constructor:: >>> from aiosmtpd.handlers import Sink >>> controller = Controller(Sink(), enable_SMTPUTF8=False) >>> controller.start() >>> client = Client(controller.hostname, controller.port) >>> code, message = client.ehlo('me') >>> code 250 The EHLO response does not include the ``SMTPUTF8`` ESMTP option. >>> lines = message.decode('utf-8').splitlines() >>> # Don't print the server host name line, since that's variable. >>> for line in lines[1:]: ... print(line) SIZE 33554432 8BITMIME HELP >>> controller.stop() Controller API ============== .. class:: Controller(handler, loop=None, hostname=None, port=8025, *, ready_timeout=1.0, enable_SMTPUTF8=True, ssl_context=None) *handler* is an instance of a :ref:`handler ` class. *loop* is the asyncio event loop to use. If not given, :meth:`asyncio.new_event_loop()` is called to create the event loop. *hostname* and *port* are passed directly to your loop's :meth:`AbstractEventLoop.create_server` method. *ready_timeout* is float number of seconds that the controller will wait in :meth:`Controller.start` for the subthread to start its server. You can also set the :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` environment variable to a float number of seconds, which takes precedence over the *ready_timeout* argument value. *enable_SMTPUTF8* is a flag which is passed directly to the same named argument to the ``SMTP`` constructor. When True, the ESMTP ``SMTPUTF8`` option is returned to the client in response to ``EHLO``, and UTF-8 content is accepted. *ssl_context* is a ``SSLContext`` that will be used by the loops server and is passed directly to :meth:`AbstractEventLoop.create_server` method. .. attribute:: handler The instance of the event *handler* passed to the constructor. .. attribute:: loop The event loop being used. This will either be the given *loop* argument, or the new event loop that was created. .. attribute:: hostname port The values of the *hostname* and *port* arguments. .. attribute:: ready_timeout The timeout value used to wait for the server to start. This will either be the float value converted from the :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` environment variable, or the *ready_timeout* argument. .. attribute:: server This is the server instance returned by :meth:`AbstractEventLoop.create_server` after the server has started. .. method:: start() Start the server in the subthread. The subthread is always a daemon thread (i.e. we always set ``thread.daemon=True``. Exceptions can be raised if the server does not start within the *ready_timeout*, or if any other exception occurs in while creating the server. .. method:: stop() Stop the server and the event loop, and cancel all tasks. .. method:: factory() You can override this method to create custom instances of the ``SMTP`` class being controlled. By default, this creates an ``SMTP`` instance, passing in your handler and setting the ``enable_SMTPUTF8`` flag. Examples of why you would want to override this method include creating an ``LMTP`` server instance instead, or passing in a different set of arguments to the ``SMTP`` constructor. .. _`asyncio event loop`: https://docs.python.org/3/library/asyncio-eventloop.html .. _`create a server`: https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.AbstractEventLoop.create_server .. _`functools.partial()`: https://docs.python.org/3/library/functools.html#functools.partial aiosmtpd-1.1/setup.cfg0000664000175000017500000000021013127451604015272 0ustar barrybarry00000000000000[easy_install] zip_ok = false [nosetests] nocapture = 1 cover-package = aiosmtp cover-erase = 1 [egg_info] tag_build = tag_date = 0 aiosmtpd-1.1/MANIFEST.in0000664000175000017500000000017013127275524015221 0ustar barrybarry00000000000000include *.py MANIFEST.in global-include *.txt *.rst *.ini *.yml *.cfg *.crt *.key global-exclude .gitignore prune build aiosmtpd-1.1/aiosmtpd.egg-info/0000775000175000017500000000000013127451604016772 5ustar barrybarry00000000000000aiosmtpd-1.1/aiosmtpd.egg-info/SOURCES.txt0000664000175000017500000000223713127451604020662 0ustar barrybarry00000000000000.appveyor.yml .coverage.ini .travis.yml MANIFEST.in README.rst conf.py setup.cfg setup.py setup_helpers.py tox.ini unittest.cfg aiosmtpd/__init__.py aiosmtpd/__main__.py aiosmtpd/controller.py aiosmtpd/handlers.py aiosmtpd/lmtp.py aiosmtpd/main.py aiosmtpd/smtp.py aiosmtpd.egg-info/PKG-INFO aiosmtpd.egg-info/SOURCES.txt aiosmtpd.egg-info/dependency_links.txt aiosmtpd.egg-info/entry_points.txt aiosmtpd.egg-info/requires.txt aiosmtpd.egg-info/top_level.txt aiosmtpd/docs/NEWS.rst aiosmtpd/docs/__init__.py aiosmtpd/docs/cli.rst aiosmtpd/docs/concepts.rst aiosmtpd/docs/controller.rst aiosmtpd/docs/handlers.rst aiosmtpd/docs/intro.rst aiosmtpd/docs/lmtp.rst aiosmtpd/docs/manpage.rst aiosmtpd/docs/migrating.rst aiosmtpd/docs/smtp.rst aiosmtpd/testing/__init__.py aiosmtpd/testing/helpers.py aiosmtpd/tests/__init__.py aiosmtpd/tests/test_handlers.py aiosmtpd/tests/test_lmtp.py aiosmtpd/tests/test_main.py aiosmtpd/tests/test_server.py aiosmtpd/tests/test_smtp.py aiosmtpd/tests/test_smtps.py aiosmtpd/tests/test_starttls.py aiosmtpd/tests/certs/__init__.py aiosmtpd/tests/certs/server.crt aiosmtpd/tests/certs/server.key examples/__init__.py examples/client.py examples/server.pyaiosmtpd-1.1/aiosmtpd.egg-info/PKG-INFO0000664000175000017500000000125513127451603020071 0ustar barrybarry00000000000000Metadata-Version: 1.1 Name: aiosmtpd Version: 1.1 Summary: aiosmtpd - asyncio based SMTP server Home-page: http://aiosmtpd.readthedocs.io/ Author: UNKNOWN Author-email: UNKNOWN License: http://www.apache.org/licenses/LICENSE-2.0 Description: This is a server for SMTP and related protocols, similar in utility to the standard library's smtpd.py module, but rewritten to be based on asyncio for Python 3. Keywords: email Platform: UNKNOWN Classifier: License :: OSI Approved Classifier: Intended Audience :: Developers Classifier: Programming Language :: Python :: 3 Classifier: Topic :: Communications :: Email :: Mail Transport Agents Classifier: Framework :: AsyncIO aiosmtpd-1.1/aiosmtpd.egg-info/entry_points.txt0000664000175000017500000000006113127451603022264 0ustar barrybarry00000000000000[console_scripts] aiosmtpd = aiosmtpd.main:main aiosmtpd-1.1/aiosmtpd.egg-info/requires.txt0000664000175000017500000000001113127451603021361 0ustar barrybarry00000000000000atpublic aiosmtpd-1.1/aiosmtpd.egg-info/dependency_links.txt0000664000175000017500000000000113127451603023037 0ustar barrybarry00000000000000 aiosmtpd-1.1/aiosmtpd.egg-info/top_level.txt0000664000175000017500000000002213127451603021515 0ustar barrybarry00000000000000aiosmtpd examples aiosmtpd-1.1/unittest.cfg0000664000175000017500000000041113101503533016003 0ustar barrybarry00000000000000[unittest] verbose = 2 plugins = flufl.testing.nose [log-capture] always-on = False [flufl.testing] always-on = True package = aiosmtpd setup = aiosmtpd.testing.helpers.setup teardown = aiosmtpd.testing.helpers.teardown start_run = aiosmtpd.testing.helpers.start aiosmtpd-1.1/tox.ini0000664000175000017500000000244613114056513014775 0ustar barrybarry00000000000000[tox] envlist = {py35,py36}-{cov,nocov,diffcov},qa,docs skip_missing_interpreters = True [testenv] commands = nocov: python -m nose2 -v {posargs} {cov,diffcov}: python -m coverage run {[coverage]rc} -m nose2 {cov,diffcov}: python -m coverage combine {[coverage]rc} cov: python -m coverage html {[coverage]rc} cov: python -m coverage report -m {[coverage]rc} --fail-under=100 diffcov: python -m coverage xml {[coverage]rc} diffcov: diff-cover coverage.xml --html-report diffcov.html diffcov: diff-cover coverage.xml --fail-under=100 #sitepackages = True usedevelop = True deps = nose2 flufl.testing {cov,diffcov}: coverage diffcov: diff_cover setenv = cov: COVERAGE_PROCESS_START={[coverage]rcfile} cov: COVERAGE_OPTIONS="-p" cov: COVERAGE_FILE={toxinidir}/.coverage py35: INTERP=py35 py36: INTERP=py36 PLATFORM={env:PLATFORM:linux} passenv = PYTHON* [coverage] rcfile = {toxinidir}/.coverage.ini rc = --rcfile={[coverage]rcfile} [testenv:qa] basepython = python3 commands = python -m flake8 aiosmtpd deps = flake8 flufl.testing [testenv:docs] basepython = python3 commands = python setup.py build_sphinx deps: sphinx [flake8] enable-extensions = U4 exclude = conf.py hang-closing = True jobs = 1 max-line-length = 79 aiosmtpd-1.1/README.rst0000664000175000017500000000634613127275524015165 0ustar barrybarry00000000000000========================================= aiosmtpd - An asyncio based SMTP server ========================================= The Python standard library includes a basic `SMTP `__ server in the `smtpd `__ module, based on the old asynchronous libraries `asyncore `__ and `asynchat `__. These modules are quite old and are definitely showing their age. asyncore and asynchat are difficult APIs to work with, understand, extend, and fix. With the introduction of the `asyncio `__ module in Python 3.4, a much better way of doing asynchronous I/O is now available. It seems obvious that an asyncio-based version of the SMTP and related protocols are needed for Python 3. This project brings together several highly experienced Python developers collaborating on this reimplementation. This package provides such an implementation of both the SMTP and LMTP protocols. Requirements ============ You need at least Python 3.5 to use this library. Both Windows and \*nix are supported. License ======= ``aiosmtpd`` is released under the Apache License version 2.0. Project details =============== As of 2016-07-14, aiosmtpd has been put under the `aio-libs `__ umbrella project and moved to GitHub. * Project home: https://github.com/aio-libs/aiosmtpd * Report bugs at: https://github.com/aio-libs/aiosmtpd/issues * Git clone: https://github.com/aio-libs/aiosmtpd.git * Documentation: http://aiosmtpd.readthedocs.io/ * StackOverflow: https://stackoverflow.com/questions/tagged/aiosmtpd The best way to contact the developers is through the GitHub links above. You can also request help by submitting a question on StackOverflow. Building ======== You can install this package in a virtual environment like so:: $ python3 -m venv /path/to/venv $ source /path/to/venv/bin/activate $ python setup.py install This will give you a command line script called ``smtpd`` which implements the SMTP server. Use ``smtpd --help`` for details. You will also have access to the ``aiosmtpd`` library, which you can use as a testing environment for your SMTP clients. See the documentation links above for details. Developing ========== You'll need the `tox `__ tool to run the test suite for Python 3. Once you've got that, run:: $ tox Individual tests can be run like this:: $ tox -e py35-nocov -- -P where ** is a Python regular expression matching a test name. You can also add the ``-E`` option to boost debugging output, e.g.:: $ tox -e py35-nocov -- -E and these options can be combined:: $ tox -e py35-nocov -- -P test_connection_reset_during_DATA -E Contents ======== .. toctree:: :maxdepth: 2 aiosmtpd/docs/intro aiosmtpd/docs/concepts aiosmtpd/docs/cli aiosmtpd/docs/controller aiosmtpd/docs/smtp aiosmtpd/docs/lmtp aiosmtpd/docs/handlers aiosmtpd/docs/migrating aiosmtpd/docs/manpage aiosmtpd/docs/NEWS Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` aiosmtpd-1.1/setup.py0000664000175000017500000000205213127446206015173 0ustar barrybarry00000000000000from setup_helpers import require_python, get_version from setuptools import setup, find_packages require_python(0x30400f0) __version__ = get_version('aiosmtpd/smtp.py') setup( name='aiosmtpd', version=__version__, description='aiosmtpd - asyncio based SMTP server', long_description="""\ This is a server for SMTP and related protocols, similar in utility to the standard library's smtpd.py module, but rewritten to be based on asyncio for Python 3.""", url='http://aiosmtpd.readthedocs.io/', keywords='email', packages=find_packages(), include_package_data=True, license='http://www.apache.org/licenses/LICENSE-2.0', install_requires=[ 'atpublic', ], entry_points={ 'console_scripts': ['aiosmtpd = aiosmtpd.main:main'], }, classifiers=[ 'License :: OSI Approved', 'Intended Audience :: Developers', 'Programming Language :: Python :: 3', 'Topic :: Communications :: Email :: Mail Transport Agents', 'Framework :: AsyncIO', ], ) aiosmtpd-1.1/.appveyor.yml0000664000175000017500000000114413127313376016131 0ustar barrybarry00000000000000environment: PYTHONASYNCIODEBUG: "1" PLATFORM: "mswin" matrix: # For Python versions available on Appveyor, see # http://www.appveyor.com/docs/installed-software#python - PYTHON: "C:\\Python35" INTERP: "py35" - PYTHON: "C:\\Python35-x64" INTERP: "py35" - PYTHON: "C:\\Python36-x64" INTERP: "py36" install: - "%PYTHON%\\python.exe -m pip install tox" - "%PYTHON%\\python.exe setup.py egg_info" - "%PYTHON%\\python.exe -m pip install -r aiosmtpd.egg-info/requires.txt" build: off test_script: - "%PYTHON%\\python.exe -m tox -e %INTERP%-nocov,%INTERP%-cov"