Repository: aio-libs/aiosmtpd
Pull request #228 (2021-01-01 06:20:37)

Change unit testing runner to Pytest
First New PR of 2021, yay! 😄

## What do these changes do?

Change unit test runner from `nose2` to `pytest` and remove all vestiges of nose2 config.

Also:

* Change to full `pyproject.toml` + `setup.cfg` to start aligning with [**PEP 517**](https://www.python.org/dev/peps/pep-0517/)
* Remove `setup_helpers.py` -- functionality used now either supported OOB by setuptools, or inlined in `conf.py`
* Remove no longer used files such as `.appveyor.yml` and `unittest.cfg`
* Update `README.rst` to reflect the changes

This should be a stepping stone towards full-fledged pytest-based test suite, to allow time to figure out possible glitches before going big bang with #202 

## Are there changes in behavior for the user?

Should be None; this is for testing back-end only.

Users using PyCharm would probably appreciate the fact that since PyCharm fully supports pytest (even wrapped with tox), with a pytest runner, one can easily see which tests are failing (and quickly go to the test case def) instead of having to peruse through reams of `tox` output then searching for the test case manually 😄

## Related issue number

Related to #198, but does not close that issue. Consider this as a stepping stone towards PR #202 which _will_ close that issue completely.

## Checklist

- [x] I think the code is well written
- [x] Unit tests for the changes exist _-- this_ is _actually the unit testing..._
- [x] tox testenvs have been executed in the following environments:
   - [x] Windows 10 (via PyCharm tox+pytest runner): `(ALL)`
   - [x] Windows 10 (via PSCore 7.1.0): `(ALL)`
   - [x] Windows 10 (via Cygwin): `qa,py36-{cov,nocov}`
   - [x] FreeBSD 12.1 on VBox: `(ALL)` + `pypy3-nocov`
- [x] Documentation reflects the changes
- [x] Add a news fragment into the `NEWS.rst` file _-- not applicable because this is not user-visible_



codecov[bot]: # [Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228?src=pr&el=h1) Report
> Merging [#228](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228?src=pr&el=desc) (9623ef4) into [master](https://codecov.io/gh/aio-libs/aiosmtpd/commit/6c8fdbc7aecabb66272bd906c123808048308885?el=desc) (6c8fdbc) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228/graphs/tree.svg?width=650&height=150&src=pr&token=k9dc7NVG69)](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #228   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            5         5           
  Lines         1038      1038           
  Branches       183       183           
=========================================
  Hits          1038      1038           
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228?src=pr&el=footer). Last update [6c8fdbc...9623ef4](https://codecov.io/gh/aio-libs/aiosmtpd/pull/228?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

lgtm-com[bot]: This pull request **introduces 1 alert** when merging 9623ef44bb4c1c83e8a7f007e9594219068ab1cf into 6c8fdbc7aecabb66272bd906c123808048308885 - [view on LGTM.com](https://lgtm.com/projects/g/aio-libs/aiosmtpd/rev/pr-f7bd248af092fafac36c910bb7225919a65e0c91)

**new alerts:**

* 1 for Unreachable code
pepoluan: > 
> 
> This pull request **introduces 1 alert** when merging [9623ef4](https://github.com/aio-libs/aiosmtpd/commit/9623ef44bb4c1c83e8a7f007e9594219068ab1cf) into [6c8fdbc](https://github.com/aio-libs/aiosmtpd/commit/6c8fdbc7aecabb66272bd906c123808048308885) - [view on LGTM.com](https://lgtm.com/projects/g/aio-libs/aiosmtpd/rev/pr-f7bd248af092fafac36c910bb7225919a65e0c91)
> 
> **new alerts:**
> 
>     * 1 for Unreachable code

Ugh, LGTM.com/Semmle does not understand that the `exec` sets the value for `__version__`.

I'll rewrite the exec out of the code.
pepoluan: If no objections within the next 2x24, I'll squamerge this PR.

Patch:
diff --git a/.appveyor.yml b/.appveyor.yml
deleted file mode 100644
index 194621e..0000000
--- a/.appveyor.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-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"
diff --git a/.gitignore b/.gitignore
index 5a24133..995ec61 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,8 @@ __pycache__/
 htmlcov/
 _dynamic/
 coverage.xml
+coverage-*.xml
 diffcov.html
+diffcov-*.html
 .python-version
 .pytype/
diff --git a/MANIFEST.in b/MANIFEST.in
index 5a8020d..e2b7b8c 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,3 @@
 graft aiosmtpd
-include LICENSE *.cfg *.ini *.py *.rst *.yml
+include LICENSE *.cfg *.ini *.py *.rst *.yml *.toml
 global-exclude *.py[oc]
diff --git a/README.rst b/README.rst
index da032ab..10814f8 100644
--- a/README.rst
+++ b/README.rst
@@ -29,16 +29,13 @@
    :alt: Discourse status
 
 The Python standard library includes a basic
-`SMTP <http://www.faqs.org/rfcs/rfc5321.html>`__ server in the
-`smtpd <https://docs.python.org/3/library/smtpd.html>`__ module, based on the
-old asynchronous libraries
-`asyncore <https://docs.python.org/3/library/asyncore.html>`__ and
-`asynchat <https://docs.python.org/3/library/asynchat.html>`__.  These modules
-are quite old and are definitely showing their age.  asyncore and asynchat are
+:rfc:`SMTP <5321>` server in the
+:mod:`smtpd` module, based on the
+old asynchronous libraries :mod:`asyncore` and :mod:`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 <https://docs.python.org/3/library/asyncio.html>`__ module in Python
+With the introduction of the :mod:`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
@@ -86,8 +83,8 @@ You can install this package in a virtual environment like so::
     $ 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.
+This will give you a command line script called ``aiosmtpd`` which implements the
+SMTP server.  Use ``aiosmtpd --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
@@ -104,17 +101,28 @@ test suite for Python 3.  Once you've got that, run::
 
 Individual tests can be run like this::
 
-    $ tox -e py36-nocov -- -P <pattern>
+    $ tox -- <testname>
 
-where *<pattern>* is a Python regular expression matching a test name.
+where ``<testname>`` is the "node id" of the test case to run, as explained
+in `the pytest documentation`_. The command above will run that one test case
+against all testenvs defined in ``tox.ini`` (see below).
 
-You can also add the ``-E`` option to boost debugging output, e.g.::
+If you want test to stop as soon as it hit a failure, use the ``-x``/``--exitfirst``
+option::
 
-    $ tox -e py36-nocov -- -E
+    $ tox -- -x
 
-and these options can be combined::
+You can also add the ``-s``/``--capture=no`` option to show output, e.g.::
 
-    $ tox -e py36-nocov -- -P test_connection_reset_during_DATA -E
+    $ tox -e py36-nocov -- -s
+
+(The ``-e`` parameter is explained in the next section about 'testenvs'.
+In general, you'll want to choose the ``nocov`` testenvs if you want to show output,
+so you can see which test is generating which output.)
+
+The `-x` and `-s` options can be combined::
+
+    $ tox -e py36-nocov -- -x -s <testname>
 
 
 Supported 'testenvs'
@@ -136,9 +144,23 @@ have been configured and tested:
   - ``profile`` = no coverage testing, but code profiling instead.
     This must be **invoked manually** using the ``-e`` parameter
 
-  **Note:** Due to possible 'complications' when setting up PyPy on
+  **Note 1:** Due to possible 'complications' when setting up PyPy on
   systems without pyenv, ``pypy3`` tests also will not be automatically
-  run; you must invoke them manually.
+  run; you must invoke them manually. For example::
+
+    $ tox -e pypy3-nocov
+
+  **Note 2:** It is also possible to use whatever Python version is used when
+  invoking ``tox`` by using the ``py`` target, but you must explicitly include
+  the type of testing you want. For example::
+
+    $ tox -e "py-{nocov,cov,diffcov}"
+
+  (Don't forget the quotes if you want to use braces!)
+
+  You might want to do this for CI platforms where the exact Python version
+  is pre-prepared, such as Travis CI or |GitHub Actions|_; this will definitely
+  save some time during tox's testenv prepping.
 
 * ``qa``
 
@@ -152,12 +174,16 @@ have been configured and tested:
 Environment Variables
 -------------------------
 
-``PLATFORM``
+.. envvar:: PLATFORM
+
     Used on non-native-Linux operating systems to specify tests to skip.
     Valid values:
 
-    * ``mswin`` -- when running tox on Microsoft Windows
-    * ``wsl`` -- when running tox on Windows Subsystem for Linux (WSL)
+    +-----------+-------------------------------------------------------+
+    | ``mswin`` | when running tox on Microsoft Windows (non-Cygwin)    |
+    +-----------+-------------------------------------------------------+
+    | ``wsl``   | when running tox on Windows Subsystem for Linux (WSL) |
+    +-----------+-------------------------------------------------------+
 
 
 Different Python Versions
@@ -184,6 +210,24 @@ versions on your system by using ``pyenv``. General steps:
    documentation about this option)
 
 
+``housekeep.py``
+-------------------
+
+If you ever need to 'reset' your repo, you can use the ``housekeep.py`` utility
+like so::
+
+    $ python housekeep.py superclean
+
+It is `strongly` recommended to NOT do superclean too often, though.
+Every time you invoke ``superclean``,
+tox will have to recreate all its testenvs,
+and this will make testing `much` longer to finish.
+
+``superclean`` is typically only needed when you switch branches,
+or if you want to really ensure that artifacts from previous testing sessions
+won't interfere with your next testing sessions.
+
+
 Contents
 ========
 
@@ -209,3 +253,9 @@ Indices and tables
 * :ref:`genindex`
 * :ref:`modindex`
 * :ref:`search`
+
+
+.. _`GitHub Actions`: https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-python#running-tests-with-tox
+.. |GitHub Actions| replace:: **GitHub Actions**
+.. _`pytest doctest`: https://docs.pytest.org/en/stable/doctest.html
+.. _`the pytest documentation`: https://docs.pytest.org/en/stable/usage.html#specifying-tests-selecting-tests
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
index 9fa2cf5..dfcbaae 100644
--- a/aiosmtpd/controller.py
+++ b/aiosmtpd/controller.py
@@ -47,7 +47,7 @@ class Controller:
         port=8025,
         *,
         ready_timeout=1.0,
-        enable_SMTPUTF8=True,
+        enable_SMTPUTF8=None,
         ssl_context: ssl.SSLContext = None,
         server_kwargs: Dict[str, Any] = None
     ):
@@ -59,16 +59,25 @@ class Controller:
         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.ready_timeout = os.getenv("AIOSMTPD_CONTROLLER_TIMEOUT", ready_timeout)
         self.server_kwargs: Dict[str, Any] = server_kwargs or {}
+        if enable_SMTPUTF8 is not None:
+            # Uncomment next lines when we hit 1.3
+            # warn("enable_SMTPUTF8 will be removed in the future. "
+            #      "Please use server_kwargs instead.", DeprecationWarning)
+            self.server_kwargs["enable_SMTPUTF8"] = enable_SMTPUTF8
+        else:
+            # This line emulates previous behavior of defaulting enable_SMTPUTF8 to
+            # True. Which actually kinda conflicts with SMTP class's default, but it's
+            # explained in the documentation.
+            self.server_kwargs.setdefault("enable_SMTPUTF8", True)
 
     def factory(self):
         """Allow subclasses to customize the handler/server creation."""
         return SMTP(
-            self.handler, enable_SMTPUTF8=self.enable_SMTPUTF8, **self.server_kwargs
+            self.handler, **self.server_kwargs
         )
 
     def _factory_invoker(self):
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 4b0cab1..49abd45 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -9,6 +9,7 @@
 Added
 -----
 * Test for ``SMTP.__init__`` behavior after taking out code that edits TLS Context
+* Implement mechanism to limit the number of commands sent (Closes #145)
 
 Fixed/Improved
 --------------
diff --git a/aiosmtpd/docs/intro.rst b/aiosmtpd/docs/intro.rst
index aaefc87..808920d 100644
--- a/aiosmtpd/docs/intro.rst
+++ b/aiosmtpd/docs/intro.rst
@@ -39,5 +39,5 @@ Other references
 * `Wikipedia page on SMTP <https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol>`__
 * `asyncio module documentation <https://docs.python.org/3/library/asyncio.html>`__
 * `Developing with asyncio <https://docs.python.org/3/library/asyncio-dev.html#asyncio-dev>`__
-* `Python issue #25508 <http://bugs.python.org/issue25008>`__ which started
+* `Python issue #25008 <http://bugs.python.org/issue25008>`__ which started
   the whole thing.
diff --git a/aiosmtpd/docs/smtp.rst b/aiosmtpd/docs/smtp.rst
index 84694c5..2d3a3eb 100644
--- a/aiosmtpd/docs/smtp.rst
+++ b/aiosmtpd/docs/smtp.rst
@@ -107,7 +107,9 @@ SMTP API
    decode_data=False, hostname=None, ident=None, tls_context=None, \
    require_starttls=False, timeout=300, auth_required=False, \
    auth_require_tls=True, auth_exclude_mechanism=None, auth_callback=None, \
-   loop=None)
+   command_call_limit=None, loop=None)
+
+   **Parameters**
 
    :boldital:`handler` is an instance of a :ref:`handler <handlers>` class.
 
@@ -158,9 +160,51 @@ SMTP API
    must return a ``bool`` that indicates whether the client's authentication
    attempt is accepted/successful or not.
 
+   :boldital:`command_call_limit` if not ``None`` sets the maximum time a certain SMTP
+   command can be invoked.
+   This is to prevent DoS due to malicious client connecting and never disconnecting,
+   due to continual sending of SMTP commands to prevent timeout.
+
+   The handling differs based on the type:
+
+   .. highlights::
+
+      If :attr:`command_call_limit` is of type ``int``,
+      then the value is the call limit for ALL SMTP commands.
+
+      If :attr:`command_call_limit` is of type ``dict``,
+      it must be a ``Dict[str, int]``
+      (the type of the values will be enforced).
+      The keys will be the SMTP Command to set the limit for,
+      the values will be the call limit per SMTP Command.
+
+      .. highlights::
+
+         A special key of ``"*"`` is used to set the 'default' call limit for commands not
+         explicitly declared in :attr:`command_call_limit`.
+         If ``"*"`` is not given,
+         then the 'default' call limit will be set to ``aiosmtpd.smtp.CALL_LIMIT_DEFAULT``
+
+      Other types -- or a ``Dict`` whose any value is not an ``int`` -- will raise a
+      ``TypeError`` exception.
+
+      Examples::
+
+          # All commands have a limit of 10 calls
+          SMTP(..., command_call_limit=10)
+
+          # Commands RCPT and NOOP have their own limits; others have an implicit limit
+          # of 20 (CALL_LIMIT_DEFAULT)
+          SMTP(..., command_call_limit={"RCPT": 30, "NOOP": 5})
+
+          # Commands RCPT and NOOP have their own limits; others set to 3
+          SMTP(..., command_call_limit={"RCPT": 20, "NOOP": 10, "*": 3})
+
    :boldital:`loop` is the asyncio event loop to use.  If not given,
    :meth:`asyncio.new_event_loop()` is called to create the event loop.
 
+   **Attributes**
+
    .. py:attribute:: line_length_limit
 
       The maximum line length, in octets (not characters; one UTF-8 character
diff --git a/aiosmtpd/lmtp.py b/aiosmtpd/lmtp.py
index 5a7ba40..a9ba9d7 100644
--- a/aiosmtpd/lmtp.py
+++ b/aiosmtpd/lmtp.py
@@ -4,7 +4,7 @@ from public import public
 
 @public
 class LMTP(SMTP):
-    show_smtp_greeting = False
+    show_smtp_greeting: bool = False
 
     @syntax('LHLO hostname')
     async def smtp_LHLO(self, arg):
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index 5447640..0381184 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -59,11 +59,13 @@ __all__ = [
     "AuthMechanismType",
     "MISSING",
 ]  # Will be added to by @public
-__version__ = '1.2.3a5'
+__version__ = '1.2.3a6'
 __ident__ = 'Python SMTP {}'.format(__version__)
 log = logging.getLogger('mail.log')
 
 
+BOGUS_LIMIT = 5
+CALL_LIMIT_DEFAULT = 20
 DATA_SIZE_DEFAULT = 2**25  # Where does this number come from, I wonder...
 EMPTY_BARR = bytearray()
 EMPTYBYTES = b''
@@ -151,6 +153,11 @@ def login_always_fail(mechanism, login, password):
     return False
 
 
+def is_int(o):
+    return isinstance(o, int)
+
+
+@public
 class TLSSetupException(Exception):
     pass
 
@@ -181,6 +188,7 @@ class SMTP(asyncio.StreamReaderProtocol):
             auth_require_tls=True,
             auth_exclude_mechanism: Optional[Iterable[str]] = None,
             auth_callback: AuthCallbackType = None,
+            command_call_limit: Union[int, Dict[str, int], None] = None,
             loop=None,
     ):
         self.__ident__ = ident or __ident__
@@ -256,6 +264,23 @@ class SMTP(asyncio.StreamReaderProtocol):
             if m.startswith("smtp_")
         }
 
+        if command_call_limit is None:
+            self._enforce_call_limit = False
+        else:
+            self._enforce_call_limit = True
+            if isinstance(command_call_limit, int):
+                self._call_limit_base = {}
+                self._call_limit_default: int = command_call_limit
+            elif isinstance(command_call_limit, dict):
+                if not all(map(is_int, command_call_limit.values())):
+                    raise TypeError("All command_call_limit values must be int")
+                self._call_limit_base = command_call_limit
+                self._call_limit_default: int = command_call_limit.get(
+                    "*", CALL_LIMIT_DEFAULT
+                )
+            else:
+                raise TypeError("command_call_limit must be int or Dict[str, int]")
+
     def _create_session(self):
         return Session(self.loop)
 
@@ -378,6 +403,15 @@ class SMTP(asyncio.StreamReaderProtocol):
     async def _handle_client(self):
         log.info('%r handling connection', self.session.peer)
         await self.push('220 {} {}'.format(self.hostname, self.__ident__))
+        if self._enforce_call_limit:
+            call_limit = collections.defaultdict(
+                lambda x=self._call_limit_default: x,
+                self._call_limit_base
+            )
+        else:
+            # Not used, but this silences code inspection tools
+            call_limit = {}
+        bogus_budget = BOGUS_LIMIT
         while self.transport is not None:   # pragma: nobranch
             try:
                 try:
@@ -454,8 +488,31 @@ class SMTP(asyncio.StreamReaderProtocol):
                     # RFC3207 part 4
                     await self.push('530 Must issue a STARTTLS command first')
                     continue
+
+                if self._enforce_call_limit:
+                    budget = call_limit[command]
+                    if budget < 1:
+                        log.warning(
+                            "%r over limit for %s", self.session.peer, command
+                        )
+                        await self.push(
+                            f"421 4.7.0 {command} sent too many times"
+                        )
+                        self.transport.close()
+                        continue
+                    call_limit[command] = budget - 1
+
                 method = self._smtp_methods.get(command)
                 if method is None:
+                    log.warning("%r unrecognised: %s", self.session.peer, command)
+                    bogus_budget -= 1
+                    if bogus_budget < 1:
+                        log.warning("%r too many bogus commands", self.session.peer)
+                        await self.push(
+                            "502 5.5.1 Too many unrecognized commands, goodbye."
+                        )
+                        self.transport.close()
+                        continue
                     await self.push(
                         '500 Error: command "%s" not recognized' % command)
                     continue
diff --git a/conf.py b/conf.py
index 0996eda..3968c70 100644
--- a/conf.py
+++ b/conf.py
@@ -14,10 +14,13 @@
 
 import sys
 import os
+import re
 import datetime
 
 from pathlib import Path
 
+RE__VERSION = re.compile(r"""__version__ = (['"])(?P<ver>[^'"]+)(\1)""")
+
 YELLOW = "\x1b[1;93m"
 NORM = "\x1b[0m"
 
@@ -64,9 +67,17 @@ copyright = f'2015-{datetime.datetime.now().year}, {author}'
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 #
-from setup_helpers import get_version
-release = get_version('aiosmtpd/smtp.py')
-version = release
+__version__ = None
+with open('aiosmtpd/smtp.py') as fp:
+    for line in fp:
+        m = RE__VERSION.match(line.strip())
+        if m:
+            __version__ = m.group("ver")
+            break
+if __version__ is None:
+    raise RuntimeError("No __version__ found in aiosmtpd/smtp.py!")
+release = __version__
+version = __version__
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/coverage.cfg b/coverage.cfg
deleted file mode 100644
index 65c4d18..0000000
--- a/coverage.cfg
+++ /dev/null
@@ -1,19 +0,0 @@
-[run]
-branch = true
-parallel = true
-omit =
-     setup*
-     aiosmtpd/testing/*
-     aiosmtpd/tests/*
-    .tox/*
-
-[paths]
-source =
-    aiosmtpd
-
-[report]
-exclude_lines =
-    pragma: nocover
-    pragma: no${PLATFORM}
-    pragma: skipif_${PY_39}_py39
-    pragma: skipif_${PY_36}_py36
diff --git a/housekeep.py b/housekeep.py
index cf35a3f..08c6a78 100644
--- a/housekeep.py
+++ b/housekeep.py
@@ -39,6 +39,7 @@ WORKDIRS = (
     "_dynamic",
     "aiosmtpd.egg-info",
     "build",
+    "dist",
     "htmlcov",
     "prof",
 )
@@ -144,9 +145,9 @@ def rm_work():
 # region #### Dispatchers #############################################################
 
 
-def dispatch_setup():
+def dispatch_prep():
     """
-    Set up work directories and dump env vars
+    Prepare work directories and dump env vars
     """
     dump_env()
 
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b3b569e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,40 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.pytest.ini_options]
+addopts = "--doctest-glob=\"*.rst\""
+
+[tool.coverage.run]
+branch = true
+parallel = true
+omit = [
+    "setup*",
+    "aiosmtpd/testing/*",
+    "aiosmtpd/tests/*",
+    ".tox/*",
+    "*/helpers/pycharm/*",
+]
+
+[tool.coverage.paths]
+source = [
+    "aiosmtpd",
+]
+
+[tool.coverage.report]
+exclude_lines = [
+    "pragma: nocover",
+    "pragma: no${PLATFORM}",
+    "pragma: skipif_${PY_39}_py39",
+    "pragma: skipon_${INTERP}",
+    "pragma: skipif_${PY_36}_py36",
+]
+fail_under = 100
+show_missing = true
+
+[tool.coverage.html]
+directory = "htmlcov/${TOX_ENV_NAME}"
+title = "aiosmtpd coverage for ${TOX_ENV_NAME}"
+
+[tool.coverage.xml]
+output = "coverage-${INTERP}.xml"
diff --git a/release.py b/release.py
index df088ea..622b558 100755
--- a/release.py
+++ b/release.py
@@ -6,11 +6,14 @@ import sys
 
 import aiosmtpd.smtp as smtpd
 
+TWINE_CONFIG = "~/.pypirc"
+UPSTREAM_REMOTE = "upstream"
+
 version = smtpd.__version__
 
-choice = input(f'Release aiosmtpd {version} - correct? [y/N]: ')
-if choice.lower() not in ('y', 'yes'):
-    sys.exit('Release aborted')
+choice = input(f"Release aiosmtpd {version} - correct? [y/N]: ")
+if choice.lower() not in ("y", "yes"):
+    sys.exit("Release aborted")
 else:
     # We're probably already in the right place
     os.chdir(os.path.dirname(os.path.abspath(__file__)))
@@ -19,10 +22,20 @@ else:
     # Assuming twine is installed. And that we're only building .tar.gz
     subprocess.run(["twine", "check", f"dist/aiosmtpd-{version}.tar.gz"])
     # You should have an aiosmtpd bit setup in your ~/.pypirc - for twine
-    subprocess.run(["twine", "upload", "--config-file", "~/.pypirc", "-r", "aiosmtpd", "dist/aiosmptd-{version}.tar.gz"])
+    subprocess.run(
+        [
+            "twine",
+            "upload",
+            "--config-file",
+            TWINE_CONFIG,
+            "-r",
+            "aiosmtpd",
+            f"dist/aiosmptd-{version}.tar.gz",
+        ]
+    )
     # Only tag when we've actually built and uploaded. If something goes wrong
     # we may need the tag somewhere else!
     # The annotation information should come from the changelog
     subprocess.run(["git", "tag", "-a", version])
     # And now push the tag, of course.
-    subprocess.run(["git", "push", "upstream", "--tags"])
+    subprocess.run(["git", "push", UPSTREAM_REMOTE, "--tags"])
diff --git a/setup.cfg b/setup.cfg
index c003825..85407f1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,7 +1,50 @@
+[metadata]
+name = aiosmtpd
+version = attr: aiosmtpd.smtp.__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.
+long_description_content_type = text/x-rst
+url = http://aiosmtpd.readthedocs.io/
+keywords =
+    email
+license = Apache-2.0
+classifiers =
+    License :: OSI Approved
+    License :: OSI Approved :: Apache Software License
+    Intended Audience :: Developers
+    Operating System :: Microsoft :: Windows
+    Operating System :: POSIX :: BSD :: FreeBSD
+    Operating System :: POSIX :: Linux
+    Programming Language :: Python :: 3 :: Only
+    Programming Language :: Python :: 3.6
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+    Programming Language :: Python :: Implementation :: CPython
+    Programming Language :: Python :: Implementation :: PyPy
+    Topic :: Communications :: Email :: Mail Transport Agents
+    Framework :: AsyncIO
+
+[options]
+zip_safe = false
+python_requires = ~=3.6
+packages = find:
+include_package_data = true
+install_requires =
+    atpublic
+tests_require =
+    tox
+
+[options.packages.find]
+exclude =
+    examples
+
+[options.entry_points]
+console_scripts =
+    aiosmtpd = aiosmtpd.main:main
+
 [easy_install]
 zip_ok = false
-
-[nosetests]
-nocapture = 1
-cover-package = aiosmtp
-cover-erase = 1
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 5491432..b024da8 100644
--- a/setup.py
+++ b/setup.py
@@ -1,36 +1,4 @@
-from setup_helpers import require_python, get_version
-from setuptools import setup, find_packages
+from setuptools import setup
 
 
-require_python(0x03_06_00_f0)
-__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.""",
-    long_description_content_type="text/x-rst",
-    url='http://aiosmtpd.readthedocs.io/',
-    keywords='email',
-    packages=find_packages(exclude=['examples']),
-    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',
-        ],
-    )
+setup()
diff --git a/setup_helpers.py b/setup_helpers.py
deleted file mode 100644
index 9b627c1..0000000
--- a/setup_helpers.py
+++ /dev/null
@@ -1,150 +0,0 @@
-# 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 <http://www.gnu.org/licenses/>.
-
-"""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<version>\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
diff --git a/tox.ini b/tox.ini
index bb8fe53..8e46bd9 100644
--- a/tox.ini
+++ b/tox.ini
@@ -12,34 +12,35 @@ envdir =
     pypy3: {toxworkdir}/pypy3
     py: {toxworkdir}/py
 commands =
-    python housekeep.py setup
-    nocov: python -m nose2 -v {posargs}
-    cov: python -m coverage run -m nose2
-    cov: python -m coverage combine
-    cov: python -m coverage html --directory=htmlcov/{envname}
-    cov: python -m coverage report -m --fail-under=100
-    cov: python -m coverage xml
-    diffcov: diff-cover coverage.xml --html-report diffcov.html
-    diffcov: diff-cover coverage.xml --fail-under=100
-    profile: python -m nose2 --profile {posargs}
+    python housekeep.py prep
+    nocov: pytest --verbose -p no:cov {posargs}
+    cov: pytest --cov --cov-report=xml --cov-report=html --cov-report=term {posargs}
+    diffcov: diff-cover coverage-{env:INTERP}.xml --html-report diffcov-{env:INTERP}.html
+    diffcov: diff-cover coverage-{env:INTERP}.xml --fail-under=100
+    profile: pytest --profile {posargs}
     python housekeep.py gather
 #sitepackages = True
 usedevelop = True
 deps =
-    nose2
-    coverage
+    colorama
+    coverage[toml]
+    packaging
+    pytest
+    pytest-cov
+    pytest-mock
+    pytest-profiling
+    pytest-sugar
     diff_cover
 setenv =
-    cov: COVERAGE_RCFILE=coverage.cfg
-    cov: COVERAGE_PROCESS_START=coverage.cfg
-    cov: COVERAGE_OPTIONS="-p"
     cov: COVERAGE_FILE={toxinidir}/.coverage
+    nocov: PYTHONASYNCIODEBUG=1
     py36: INTERP=py36
     py37: INTERP=py37
     py38: INTERP=py38
     py39: INTERP=py39
     pypy3: INTERP=pypy3
-    nocov: PYTHONASYNCIODEBUG=1
+    py: INTERP=py
+    # Special flags to handle deprecated features
     py36: PY_36=eq
     !py36: PY_36=gt
     py39: PY_39=ge
@@ -55,6 +56,7 @@ passenv =
 basepython = python3
 envdir = {toxworkdir}/python3
 deps =
+    colorama
     flake8
     sphinx
 
@@ -62,6 +64,7 @@ deps =
 basepython = {[qadocs]basepython}
 envdir = {[qadocs]envdir}
 commands =
+    python housekeep.py prep
     python -m flake8 aiosmtpd
 deps =
     {[qadocs]deps}
@@ -70,6 +73,7 @@ deps =
 basepython = {[qadocs]basepython}
 envdir = {[qadocs]envdir}
 commands =
+    python housekeep.py prep
     python setup.py build_sphinx
 deps:
     {[qadocs]deps}


Test patch:
diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py
index 1416383..e7b1bd4 100644
--- a/aiosmtpd/tests/test_main.py
+++ b/aiosmtpd/tests/test_main.py
@@ -31,7 +31,7 @@ def tearDownModule():
     ModuleResources.close()
 
 
-class TestHandler1:
+class HandlerWithFromCli:
     def __init__(self, called):
         self.called = called
 
@@ -40,7 +40,7 @@ class TestHandler1:
         return cls(*args)
 
 
-class TestHandler2:
+class HandlerWithoutFromCli:
     pass
 
 
@@ -168,19 +168,19 @@ 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)
+            ('-c', 'aiosmtpd.tests.test_main.HandlerWithFromCli', '--', 'FOO'))
+        self.assertIsInstance(args.handler, HandlerWithFromCli)
         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)
+            ('-c', 'aiosmtpd.tests.test_main.HandlerWithoutFromCli'))
+        self.assertIsInstance(args.handler, HandlerWithoutFromCli)
 
     def test_handler_from_cli_exception(self):
         self.assertRaises(TypeError, parseargs,
-                          ('-c', 'aiosmtpd.tests.test_main.TestHandler1',
+                          ('-c', 'aiosmtpd.tests.test_main.HandlerWithFromCli',
                            'FOO', 'BAR'))
 
     def test_handler_no_from_cli_exception(self):
@@ -188,7 +188,7 @@ class TestParseArgs(unittest.TestCase):
         with patch('sys.stderr', stderr):
             with self.assertRaises(SystemExit) as cm:
                 parseargs(
-                    ('-c', 'aiosmtpd.tests.test_main.TestHandler2',
+                    ('-c', 'aiosmtpd.tests.test_main.HandlerWithoutFromCli',
                      'FOO', 'BAR'))
             self.assertEqual(cm.exception.code, 2)
         usage_lines = stderr.getvalue().splitlines()
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
index c53920e..d1d8b41 100644
--- a/aiosmtpd/tests/test_server.py
+++ b/aiosmtpd/tests/test_server.py
@@ -155,19 +155,6 @@ class TestFactory(unittest.TestCase):
         finally:
             cont.stop()
 
-    def test_conflict_smtputf8(self):
-        kwargs = dict(enable_SMTPUTF8=True)
-        cont = Controller(Sink(), server_kwargs=kwargs)
-        try:
-            with self.assertRaises(TypeError) as cm:
-                cont.start()
-            self.assertIsNone(cont.smtpd)
-            excm = str(cm.exception)
-            self.assertIn("multiple values", excm)
-            self.assertIn("enable_SMTPUTF8", excm)
-        finally:
-            cont.stop()
-
     def test_factory_none(self):
         # Hypothetical situation where factory() did not raise an Exception
         # but returned None instead
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index 04f541a..e9319f3 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -9,7 +9,11 @@ import warnings
 from aiosmtpd.controller import Controller
 from aiosmtpd.handlers import Sink
 from aiosmtpd.smtp import (
-    MISSING, Session as SMTPSess, SMTP as Server, __ident__ as GREETING, auth_mechanism
+    CALL_LIMIT_DEFAULT,
+    MISSING,
+    Session as SMTPSess,
+    SMTP as Server,
+    __ident__ as GREETING, auth_mechanism
 )
 from aiosmtpd.testing.helpers import (
     ReceivingHandler,
@@ -105,7 +109,8 @@ auth_peeker = PeekerAuth()
 
 class DecodingControllerPeekAuth(Controller):
     def factory(self):
-        return Server(self.handler, decode_data=True, enable_SMTPUTF8=True,
+        self.server_kwargs["enable_SMTPUTF8"] = True
+        return Server(self.handler, decode_data=True,
                       auth_require_tls=False,
                       auth_callback=auth_peeker.authenticate,
                       **self.server_kwargs)
@@ -803,7 +808,8 @@ class TestSMTP(unittest.TestCase):
             self.assertEqual(code, 250)
             self.assertEqual(response, b'OK')
 
-    def test_unknown_command(self):
+    @patch("logging.Logger.warning")
+    def test_unknown_command(self, mock_warning):
         with SMTP(*self.address) as client:
             code, response = client.docmd('FOOBAR')
             self.assertEqual(code, 500)
@@ -1888,3 +1894,158 @@ class TestAuthArgs(unittest.TestCase):
         self.assertRaises(ValueError, auth_mechanism, "has space")
         self.assertRaises(ValueError, auth_mechanism, "has.dot")
         self.assertRaises(ValueError, auth_mechanism, "has/slash")
+
+
+class TestLimits(unittest.TestCase):
+    @patch("logging.Logger.warning")
+    def test_all_limit_15(self, mock_warning):
+        kwargs = dict(
+            command_call_limit=15,
+        )
+        controller = Controller(Sink(), server_kwargs=kwargs)
+        self.addCleanup(controller.stop)
+        controller.start()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, 15):
+                code, mesg = client.noop()
+                self.assertEqual(250, code)
+            code, mesg = client.noop()
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 NOOP sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+
+    @patch("logging.Logger.warning")
+    def test_different_limits(self, mock_warning):
+        noop_max, expn_max = 15, 5
+        kwargs = dict(
+            command_call_limit={"NOOP": noop_max, "EXPN": expn_max},
+        )
+        controller = Controller(Sink(), server_kwargs=kwargs)
+        self.addCleanup(controller.stop)
+        controller.start()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, noop_max):
+                code, mesg = client.noop()
+                self.assertEqual(250, code)
+            code, mesg = client.noop()
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 NOOP sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, expn_max):
+                code, mesg = client.expn("alice@example.com")
+                self.assertEqual(502, code)
+            code, mesg = client.expn("alice@example.com")
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 EXPN sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, CALL_LIMIT_DEFAULT):
+                code, mesg = client.vrfy("alice@example.com")
+                self.assertEqual(252, code)
+            code, mesg = client.vrfy("alice@example.com")
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 VRFY sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+
+    @patch("logging.Logger.warning")
+    def test_different_limits_custom_default(self, mock_warning):
+        # Important: make sure default_max > CALL_LIMIT_DEFAULT
+        # Others can be set small to cut down on testing time, but must be different
+        noop_max, expn_max, default_max = 7, 5, 25
+        self.assertGreater(default_max, CALL_LIMIT_DEFAULT)
+        self.assertNotEqual(noop_max, expn_max)
+        kwargs = dict(
+            command_call_limit={"NOOP": noop_max, "EXPN": expn_max, "*": default_max},
+        )
+        controller = Controller(Sink(), server_kwargs=kwargs)
+        self.addCleanup(controller.stop)
+        controller.start()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, noop_max):
+                code, mesg = client.noop()
+                self.assertEqual(250, code)
+            code, mesg = client.noop()
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 NOOP sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, expn_max):
+                code, mesg = client.expn("alice@example.com")
+                self.assertEqual(502, code)
+            code, mesg = client.expn("alice@example.com")
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 EXPN sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for _ in range(0, default_max):
+                code, mesg = client.vrfy("alice@example.com")
+                self.assertEqual(252, code)
+            code, mesg = client.vrfy("alice@example.com")
+            self.assertEqual(421, code)
+            self.assertEqual(b"4.7.0 VRFY sent too many times", mesg)
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
+
+    def test_limit_wrong_type(self):
+        kwargs = dict(
+            command_call_limit="invalid",
+        )
+        controller = Controller(Sink(), server_kwargs=kwargs)
+        self.addCleanup(controller.stop)
+        with self.assertRaises(TypeError):
+            controller.start()
+
+    def test_limit_wrong_value_type(self):
+        kwargs = dict(
+            command_call_limit={"NOOP": "invalid"},
+        )
+        controller = Controller(Sink(), server_kwargs=kwargs)
+        self.addCleanup(controller.stop)
+        with self.assertRaises(TypeError):
+            controller.start()
+
+    @patch("logging.Logger.warning")
+    def test_limit_bogus(self, mock_warning):
+        # Extreme limit.
+        kwargs = dict(
+            command_call_limit=1,
+        )
+        controller = Controller(Sink(), server_kwargs=kwargs)
+        self.addCleanup(controller.stop)
+        controller.start()
+        with SMTP(controller.hostname, controller.port) as client:
+            code, mesg = client.ehlo('example.com')
+            self.assertEqual(250, code)
+            for i in range(0, 4):
+                code, mesg = client.docmd(f"BOGUS{i}")
+                self.assertEqual(500, code)
+                expected = f"Error: command \"BOGUS{i}\" not recognized"
+                self.assertEqual(expected, mesg.decode("ascii"))
+            code, mesg = client.docmd("LASTBOGUS")
+            self.assertEqual(502, code)
+            self.assertEqual(
+                b"5.5.1 Too many unrecognized commands, goodbye.", mesg
+            )
+            with self.assertRaises(SMTPServerDisconnected):
+                client.noop()
diff --git a/unittest.cfg b/unittest.cfg
deleted file mode 100644
index 139241e..0000000
--- a/unittest.cfg
+++ /dev/null
@@ -1,19 +0,0 @@
-[unittest]
-verbose = 2
-plugins =
-    nose2.plugins.prof
-
-[log-capture]
-always-on = False
-clear-handlers = True
-date-format = None
-; format = %%(name)s: %%(levelname)s: %%(message)s
-format = %%(asctime)s: %%(name)s: %%(levelname)s: %%(message)s
-filter = -nose
-log-level = DEBUG
-
-[profiler]
-always-on = False
-filename = _dynamic/cPROFILE
-restrict =
-sort = ncalls