aiosmtpd-1.2/ 0000755 0001751 0001751 00000000000 13342506003 013462 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/setup.cfg 0000644 0001751 0001751 00000000235 13342506003 015303 0 ustar wayne wayne 0000000 0000000 [easy_install]
zip_ok = false
[nosetests]
nocapture = 1
cover-package = aiosmtp
cover-erase = 1
[egg_info]
tag_build =
tag_date = 0
tag_svn_revision = 0
aiosmtpd-1.2/examples/ 0000755 0001751 0001751 00000000000 13342506003 015300 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/examples/client.py 0000644 0001751 0001751 00000000304 13342502335 017131 0 ustar wayne wayne 0000000 0000000 from 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.2/examples/server.py 0000644 0001751 0001751 00000000661 13342502335 017167 0 ustar wayne wayne 0000000 0000000 import asyncio
import logging
from aiosmtpd.controller import Controller
from aiosmtpd.handlers import Sink
async def amain(loop):
cont = Controller(Sink(), hostname='', 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.2/examples/__init__.py 0000644 0001751 0001751 00000000000 13342502335 017403 0 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/MANIFEST.in 0000644 0001751 0001751 00000000170 13342502335 015222 0 ustar wayne wayne 0000000 0000000 include *.py MANIFEST.in
global-include *.txt *.rst *.ini *.yml *.cfg *.crt *.key
global-exclude .gitignore
prune build
aiosmtpd-1.2/.appveyor.yml 0000644 0001751 0001751 00000001144 13342502335 016134 0 ustar wayne wayne 0000000 0000000 environment:
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"
aiosmtpd-1.2/setup_helpers.py 0000644 0001751 0001751 00000011747 13342502335 016734 0 ustar wayne wayne 0000000 0000000 # 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.2/aiosmtpd.egg-info/ 0000755 0001751 0001751 00000000000 13342506003 016774 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd.egg-info/dependency_links.txt 0000644 0001751 0001751 00000000001 13342506003 023042 0 ustar wayne wayne 0000000 0000000
aiosmtpd-1.2/aiosmtpd.egg-info/top_level.txt 0000644 0001751 0001751 00000000022 13342506003 021520 0 ustar wayne wayne 0000000 0000000 aiosmtpd
examples
aiosmtpd-1.2/aiosmtpd.egg-info/requires.txt 0000644 0001751 0001751 00000000011 13342506003 021364 0 ustar wayne wayne 0000000 0000000 atpublic
aiosmtpd-1.2/aiosmtpd.egg-info/SOURCES.txt 0000644 0001751 0001751 00000002237 13342506003 020664 0 ustar wayne wayne 0000000 0000000 .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.py aiosmtpd-1.2/aiosmtpd.egg-info/entry_points.txt 0000644 0001751 0001751 00000000061 13342506003 022267 0 ustar wayne wayne 0000000 0000000 [console_scripts]
aiosmtpd = aiosmtpd.main:main
aiosmtpd-1.2/aiosmtpd.egg-info/PKG-INFO 0000644 0001751 0001751 00000001255 13342506003 020074 0 ustar wayne wayne 0000000 0000000 Metadata-Version: 1.1
Name: aiosmtpd
Version: 1.2
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.2/setup.py 0000644 0001751 0001751 00000002052 13342502335 015177 0 ustar wayne wayne 0000000 0000000 from 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.2/.travis.yml 0000644 0001751 0001751 00000001141 13342502335 015574 0 ustar wayne wayne 0000000 0000000 language: 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
before_script:
# Disable IPv6. Ref travis-ci/travis-ci#8711
- echo 0 | sudo tee /proc/sys/net/ipv6/conf/all/disable_ipv6
script:
- tox -e $INTERP-nocov,$INTERP-cov,qa,docs
- 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then tox -e $INTERP-diffcov; fi'
before_script:
- echo 0 | sudo tee /proc/sys/net/ipv6/conf/all/disable_ipv6
aiosmtpd-1.2/PKG-INFO 0000644 0001751 0001751 00000001255 13342506003 014562 0 ustar wayne wayne 0000000 0000000 Metadata-Version: 1.1
Name: aiosmtpd
Version: 1.2
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.2/README.rst 0000644 0001751 0001751 00000006346 13342502335 015166 0 ustar wayne wayne 0000000 0000000 =========================================
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.2/tox.ini 0000644 0001751 0001751 00000002446 13342502335 015007 0 ustar wayne wayne 0000000 0000000 [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.2/conf.py 0000644 0001751 0001751 00000021333 13342502335 014767 0 ustar wayne wayne 0000000 0000000 # -*- 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 = [
'sphinx.ext.intersphinx',
]
# 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
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
}
# -- 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.2/aiosmtpd/ 0000755 0001751 0001751 00000000000 13342506003 015302 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/main.py 0000644 0001751 0001751 00000012665 13342502335 016616 0 ustar wayne wayne 0000000 0000000 import 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.2/aiosmtpd/handlers.py 0000644 0001751 0001751 00000016061 13342502335 017464 0 ustar wayne wayne 0000000 0000000 """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.2/aiosmtpd/docs/ 0000755 0001751 0001751 00000000000 13342506003 016232 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/docs/migrating.rst 0000644 0001751 0001751 00000004141 13342502335 020751 0 ustar wayne wayne 0000000 0000000 .. _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.2/aiosmtpd/docs/handlers.rst 0000644 0001751 0001751 00000023247 13342502335 020600 0 ustar wayne wayne 0000000 0000000 .. _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.2/aiosmtpd/docs/manpage.rst 0000644 0001751 0001751 00000003527 13342502335 020407 0 ustar wayne wayne 0000000 0000000 ==========
aiosmtpd
==========
-----------------------------------------------------
Provide a Simple Mail Transfer Protocol (SMTP) server
-----------------------------------------------------
:Author: The aiosmtpd developers
:Date: 2017-07-01
:Copyright: 2015-2017 The aiosmtpd developers
: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.2/aiosmtpd/docs/cli.rst 0000644 0001751 0001751 00000005320 13342502335 017537 0 ustar wayne wayne 0000000 0000000 .. _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.2/aiosmtpd/docs/concepts.rst 0000644 0001751 0001751 00000011201 13342502335 020601 0 ustar wayne wayne 0000000 0000000 ==========
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.2/aiosmtpd/docs/NEWS.rst 0000644 0001751 0001751 00000014374 13342504663 017563 0 ustar wayne wayne 0000000 0000000 ===================
NEWS for aiosmtpd
===================
1.2 (2018-09-01)
================
* Improve the documentation on enabling ``STARTTLS``. (Closes #125)
* Add customizable ident field to SMTP class constructor. (Closes #131)
* Remove asyncio.coroutine decorator as it was introduced in Python 3.5.
* Add Controller docstring, explain dual-stack binding. (Closes #140)
* Gracefully handle ASCII decoding exceptions. (Closes #142)
* Fix typo.
* Improve Controller ssl_context documentation.
* Add timeout feature. (Partial fix for #145)
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.2/aiosmtpd/docs/__init__.py 0000644 0001751 0001751 00000000000 13342502335 020335 0 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/docs/intro.rst 0000644 0001751 0001751 00000003451 13342502335 020126 0 ustar wayne wayne 0000000 0000000 ==============
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.2/aiosmtpd/docs/lmtp.rst 0000644 0001751 0001751 00000001166 13342502335 017750 0 ustar wayne wayne 0000000 0000000 .. _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.2/aiosmtpd/docs/smtp.rst 0000644 0001751 0001751 00000023456 13342502335 017765 0 ustar wayne wayne 0000000 0000000 .. _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, ident=None, tls_context=None, require_starttls=False, timeout=300, 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 first part of 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.
*ident* is the second part of the string returned in the ``220`` greeting
response that identifies the software name and version of the SMTP server
to the client. If not given, a default Python SMTP ident is used.
*tls_context* and *require_starttls*. The ``STARTTLS`` option of ESMTP
(and LMTP), defined in `RFC 3207`_, provides for secure connections to the
server. For this option to be available, *tls_context* must be supplied,
and *require_starttls* should be ``True``. See :ref:`tls` for a more in
depth discussion on enabling ``STARTTLS``.
*timeout* is the number of seconds to wait between valid SMTP commands.
After this time the connection will be closed by the server. The default
is 300 seconds, as per `RFC 2821`_.
*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.
.. _tls:
Enabling STARTTLS
=================
To enable `RFC 3207`_ ``STARTTLS``, you must supply the *tls_context* argument
to the :class:`SMTP` class. *tls_context* is created with the
:meth:`ssl.create_default_context()` call from the ssl_ module, as follows::
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
The context must be initialized with a server certificate, private key, and/or
intermediate CA certificate chain with the
:meth:`ssl.SSLContext.load_cert_chain()` method. This can be done with
separate files, or an all in one file. Files must be in PEM format.
For example, if you wanted to use a self-signed certification for localhost,
which is easy to create but doesn't provide much security, you could use the
``openssl(1)`` command like so::
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
and then in Python::
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain('cert.pem', 'key.pem')
Now pass the ``context`` object to the *tls_context* argument in the ``SMTP``
constructor.
Note that a number of exceptions can be generated by these methods, and by SSL
connections, which you must be prepared to handle. Additional documentation
is available in Python's ssl_ module, and should be reviewed before use; in
particular if client authentication and/or advanced error handling is desired.
If *require_starttls* is ``True``, a TLS session must be initiated for the
server to respond to any commands other than ``EHLO``/``LHLO``, ``NOOP``,
``QUIT``, and ``STARTTLS``.
If *require_starttls* is ``False`` (the default), use of TLS is not required;
the client *may* upgrade the connection to TLS, or may use any supported
command over an insecure connection.
If *tls_context* is not supplied, the ``STARTTLS`` option will not be
advertised, and the ``STARTTLS`` command will not be accepted.
*require_starttls* is meaningless in this case, and should be set to
``False``.
.. _StreamReaderProtocol: https://docs.python.org/3/library/asyncio-stream.html#streamreaderprotocol
.. _`RFC 3207`: http://www.faqs.org/rfcs/rfc3207.html
.. _`RFC 2821`: https://www.ietf.org/rfc/rfc2821.txt
.. _`asyncio transport`: https://docs.python.org/3/library/asyncio-protocol.html#asyncio-transport
.. _ssl: https://docs.python.org/3/library/ssl.html
aiosmtpd-1.2/aiosmtpd/docs/controller.rst 0000644 0001751 0001751 00000022453 13342502335 021161 0 ustar wayne wayne 0000000 0000000 .. _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* is passed to your loop's
:meth:`AbstractEventLoop.create_server` method as the
``host`` parameter,
except None (default) is translated to '::1'. To bind
dual-stack locally, use 'localhost'. To bind `dual-stack
`_
on all interfaces, use ''.
*port* is 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 an ``SSLContext`` that will be used by the loop's
server. It is passed directly to the :meth:`AbstractEventLoop.create_server`
method. Note that this implies unconditional encryption of the connection,
and prevents use of the ``STARTTLS`` mechanism.
.. 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.2/aiosmtpd/controller.py 0000644 0001751 0001751 00000004661 13342502335 020052 0 ustar wayne wayne 0000000 0000000 import 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):
"""
`Documentation can be found here
`_.
"""
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.2/aiosmtpd/smtp.py 0000644 0001751 0001751 00000067604 13342505044 016660 0 ustar wayne wayne 0000000 0000000 import 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.2+'
__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,
ident=None,
tls_context=None,
require_starttls=False,
timeout=300,
loop=None):
self.__ident__ = ident or __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._timeout_duration = timeout
self._timeout_handle = None
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')
self._reset_timeout()
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)
self._timeout_handle.cancel()
# 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 _reset_timeout(self):
if self._timeout_handle is not None:
self._timeout_handle.cancel()
self._timeout_handle = self.loop.call_later(
self._timeout_duration, self._timeout_cb)
def _timeout_cb(self):
log.info('%r connection timeout', self.session.peer)
# Calling close() on the transport will trigger connection_lost(),
# which gracefully closes the SSL transport if required and cleans
# up state.
self.transport.close()
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:
try:
command = line.upper().decode(encoding='ascii')
except UnicodeDecodeError:
await self.push('500 Error: bad syntax')
continue
arg = None
else:
try:
command = line[:i].upper().decode(encoding='ascii')
except UnicodeDecodeError:
await self.push('500 Error: bad syntax')
continue
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
# Received a valid command, reset the timer.
self._reset_timeout()
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]')
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.2/aiosmtpd/testing/ 0000755 0001751 0001751 00000000000 13342506003 016757 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/testing/helpers.py 0000644 0001751 0001751 00000002701 13342502335 020777 0 ustar wayne wayne 0000000 0000000 """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.2/aiosmtpd/testing/__init__.py 0000644 0001751 0001751 00000000000 13342502335 021062 0 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/__init__.py 0000644 0001751 0001751 00000000000 13342502335 017405 0 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/lmtp.py 0000644 0001751 0001751 00000001131 13342502335 016630 0 ustar wayne wayne 0000000 0000000 from 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.2/aiosmtpd/tests/ 0000755 0001751 0001751 00000000000 13342506003 016444 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/tests/test_lmtp.py 0000644 0001751 0001751 00000003440 13342502335 021036 0 ustar wayne wayne 0000000 0000000 """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.2/aiosmtpd/tests/test_main.py 0000644 0001751 0001751 00000031235 13342502335 021011 0 ustar wayne wayne 0000000 0000000 import 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.2/aiosmtpd/tests/test_starttls.py 0000644 0001751 0001751 00000015571 13342502335 021752 0 ustar wayne wayne 0000000 0000000 import 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.2/aiosmtpd/tests/test_smtp.py 0000644 0001751 0001751 00000134200 13342502335 021044 0 ustar wayne wayne 0000000 0000000 """Test the SMTP protocol."""
import time
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 TimeoutController(Controller):
def factory(self):
return Server(self.handler, timeout=0.1)
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, 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_binary(self):
with SMTP(*self.address) as client:
client.sock.send(b"\x80FAIL\r\n")
code, response = client.getreply()
self.assertEqual(code, 500)
self.assertEqual(response, b'Error: bad syntax')
def test_binary_space(self):
with SMTP(*self.address) as client:
client.sock.send(b"\x80 FAIL\r\n")
code, response = client.getreply()
self.assertEqual(code, 500)
self.assertEqual(response, b'Error: bad syntax')
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)
class TestTimeout(unittest.TestCase):
def setUp(self):
controller = TimeoutController(Sink)
controller.start()
self.addCleanup(controller.stop)
self.address = (controller.hostname, controller.port)
def test_timeout(self):
with SMTP(*self.address) as client:
code, response = client.ehlo('example.com')
time.sleep(0.3)
self.assertRaises(SMTPServerDisconnected, client.getreply)
aiosmtpd-1.2/aiosmtpd/tests/test_handlers.py 0000644 0001751 0001751 00000060137 13342502335 021670 0 ustar wayne wayne 0000000 0000000 import 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.2/aiosmtpd/tests/test_server.py 0000644 0001751 0001751 00000003100 13342502335 021361 0 ustar wayne wayne 0000000 0000000 """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.2/aiosmtpd/tests/__init__.py 0000644 0001751 0001751 00000000000 13342502335 020547 0 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/tests/certs/ 0000755 0001751 0001751 00000000000 13342506003 017564 5 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/tests/certs/server.key 0000644 0001751 0001751 00000003250 13342502335 021610 0 ustar wayne wayne 0000000 0000000 -----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.2/aiosmtpd/tests/certs/__init__.py 0000644 0001751 0001751 00000000000 13342502335 021667 0 ustar wayne wayne 0000000 0000000 aiosmtpd-1.2/aiosmtpd/tests/certs/server.crt 0000644 0001751 0001751 00000002700 13342502335 021607 0 ustar wayne wayne 0000000 0000000 -----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.2/aiosmtpd/tests/test_smtps.py 0000644 0001751 0001751 00000003724 13342502335 021235 0 ustar wayne wayne 0000000 0000000 """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.2/aiosmtpd/__main__.py 0000644 0001751 0001751 00000000107 13342502335 017376 0 ustar wayne wayne 0000000 0000000 from aiosmtpd.main import main
if __name__ == '__main__':
main()
aiosmtpd-1.2/unittest.cfg 0000644 0001751 0001751 00000000411 13342502335 016022 0 ustar wayne wayne 0000000 0000000 [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.2/.coverage.ini 0000644 0001751 0001751 00000000443 13342502335 016041 0 ustar wayne wayne 0000000 0000000 [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}