2020 lines
74 KiB
Diff
2020 lines
74 KiB
Diff
From 747a7c467d45354d8d1ea72bc9d2fce15e186479 Mon Sep 17 00:00:00 2001
|
|
From: Pandu E POLUAN <pepoluan@gmail.com>
|
|
Date: Tue, 9 Mar 2021 00:25:48 +0700
|
|
Subject: [PATCH 1/4] Implement Unthreaded Controller (#256)
|
|
|
|
* Complete rewrite of aiosmtpd.controller
|
|
* Implement Unthreaded Controllers
|
|
* Implement tests of Unthreaded Controllers
|
|
* Improve other tests
|
|
* Improve coverage by replacing nocover's with conditional pragmas
|
|
* Suppress exception ignored during __del__
|
|
* Blackification
|
|
* Update badges
|
|
* Tidy up table + link to Public PGP on GH
|
|
* Bump version to 1.5.0a1 and update NEWS.rst
|
|
---
|
|
DESCRIPTION.rst | 45 ++-
|
|
README.rst | 24 +-
|
|
aiosmtpd/__init__.py | 2 +-
|
|
aiosmtpd/controller.py | 296 ++++++++++++----
|
|
aiosmtpd/docs/NEWS.rst | 12 +
|
|
aiosmtpd/docs/controller.rst | 642 +++++++++++++++++++++++-----------
|
|
aiosmtpd/docs/smtp.rst | 10 +-
|
|
aiosmtpd/handlers.py | 11 +-
|
|
aiosmtpd/proxy_protocol.py | 2 +-
|
|
aiosmtpd/tests/conftest.py | 15 +
|
|
aiosmtpd/tests/test_main.py | 27 +-
|
|
aiosmtpd/tests/test_server.py | 248 ++++++++++---
|
|
pyproject.toml | 6 +-
|
|
13 files changed, 958 insertions(+), 382 deletions(-)
|
|
|
|
diff --git a/DESCRIPTION.rst b/DESCRIPTION.rst
|
|
index 9ec007b..caa9e7a 100644
|
|
--- a/DESCRIPTION.rst
|
|
+++ b/DESCRIPTION.rst
|
|
@@ -2,16 +2,22 @@
|
|
aiosmtpd - asyncio based SMTP server
|
|
######################################
|
|
|
|
-| |github license| |_| |PyPI Version| |PyPI Python|
|
|
-| |GA badge| |codecov| |_| |LGTM.com| |readthedocs| |_|
|
|
-| |GH Release| |_| |PullRequests| |_| |LastCommit|
|
|
+| |github license| |_| |PyPI Version| |_| |PyPI Python|
|
|
+| |GA badge| |_| |codecov| |_| |LGTM.com| |_| |readthedocs|
|
|
+| |GH Release| |_| |GH PRs| |_| |GH LastCommit|
|
|
|
|
|
|
|
.. |_| unicode:: 0xA0
|
|
:trim:
|
|
-.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd
|
|
+.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd?logo=Open+Source+Initiative&logoColor=0F0
|
|
:target: https://github.com/aio-libs/aiosmtpd/blob/master/LICENSE
|
|
:alt: Project License on GitHub
|
|
+.. |PyPI Version| image:: https://img.shields.io/pypi/v/aiosmtpd?logo=pypi&logoColor=yellow
|
|
+ :target: https://pypi.org/project/aiosmtpd/
|
|
+ :alt: PyPI Package
|
|
+.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd?logo=python&logoColor=yellow
|
|
+ :target: https://pypi.org/project/aiosmtpd/
|
|
+ :alt: Supported Python Versions
|
|
.. .. For |GA badge|, don't forget to check actual workflow name in unit-testing-and-coverage.yml
|
|
.. |GA badge| image:: https://github.com/aio-libs/aiosmtpd/workflows/aiosmtpd%20CI/badge.svg
|
|
:target: https://github.com/aio-libs/aiosmtpd/actions
|
|
@@ -25,21 +31,15 @@
|
|
.. |readthedocs| image:: https://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs
|
|
:target: https://aiosmtpd.readthedocs.io/en/latest/?badge=latest
|
|
:alt: Documentation Status
|
|
-.. |PyPI Version| image:: https://badge.fury.io/py/aiosmtpd.svg
|
|
- :target: https://badge.fury.io/py/aiosmtpd
|
|
- :alt: PyPI Package
|
|
-.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd.svg
|
|
- :target: https://pypi.org/project/aiosmtpd/
|
|
- :alt: Supported Python Versions
|
|
.. .. Do NOT include the Discourse badge!
|
|
.. .. Below are badges just for PyPI
|
|
.. |GH Release| image:: https://img.shields.io/github/v/release/aio-libs/aiosmtpd?logo=github
|
|
:target: https://github.com/aio-libs/aiosmtpd/releases
|
|
:alt: GitHub latest release
|
|
-.. |PullRequests| image:: https://img.shields.io/github/issues-pr/aio-libs/aiosmtpd?logo=GitHub
|
|
+.. |GH PRs| image:: https://img.shields.io/github/issues-pr/aio-libs/aiosmtpd?logo=GitHub
|
|
:target: https://github.com/aio-libs/aiosmtpd/pulls
|
|
:alt: GitHub pull requests
|
|
-.. |LastCommit| image:: https://img.shields.io/github/last-commit/aio-libs/aiosmtpd?logo=GitHub
|
|
+.. |GH LastCommit| image:: https://img.shields.io/github/last-commit/aio-libs/aiosmtpd?logo=GitHub
|
|
:target: https://github.com/aio-libs/aiosmtpd/commits/master
|
|
:alt: GitHub last commit
|
|
|
|
@@ -61,10 +61,19 @@ Starting version 1.3.1,
|
|
files provided through PyPI or `GitHub Releases`_
|
|
will be signed using one of the following GPG Keys:
|
|
|
|
-+-------------------------+----------------+------------------------------+
|
|
-| GPG Key ID | Owner | Email |
|
|
-+=========================+================+==============================+
|
|
-| ``5D60 CE28 9CD7 C258`` | Pandu E POLUAN | pepoluan at gmail period com |
|
|
-+-------------------------+----------------+------------------------------+
|
|
-
|
|
.. _`GitHub Releases`: https://github.com/aio-libs/aiosmtpd/releases
|
|
+
|
|
+.. .. In the second column of the table, prefix each line with "| "
|
|
+ .. In the third column, refrain from putting in a direct link to keep the table tidy.
|
|
+ Rather, use the |...|_ construct and do the replacement+linking directive below the table
|
|
+
|
|
++-------------------------+--------------------------------+-----------+
|
|
+| GPG Key ID | Owner / Email | Key |
|
|
++=========================+================================+===========+
|
|
+| ``5D60 CE28 9CD7 C258`` | | Pandu POLUAN / | |pep_gh|_ |
|
|
+| | | pepoluan at gmail period com | |
|
|
++-------------------------+--------------------------------+-----------+
|
|
+
|
|
+.. .. The |_| contruct is U+00A0 (non-breaking space), defined at the start of the file
|
|
+.. |pep_gh| replace:: On |_| GitHub
|
|
+.. _`pep_gh`: https://github.com/pepoluan.gpg
|
|
diff --git a/README.rst b/README.rst
|
|
index 35dcb88..2c1bab7 100644
|
|
--- a/README.rst
|
|
+++ b/README.rst
|
|
@@ -2,14 +2,22 @@
|
|
aiosmtpd - An asyncio based SMTP server
|
|
=========================================
|
|
|
|
-| |github license| |PyPI| |PyPI Python|
|
|
-| |GA badge| |codecov| |LGTM.com| |readthedocs|
|
|
+| |github license| |_| |PyPI Version| |_| |PyPI Python|
|
|
+| |GA badge| |_| |codecov| |_| |LGTM.com| |_| |readthedocs|
|
|
|
|
|
| |Discourse|
|
|
|
|
-.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd
|
|
+.. |_| unicode:: 0xA0
|
|
+ :trim:
|
|
+.. |github license| image:: https://img.shields.io/github/license/aio-libs/aiosmtpd?logo=Open+Source+Initiative&logoColor=0F0
|
|
:target: https://github.com/aio-libs/aiosmtpd/blob/master/LICENSE
|
|
:alt: Project License on GitHub
|
|
+.. |PyPI Version| image:: https://img.shields.io/pypi/v/aiosmtpd?logo=pypi&logoColor=yellow
|
|
+ :target: https://pypi.org/project/aiosmtpd/
|
|
+ :alt: PyPI Package
|
|
+.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd?logo=python&logoColor=yellow
|
|
+ :target: https://pypi.org/project/aiosmtpd/
|
|
+ :alt: Supported Python Versions
|
|
.. .. For |GA badge|, don't forget to check actual workflow name in unit-testing-and-coverage.yml
|
|
.. |GA badge| image:: https://github.com/aio-libs/aiosmtpd/workflows/aiosmtpd%20CI/badge.svg
|
|
:target: https://github.com/aio-libs/aiosmtpd/actions
|
|
@@ -20,15 +28,9 @@
|
|
.. |LGTM.com| image:: https://img.shields.io/lgtm/grade/python/github/aio-libs/aiosmtpd.svg?logo=lgtm&logoWidth=18
|
|
:target: https://lgtm.com/projects/g/aio-libs/aiosmtpd/context:python
|
|
:alt: Semmle/LGTM.com quality
|
|
-.. |readthedocs| image:: https://readthedocs.org/projects/aiosmtpd/badge/?version=latest
|
|
- :target: https://aiosmtpd.readthedocs.io/en/latest/?badge=latest
|
|
+.. |readthedocs| image:: https://img.shields.io/readthedocs/aiosmtpd?logo=Read+the+Docs&logoColor=white
|
|
+ :target: https://aiosmtpd.readthedocs.io/en/latest/
|
|
:alt: Documentation Status
|
|
-.. |PyPI| image:: https://badge.fury.io/py/aiosmtpd.svg
|
|
- :target: https://badge.fury.io/py/aiosmtpd
|
|
- :alt: PyPI Package
|
|
-.. |PyPI Python| image:: https://img.shields.io/pypi/pyversions/aiosmtpd.svg
|
|
- :target: https://pypi.org/project/aiosmtpd/
|
|
- :alt: Supported Python Versions
|
|
.. .. If you edit the above badges, don't forget to edit setup.cfg
|
|
.. .. The |Discourse| badge MUST NOT be included in setup.cfg
|
|
.. |Discourse| image:: https://img.shields.io/discourse/status?server=https%3A%2F%2Faio-libs.discourse.group%2F&style=social
|
|
diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py
|
|
index 7d459d8..9c7b938 100644
|
|
--- a/aiosmtpd/__init__.py
|
|
+++ b/aiosmtpd/__init__.py
|
|
@@ -1,4 +1,4 @@
|
|
# Copyright 2014-2021 The aiosmtpd Developers
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
-__version__ = "1.4.2"
|
|
+__version__ = "1.5.0a1"
|
|
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
|
|
index 2258c54..d3345b8 100644
|
|
--- a/aiosmtpd/controller.py
|
|
+++ b/aiosmtpd/controller.py
|
|
@@ -5,6 +5,7 @@ import asyncio
|
|
import errno
|
|
import os
|
|
import ssl
|
|
+import sys
|
|
import threading
|
|
import time
|
|
from abc import ABCMeta, abstractmethod
|
|
@@ -19,6 +20,11 @@ try:
|
|
except ImportError: # pragma: on-not-win32
|
|
AF_UNIX = None
|
|
from typing import Any, Coroutine, Dict, Optional, Union
|
|
+
|
|
+if sys.version_info >= (3, 8):
|
|
+ from typing import Literal # pragma: py-lt-38
|
|
+else: # pragma: py-ge-38
|
|
+ from typing_extensions import Literal
|
|
from warnings import warn
|
|
|
|
from public import public
|
|
@@ -38,13 +44,14 @@ class IP6_IS:
|
|
YES = {errno.EADDRINUSE}
|
|
|
|
|
|
-def _has_ipv6():
|
|
+def _has_ipv6() -> bool:
|
|
# Helper function to assist in mocking
|
|
return has_ipv6
|
|
|
|
|
|
@public
|
|
-def get_localhost() -> str:
|
|
+def get_localhost() -> Literal["::1", "127.0.0.1"]:
|
|
+ """Returns numeric address to localhost depending on IPv6 availability"""
|
|
# Ref:
|
|
# - https://github.com/urllib3/urllib3/pull/611#issuecomment-100954017
|
|
# - https://github.com/python/cpython/blob/ :
|
|
@@ -91,24 +98,17 @@ class _FakeServer(asyncio.StreamReaderProtocol):
|
|
|
|
|
|
@public
|
|
-class BaseThreadedController(metaclass=ABCMeta):
|
|
- """
|
|
- `Documentation can be found here
|
|
- <https://aiosmtpd.readthedocs.io/en/latest/controller.html>`_.
|
|
- """
|
|
+class BaseController(metaclass=ABCMeta):
|
|
+ smtpd = None
|
|
server: Optional[AsyncServer] = None
|
|
server_coro: Optional[Coroutine] = None
|
|
- smtpd = None
|
|
- _factory_invoked: Optional[threading.Event] = None
|
|
- _thread: Optional[threading.Thread] = None
|
|
- _thread_exception: Optional[Exception] = None
|
|
+ _factory_invoked: threading.Event = None
|
|
|
|
def __init__(
|
|
self,
|
|
- handler,
|
|
- loop=None,
|
|
+ handler: Any,
|
|
+ loop: asyncio.AbstractEventLoop = None,
|
|
*,
|
|
- ready_timeout: float,
|
|
ssl_context: Optional[ssl.SSLContext] = None,
|
|
# SMTP parameters
|
|
server_hostname: Optional[str] = None,
|
|
@@ -119,9 +119,6 @@ class BaseThreadedController(metaclass=ABCMeta):
|
|
self.loop = asyncio.new_event_loop()
|
|
else:
|
|
self.loop = loop
|
|
- self.ready_timeout = float(
|
|
- os.getenv("AIOSMTPD_CONTROLLER_TIMEOUT", ready_timeout)
|
|
- )
|
|
self.ssl_context = ssl_context
|
|
self.SMTP_kwargs: Dict[str, Any] = {}
|
|
if "server_kwargs" in SMTP_parameters:
|
|
@@ -139,9 +136,11 @@ class BaseThreadedController(metaclass=ABCMeta):
|
|
# It actually conflicts with SMTP class's default, but the reasoning is
|
|
# discussed in the docs.
|
|
self.SMTP_kwargs.setdefault("enable_SMTPUTF8", True)
|
|
+ #
|
|
+ self._factory_invoked = threading.Event()
|
|
|
|
def factory(self):
|
|
- """Allow subclasses to customize the handler/server creation."""
|
|
+ """Subclasses can override this to customize the handler/server creation."""
|
|
return SMTP(self.handler, **self.SMTP_kwargs)
|
|
|
|
def _factory_invoker(self):
|
|
@@ -159,13 +158,72 @@ class BaseThreadedController(metaclass=ABCMeta):
|
|
|
|
@abstractmethod
|
|
def _create_server(self) -> Coroutine:
|
|
- raise NotImplementedError # pragma: nocover
|
|
+ """
|
|
+ Overridden by subclasses to actually perform the async binding to the
|
|
+ listener endpoint. When overridden, MUST refer the _factory_invoker() method.
|
|
+ """
|
|
+ raise NotImplementedError
|
|
+
|
|
+ def _cleanup(self):
|
|
+ """Reset internal variables to prevent contamination"""
|
|
+ self._thread_exception = None
|
|
+ self._factory_invoked.clear()
|
|
+ self.server_coro = None
|
|
+ self.server = None
|
|
+ self.smtpd = None
|
|
+
|
|
+ def cancel_tasks(self, stop_loop: bool = True):
|
|
+ """
|
|
+ Convenience method to stop the loop and cancel all tasks.
|
|
+ Use loop.call_soon_threadsafe() to invoke this.
|
|
+ """
|
|
+ if stop_loop: # pragma: nobranch
|
|
+ self.loop.stop()
|
|
+ try:
|
|
+ _all_tasks = asyncio.all_tasks # pytype: disable=module-attr
|
|
+ except AttributeError: # pragma: py-gt-36
|
|
+ _all_tasks = asyncio.Task.all_tasks
|
|
+ for task in _all_tasks(self.loop):
|
|
+ # This needs to be invoked in a thread-safe way
|
|
+ task.cancel()
|
|
+
|
|
+
|
|
+@public
|
|
+class BaseThreadedController(BaseController, metaclass=ABCMeta):
|
|
+ _thread: Optional[threading.Thread] = None
|
|
+ _thread_exception: Optional[Exception] = None
|
|
+
|
|
+ def __init__(
|
|
+ self,
|
|
+ handler: Any,
|
|
+ loop: asyncio.AbstractEventLoop = None,
|
|
+ *,
|
|
+ ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
|
+ ssl_context: Optional[ssl.SSLContext] = None,
|
|
+ # SMTP parameters
|
|
+ server_hostname: Optional[str] = None,
|
|
+ **SMTP_parameters,
|
|
+ ):
|
|
+ super().__init__(
|
|
+ handler,
|
|
+ loop,
|
|
+ ssl_context=ssl_context,
|
|
+ server_hostname=server_hostname,
|
|
+ **SMTP_parameters,
|
|
+ )
|
|
+ self.ready_timeout = float(
|
|
+ os.getenv("AIOSMTPD_CONTROLLER_TIMEOUT", ready_timeout)
|
|
+ )
|
|
|
|
@abstractmethod
|
|
def _trigger_server(self):
|
|
- raise NotImplementedError # pragma: nocover
|
|
+ """
|
|
+ Overridden by subclasses to trigger asyncio to actually initialize the SMTP
|
|
+ class (it's lazy initialization, done only on initial connection).
|
|
+ """
|
|
+ raise NotImplementedError
|
|
|
|
- def _run(self, ready_event):
|
|
+ def _run(self, ready_event: threading.Event):
|
|
asyncio.set_event_loop(self.loop)
|
|
try:
|
|
# Need to do two-step assignments here to ensure IDEs can properly
|
|
@@ -187,14 +245,19 @@ class BaseThreadedController(metaclass=ABCMeta):
|
|
return
|
|
self.loop.call_soon(ready_event.set)
|
|
self.loop.run_forever()
|
|
+ # We reach this point when loop is ended (by external code)
|
|
+ # Perform some stoppages to ensure endpoint no longer bound.
|
|
self.server.close()
|
|
self.loop.run_until_complete(self.server.wait_closed())
|
|
self.loop.close()
|
|
self.server = None
|
|
|
|
def start(self):
|
|
+ """
|
|
+ Start a thread and run the asyncio event loop in that thread
|
|
+ """
|
|
assert self._thread is None, "SMTP daemon already running"
|
|
- self._factory_invoked = threading.Event()
|
|
+ self._factory_invoked.clear()
|
|
|
|
ready_event = threading.Event()
|
|
self._thread = threading.Thread(target=self._run, args=(ready_event,))
|
|
@@ -240,43 +303,26 @@ class BaseThreadedController(metaclass=ABCMeta):
|
|
if self.smtpd is None:
|
|
raise RuntimeError("Unknown Error, failed to init SMTP server")
|
|
|
|
- def _stop(self):
|
|
- self.loop.stop()
|
|
- try:
|
|
- _all_tasks = asyncio.all_tasks # pytype: disable=module-attr
|
|
- except AttributeError: # pragma: py-gt-36
|
|
- _all_tasks = asyncio.Task.all_tasks
|
|
- for task in _all_tasks(self.loop):
|
|
- task.cancel()
|
|
-
|
|
- def stop(self, no_assert=False):
|
|
+ def stop(self, no_assert: bool = False):
|
|
+ """
|
|
+ Stop the loop, the tasks in the loop, and terminate the thread as well.
|
|
+ """
|
|
assert no_assert or self._thread is not None, "SMTP daemon not running"
|
|
- self.loop.call_soon_threadsafe(self._stop)
|
|
+ self.loop.call_soon_threadsafe(self.cancel_tasks)
|
|
if self._thread is not None:
|
|
self._thread.join()
|
|
self._thread = None
|
|
- self._thread_exception = None
|
|
- self._factory_invoked = None
|
|
- self.server_coro = None
|
|
- self.server = None
|
|
- self.smtpd = None
|
|
+ self._cleanup()
|
|
|
|
|
|
@public
|
|
-class Controller(BaseThreadedController):
|
|
- """
|
|
- `Documentation can be found here
|
|
- <https://aiosmtpd.readthedocs.io/en/latest/controller.html>`_.
|
|
- """
|
|
+class BaseUnthreadedController(BaseController, metaclass=ABCMeta):
|
|
def __init__(
|
|
self,
|
|
- handler,
|
|
- hostname: Optional[str] = None,
|
|
- port: int = 8025,
|
|
- loop=None,
|
|
+ handler: Any,
|
|
+ loop: asyncio.AbstractEventLoop = None,
|
|
*,
|
|
- ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
|
- ssl_context: ssl.SSLContext = None,
|
|
+ ssl_context: Optional[ssl.SSLContext] = None,
|
|
# SMTP parameters
|
|
server_hostname: Optional[str] = None,
|
|
**SMTP_parameters,
|
|
@@ -284,15 +330,80 @@ class Controller(BaseThreadedController):
|
|
super().__init__(
|
|
handler,
|
|
loop,
|
|
- ready_timeout=ready_timeout,
|
|
+ ssl_context=ssl_context,
|
|
server_hostname=server_hostname,
|
|
- **SMTP_parameters
|
|
+ **SMTP_parameters,
|
|
)
|
|
- self.hostname = get_localhost() if hostname is None else hostname
|
|
+ self.ended = threading.Event()
|
|
+
|
|
+ def begin(self):
|
|
+ """
|
|
+ Sets up the asyncio server task and inject it into the asyncio event loop.
|
|
+ Does NOT actually start the event loop itself.
|
|
+ """
|
|
+ asyncio.set_event_loop(self.loop)
|
|
+ # Need to do two-step assignments here to ensure IDEs can properly
|
|
+ # detect the types of the vars. Cannot use `assert isinstance`, because
|
|
+ # Python 3.6 in asyncio debug mode has a bug wherein CoroWrapper is not
|
|
+ # an instance of Coroutine
|
|
+ self.server_coro = self._create_server()
|
|
+ srv: AsyncServer = self.loop.run_until_complete(self.server_coro)
|
|
+ self.server = srv
|
|
+
|
|
+ async def finalize(self):
|
|
+ """
|
|
+ Perform orderly closing of the server listener.
|
|
+ NOTE: This is an async method; await this from an async or use
|
|
+ loop.create_task() (if loop is still running), or
|
|
+ loop.run_until_complete() (if loop has stopped)
|
|
+ """
|
|
+ self.ended.clear()
|
|
+ server = self.server
|
|
+ server.close()
|
|
+ await server.wait_closed()
|
|
+ self.server_coro.close()
|
|
+ self._cleanup()
|
|
+ self.ended.set()
|
|
+
|
|
+ def end(self):
|
|
+ """
|
|
+ Convenience method to asynchronously invoke finalize().
|
|
+ Consider using loop.call_soon_threadsafe to invoke this method, especially
|
|
+ if your loop is running in a different thread. You can afterwards .wait() on
|
|
+ ended attribute (a threading.Event) to check for completion, if needed.
|
|
+ """
|
|
+ self.ended.clear()
|
|
+ if self.loop.is_running():
|
|
+ self.loop.create_task(self.finalize())
|
|
+ else:
|
|
+ self.loop.run_until_complete(self.finalize())
|
|
+
|
|
+
|
|
+@public
|
|
+class InetMixin(BaseController, metaclass=ABCMeta):
|
|
+ def __init__(
|
|
+ self,
|
|
+ handler: Any,
|
|
+ hostname: Optional[str] = None,
|
|
+ port: int = 8025,
|
|
+ loop: asyncio.AbstractEventLoop = None,
|
|
+ **kwargs,
|
|
+ ):
|
|
+ super().__init__(
|
|
+ handler,
|
|
+ loop,
|
|
+ **kwargs,
|
|
+ )
|
|
+ self._localhost = get_localhost()
|
|
+ self.hostname = self._localhost if hostname is None else hostname
|
|
self.port = port
|
|
- self.ssl_context = ssl_context
|
|
|
|
def _create_server(self) -> Coroutine:
|
|
+ """
|
|
+ Creates a 'server task' that listens on an INET host:port.
|
|
+ Does NOT actually start the protocol object itself;
|
|
+ _factory_invoker() is only called upon fist connection attempt.
|
|
+ """
|
|
return self.loop.create_server(
|
|
self._factory_invoker,
|
|
host=self.hostname,
|
|
@@ -308,42 +419,36 @@ class Controller(BaseThreadedController):
|
|
"""
|
|
# At this point, if self.hostname is Falsy, it most likely is "" (bind to all
|
|
# addresses). In such case, it should be safe to connect to localhost)
|
|
- hostname = self.hostname or get_localhost()
|
|
+ hostname = self.hostname or self._localhost
|
|
with ExitStack() as stk:
|
|
s = stk.enter_context(create_connection((hostname, self.port), 1.0))
|
|
if self.ssl_context:
|
|
s = stk.enter_context(self.ssl_context.wrap_socket(s))
|
|
- _ = s.recv(1024)
|
|
+ s.recv(1024)
|
|
|
|
|
|
-class UnixSocketController(BaseThreadedController): # pragma: on-win32 on-cygwin
|
|
- """
|
|
- `Documentation can be found here
|
|
- <https://aiosmtpd.readthedocs.io/en/latest/controller.html>`_.
|
|
- """
|
|
+@public
|
|
+class UnixSocketMixin(BaseController, metaclass=ABCMeta): # pragma: no-unixsock
|
|
def __init__(
|
|
self,
|
|
- handler,
|
|
- unix_socket: Optional[Union[str, Path]],
|
|
- loop=None,
|
|
- *,
|
|
- ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
|
- ssl_context: ssl.SSLContext = None,
|
|
- # SMTP parameters
|
|
- server_hostname: str = None,
|
|
- **SMTP_parameters,
|
|
+ handler: Any,
|
|
+ unix_socket: Union[str, Path],
|
|
+ loop: asyncio.AbstractEventLoop = None,
|
|
+ **kwargs,
|
|
):
|
|
super().__init__(
|
|
handler,
|
|
loop,
|
|
- ready_timeout=ready_timeout,
|
|
- ssl_context=ssl_context,
|
|
- server_hostname=server_hostname,
|
|
- **SMTP_parameters
|
|
+ **kwargs,
|
|
)
|
|
self.unix_socket = str(unix_socket)
|
|
|
|
def _create_server(self) -> Coroutine:
|
|
+ """
|
|
+ Creates a 'server task' that listens on a Unix Socket file.
|
|
+ Does NOT actually start the protocol object itself;
|
|
+ _factory_invoker() is only called upon fist connection attempt.
|
|
+ """
|
|
return self.loop.create_unix_server(
|
|
self._factory_invoker,
|
|
path=self.unix_socket,
|
|
@@ -351,9 +456,52 @@ class UnixSocketController(BaseThreadedController): # pragma: on-win32 on-cygwi
|
|
)
|
|
|
|
def _trigger_server(self):
|
|
+ """
|
|
+ Opens a socket connection to the newly launched server, wrapping in an SSL
|
|
+ Context if necessary, and read some data from it to ensure that factory()
|
|
+ gets invoked.
|
|
+ """
|
|
with ExitStack() as stk:
|
|
s: makesock = stk.enter_context(makesock(AF_UNIX, SOCK_STREAM))
|
|
s.connect(self.unix_socket)
|
|
if self.ssl_context:
|
|
s = stk.enter_context(self.ssl_context.wrap_socket(s))
|
|
- _ = s.recv(1024)
|
|
+ s.recv(1024)
|
|
+
|
|
+
|
|
+@public
|
|
+class Controller(InetMixin, BaseThreadedController):
|
|
+ """Provides a multithreaded controller that listens on an INET endpoint"""
|
|
+
|
|
+ def _trigger_server(self):
|
|
+ # Prevent confusion on which _trigger_server() to invoke.
|
|
+ # Or so LGTM.com claimed
|
|
+ InetMixin._trigger_server(self)
|
|
+
|
|
+
|
|
+@public
|
|
+class UnixSocketController( # pragma: no-unixsock
|
|
+ UnixSocketMixin, BaseThreadedController
|
|
+):
|
|
+ """Provides a multithreaded controller that listens on a Unix Socket file"""
|
|
+
|
|
+ def _trigger_server(self): # pragma: no-unixsock
|
|
+ # Prevent confusion on which _trigger_server() to invoke.
|
|
+ # Or so LGTM.com claimed
|
|
+ UnixSocketMixin._trigger_server(self)
|
|
+
|
|
+
|
|
+@public
|
|
+class UnthreadedController(InetMixin, BaseUnthreadedController):
|
|
+ """Provides an unthreaded controller that listens on an INET endpoint"""
|
|
+
|
|
+ pass
|
|
+
|
|
+
|
|
+@public
|
|
+class UnixSocketUnthreadedController( # pragma: no-unixsock
|
|
+ UnixSocketMixin, BaseUnthreadedController
|
|
+):
|
|
+ """Provides an unthreaded controller that listens on a Unix Socket file"""
|
|
+
|
|
+ pass
|
|
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
|
|
index fb32de4..ce627a7 100644
|
|
--- a/aiosmtpd/docs/NEWS.rst
|
|
+++ b/aiosmtpd/docs/NEWS.rst
|
|
@@ -3,6 +3,18 @@
|
|
###################
|
|
|
|
|
|
+1.5.0 (aiosmtpd-next-next)
|
|
+==========================
|
|
+
|
|
+Added
|
|
+-----
|
|
+* Unthreaded Controllers (Closes #160)
|
|
+
|
|
+Fixed/Improved
|
|
+--------------
|
|
+* All Controllers now have more rationale design, as they are now composited from a Base + a Mixin
|
|
+
|
|
+
|
|
1.4.2 (2021-03-08)
|
|
=====================
|
|
|
|
diff --git a/aiosmtpd/docs/controller.rst b/aiosmtpd/docs/controller.rst
|
|
index d3e08ed..e43720b 100644
|
|
--- a/aiosmtpd/docs/controller.rst
|
|
+++ b/aiosmtpd/docs/controller.rst
|
|
@@ -5,15 +5,15 @@
|
|
====================
|
|
|
|
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.
|
|
+the :class:`~aiosmtpd.smtp.SMTP` class as the *protocol factory*, and then run the loop forever.
|
|
If you need to pass arguments to the ``SMTP`` constructor, use
|
|
:func:`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
|
|
+It's probably easier to use a *threaded 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
|
|
+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.
|
|
|
|
@@ -30,7 +30,7 @@ Using the controller
|
|
TCP-based Server
|
|
----------------
|
|
|
|
-The :class:`Controller` class creates a TCP-based server,
|
|
+The :class:`~aiosmtpd.controller.Controller` class creates a TCP-based server,
|
|
listening on an Internet endpoint (i.e., ``ip_address:port`` pair).
|
|
|
|
Say you want to receive email for ``example.com`` and print incoming mail data
|
|
@@ -100,11 +100,11 @@ Connect to the server and send a message, which then gets printed by
|
|
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
|
|
+:meth:`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
|
|
+The ``ExampleHandler`` class also implements a :meth:`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
|
|
@@ -148,10 +148,11 @@ 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 <hooks>`.
|
|
|
|
+
|
|
Unix Socket-based Server
|
|
------------------------
|
|
|
|
-The :class:`UnixSocketController` class creates a server listening to
|
|
+The :class:`~aiosmtpd.controller.UnixSocketController` class creates a server listening to
|
|
a Unix Socket (i.e., a special file that can act as a 'pipe' for interprocess
|
|
communication).
|
|
|
|
@@ -168,8 +169,13 @@ with some differences:
|
|
>>> controller = UnixSocketController(Sink(), unix_socket="smtp_socket~")
|
|
>>> controller.start()
|
|
|
|
+.. warning::
|
|
+
|
|
+ Do not exceed the Operating System limit for the length of the socket file path.
|
|
+ On Linux, the limit is 108 characters. On BSD OSes, it's 104 characters.
|
|
+
|
|
**Rather than connecting to IP:port, you connect to the Socket file.**
|
|
-Python's :class:`smtplib.SMTP` sadly cannot connect to a Unix Socket,
|
|
+Python's :class:`smtplib.SMTP` class sadly cannot connect to a Unix Socket,
|
|
so we need to handle it on our own here:
|
|
|
|
.. doctest:: unix_socket
|
|
@@ -178,9 +184,8 @@ so we need to handle it on our own here:
|
|
>>> import socket
|
|
>>> sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
>>> sock.connect("smtp_socket~")
|
|
- >>> resp = sock.recv(1024)
|
|
- >>> resp[0:4]
|
|
- b'220 '
|
|
+ >>> sock.recv(1024)
|
|
+ b'220 ...'
|
|
|
|
Try sending something, don't forget to end with ``"\r\n"``:
|
|
|
|
@@ -189,9 +194,8 @@ Try sending something, don't forget to end with ``"\r\n"``:
|
|
|
|
>>> sock.send(b"HELO example.org\r\n")
|
|
18
|
|
- >>> resp = sock.recv(1024)
|
|
- >>> resp[0:4]
|
|
- b'250 '
|
|
+ >>> sock.recv(1024)
|
|
+ b'250 ...'
|
|
|
|
And close everything when done:
|
|
|
|
@@ -200,13 +204,116 @@ And close everything when done:
|
|
|
|
>>> sock.send(b"QUIT\r\n")
|
|
6
|
|
- >>> resp = sock.recv(1024)
|
|
- >>> resp[0:4]
|
|
- b'221 '
|
|
+ >>> sock.recv(1024)
|
|
+ b'221 Bye...'
|
|
>>> sock.close()
|
|
>>> controller.stop()
|
|
|
|
|
|
+.. _unthreaded:
|
|
+
|
|
+Unthreaded Controllers
|
|
+----------------------
|
|
+
|
|
+In addition to the **threaded** controllers described above,
|
|
+``aiosmtpd`` also provides the following **UNthreaded** controllers:
|
|
+
|
|
+* :class:`UnthreadedController` -- the unthreaded version of :class:`Controller`
|
|
+* :class:`UnixSocketUnthreadedController` -- the unthreaded version of :class:`UnixSocketController`
|
|
+
|
|
+These classes are considered *advanced* classes,
|
|
+because you'll have to manage the event loop yourself.
|
|
+
|
|
+For example, to start an unthreaded controller,
|
|
+you'll have to do something similar to this:
|
|
+
|
|
+.. doctest:: unthreaded
|
|
+
|
|
+ >>> import asyncio
|
|
+ >>> loop = asyncio.get_event_loop()
|
|
+ >>> from aiosmtpd.controller import UnthreadedController
|
|
+ >>> from aiosmtpd.handlers import Sink
|
|
+ >>> controller = UnthreadedController(Sink(), loop=loop)
|
|
+ >>> controller.begin()
|
|
+
|
|
+Note that unlike the threaded counterparts,
|
|
+the method used to start the controller is named ``begin()``.
|
|
+And unlike the method in the threaded version,
|
|
+``begin()`` does NOT start the asyncio event loop;
|
|
+you'll have to start it yourself.
|
|
+
|
|
+For the purposes of trying this,
|
|
+let's create a thread and have it run the asyncio event loop;
|
|
+we'll also schedule an autostop so it won't hang:
|
|
+
|
|
+.. doctest:: unthreaded
|
|
+
|
|
+ >>> def runner():
|
|
+ ... # Set the delay to something long enough so you have time
|
|
+ ... # to do some testing
|
|
+ ... loop.call_later(3.0, loop.stop)
|
|
+ ... loop.run_forever()
|
|
+ >>> import threading
|
|
+ >>> thread = threading.Thread(target=runner)
|
|
+ >>> thread.setDaemon(True)
|
|
+ >>> thread.start()
|
|
+ >>> import time
|
|
+ >>> time.sleep(0.1) # Allow the loop to begin
|
|
+
|
|
+At this point in time, the server would be listening:
|
|
+
|
|
+.. doctest:: unthreaded
|
|
+
|
|
+ >>> from smtplib import SMTP as Client
|
|
+ >>> client = Client(controller.hostname, controller.port)
|
|
+ >>> client.helo("example.com")
|
|
+ (250, ...)
|
|
+ >>> client.quit()
|
|
+ (221, b'Bye')
|
|
+
|
|
+The complex thing will be to end it;
|
|
+that is why we're marking these classes as "advanced".
|
|
+
|
|
+For our example here,
|
|
+since we have created an "autostop loop",
|
|
+all we have to do is wait for the runner thread to end:
|
|
+
|
|
+.. doctest:: unthreaded
|
|
+
|
|
+ >>> thread.join()
|
|
+ >>> loop.is_running()
|
|
+ False
|
|
+
|
|
+We still need to do some cleanup to fully release the bound port.
|
|
+Since the loop has ended, we can simply call the :meth:`end` method:
|
|
+
|
|
+.. doctest:: unthreaded
|
|
+
|
|
+ >>> controller.end()
|
|
+
|
|
+If you want to end the controller *but* keep the loop running,
|
|
+you'll have to do it like this::
|
|
+
|
|
+ loop.call_soon_threadsafe(controller.end)
|
|
+ # If you want to ensure that controller has stopped, you can wait() here:
|
|
+ controller.ended.wait(10.0) # Optional
|
|
+
|
|
+You must remember to cleanup the canceled tasks yourself.
|
|
+We have provided a convenience method,
|
|
+:meth:`~aiosmtpd.controller.BaseController.cancel_tasks`::
|
|
+
|
|
+ # Will also stop the loop!
|
|
+ loop.call_soon_threadsafe(controller.cancel_tasks)
|
|
+
|
|
+(If you invoke ``cancel_tasks`` with the parameter ``stop_loop=False``,
|
|
+then loop will NOT be stopped.
|
|
+That is a much too-advanced topic and we will not discuss it further in this documentation.)
|
|
+
|
|
+The Unix Socket variant, ``UnixSocketUnthreadedController``, works in the same way.
|
|
+The difference is only in how to access the server, i.e., through a Unix Socket instead of TCP/IP.
|
|
+We'll leave out the details for you to figure it out yourself.
|
|
+
|
|
+
|
|
.. _enablesmtputf8:
|
|
|
|
Enabling SMTPUTF8
|
|
@@ -253,265 +360,398 @@ Controller API
|
|
|
|
.. py:module:: aiosmtpd.controller
|
|
|
|
-.. class:: IP6_IS
|
|
|
|
- .. py:attribute:: NO
|
|
- :type: set
|
|
+.. py:data:: DEFAULT_READY_TIMEOUT
|
|
+ :type: float
|
|
+ :value: 5.0
|
|
+
|
|
+
|
|
+.. py:function:: get_localhost()
|
|
|
|
- Contains constants from :mod:`errno` that will be raised by `socket.bind()`
|
|
- if IPv6 is not available on the system.
|
|
+ :return: The numeric address of the loopback interface; ``"::1"`` if IPv6 is supported,
|
|
+ ``"127.0.0.1"`` if IPv6 is not supported.
|
|
+ :rtype: Literal["::1", "127.0.0.1"]
|
|
+
|
|
+
|
|
+.. class:: IP6_IS
|
|
|
|
- .. important::
|
|
+ .. py:attribute:: NO
|
|
+ :type: set[int]
|
|
|
|
- If your system does not have IPv6 support but :func:`get_localhost`
|
|
- raises an error instead of returning ``"127.0.0.1"``,
|
|
- you can add the error number into this attribute.
|
|
+ Contains constants from :mod:`errno` that will be raised by :meth:`socket.socket.bind`
|
|
+ if IPv6 is NOT available on the system.
|
|
|
|
.. py:attribute:: YES
|
|
- :type: set
|
|
+ :type: set[int]
|
|
|
|
- Contains constants from :mod:`errno` that will be raised by `socket.bind()`
|
|
- if IPv6 is not available on the system.
|
|
+ Contains constants from :mod:`errno` that will be raised by :meth:`socket.socket.bind`
|
|
+ if IPv6 IS available on the system.
|
|
|
|
-.. py:function:: get_localhost
|
|
+ .. note::
|
|
|
|
- :return: The numeric address of the loopback interface; ``"::1"`` if IPv6 is supported,
|
|
- ``"127.0.0.1"`` if IPv6 is not supported.
|
|
- :rtype: str
|
|
+ You can customize the contents of these attributes by adding/removing from them,
|
|
+ in case the behavior does not align with your expectations *and*
|
|
+ you cannot wait for a patch to be merged.
|
|
|
|
-.. class:: BaseThreadedController(\
|
|
- handler, \
|
|
- loop=None, \
|
|
- *, \
|
|
- ready_timeout, \
|
|
- ssl_context=None, \
|
|
- server_hostname=None, server_kwargs=None, **SMTP_parameters)
|
|
|
|
- :param handler: Handler object
|
|
- :param loop: The asyncio event loop in which the server will run.
|
|
- If not given, :func:`asyncio.new_event_loop` will be called to create the event loop.
|
|
- :param ready_timeout: How long to wait until server starts.
|
|
- The :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` takes precedence over this parameter.
|
|
- See :attr:`ready_timeout` for more information.
|
|
- :type ready_timeout: float
|
|
- :param ssl_context: SSL Context to wrap the socket in.
|
|
- Will be passed-through to :meth:`~asyncio.loop.create_server` method
|
|
- :type ssl_context: ssl.SSLContext
|
|
- :param server_hostname: Server's hostname,
|
|
- will be passed-through as ``hostname`` parameter of :class:`~aiosmtpd.smtp.SMTP`
|
|
- :type server_hostname: Optional[str]
|
|
- :param server_kwargs: (DEPRECATED) A dict that
|
|
- will be passed-through as keyword arguments of :class:`~aiosmtpd.smtp.SMTP`.
|
|
- Explicitly listed keyword arguments going into ``**SMTP_parameters``
|
|
- will take precedence over this parameter
|
|
- :type server_kwargs: Dict[str, Any]
|
|
- :param SMTP_parameters: Optional keyword arguments that
|
|
- will be passed-through as keyword arguments of :class:`~aiosmtpd.smtp.SMTP`
|
|
+.. class:: BaseController(\
|
|
+ handler, \
|
|
+ loop=None, \
|
|
+ *, \
|
|
+ ssl_context=None, \
|
|
+ server_hostname=None, \
|
|
+ server_kwargs=None, \
|
|
+ **SMTP_parameters, \
|
|
+ )
|
|
|
|
- .. important::
|
|
+ This **Abstract Base Class** defines parameters, attributes, and methods common between
|
|
+ all concrete controller classes.
|
|
|
|
- Usually, setting the ``ssl_context`` parameter will switch the protocol to ``SMTPS`` mode,
|
|
- implying unconditional encryption of the connection,
|
|
- and preventing the use of the ``STARTTLS`` mechanism.
|
|
+ :param handler: Handler object
|
|
+ :param loop: The asyncio event loop in which the server will run.
|
|
+ If not given, :func:`asyncio.new_event_loop` will be called to create the event loop.
|
|
+ :type loop: asyncio.AbstractEventLoop
|
|
+ :param ssl_context: SSL Context to wrap the socket in.
|
|
+ Will be passed-through to :meth:`~asyncio.loop.create_server` method
|
|
+ :type ssl_context: ssl.SSLContext
|
|
+ :param server_hostname: Server's hostname,
|
|
+ will be passed-through as ``hostname`` parameter of :class:`~aiosmtpd.smtp.SMTP`
|
|
+ :type server_hostname: Optional[str]
|
|
+ :param server_kwargs: *(DEPRECATED)* A dict that will be passed-through as keyword
|
|
+ arguments of :class:`~aiosmtpd.smtp.SMTP`.
|
|
+ This is DEPRECATED; please use ``**SMTP_parameters`` instead.
|
|
+ :type server_kwargs: dict
|
|
+ :param SMTP_parameters: Optional keyword arguments that
|
|
+ will be passed-through as keyword arguments of :class:`~aiosmtpd.smtp.SMTP`
|
|
|
|
- Actual behavior depends on the subclass's implementation.
|
|
+ |
|
|
+ | :part:`Attributes`
|
|
|
|
- |
|
|
- | :part:`Attributes`
|
|
+ .. attribute:: handler
|
|
+ :noindex:
|
|
|
|
- .. attribute:: handler
|
|
- :noindex:
|
|
+ The instance of the event *handler* passed to the constructor.
|
|
|
|
- The instance of the event *handler* passed to the constructor.
|
|
+ .. attribute:: loop
|
|
+ :noindex:
|
|
|
|
- .. attribute:: loop
|
|
- :noindex:
|
|
+ The event loop being used.
|
|
|
|
- The event loop being used.
|
|
+ .. attribute:: server
|
|
|
|
- .. attribute:: ready_timeout
|
|
- :type: float
|
|
+ This is the server instance returned by
|
|
+ :meth:`_create_server` after the server has started.
|
|
|
|
- The timeout value used to wait for the server to start.
|
|
+ You can retrieve the :class:`~socket.socket` objects the server is listening on
|
|
+ from the ``server.sockets`` attribute.
|
|
|
|
- This will either be the value of
|
|
- the :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` environment variable (converted to float),
|
|
- or the :attr:`ready_timeout` parameter.
|
|
+ .. py:attribute:: smtpd
|
|
+ :type: aiosmtpd.smtp.SMTP
|
|
|
|
- Setting this to a high value will NOT slow down controller startup,
|
|
- because it's a timeout limit rather than a sleep delay.
|
|
- However, you may want to reduce the default value to something 'just enough'
|
|
- so you don't have to wait too long for an exception, if problem arises.
|
|
+ The server instance (of class SMTP) created by :meth:`factory` after
|
|
+ the controller is started.
|
|
|
|
- If this timeout is breached, a :class:`TimeoutError` exception will be raised.
|
|
+ |
|
|
+ | :part:`Methods`
|
|
|
|
- .. attribute:: server
|
|
+ .. method:: factory() -> aiosmtpd.smtp.SMTP
|
|
|
|
- This is the server instance returned by
|
|
- :meth:`_create_server` after the server has started.
|
|
+ You can override this method to create custom instances of
|
|
+ the :class:`~aiosmtpd.smtp.SMTP` class being controlled.
|
|
|
|
- .. py:attribute:: smtpd
|
|
- :type: aiosmtpd.smtp.SMTP
|
|
+ By default, this creates an ``SMTP`` instance,
|
|
+ passing in your handler and setting flags from the :attr:`**SMTP_Parameters` parameter.
|
|
|
|
- The server instance (of class SMTP) created by :meth:`factory` after
|
|
- the controller is started.
|
|
+ Examples of why you would want to override this method include
|
|
+ creating an :ref:`LMTP <LMTP>` server instance instead of the standard ``SMTP`` server.
|
|
|
|
- |
|
|
- | :part:`Methods`
|
|
+ .. py:method:: cancel_tasks(stop_loop=True)
|
|
|
|
- .. py:method:: _create_server() -> Coroutine
|
|
- :abstractmethod:
|
|
+ :param stop_loop: If ``True``, stops the loop before canceling tasks.
|
|
+ :type stop_loop: bool
|
|
|
|
- This method will be called by :meth:`_run` during :meth:`start` procedure.
|
|
+ This is a convenience class that will stop the loop &
|
|
+ cancel all asyncio tasks for you.
|
|
|
|
- It must return a ``Coroutine`` object which will be executed by the asyncio event loop.
|
|
|
|
- .. py:method:: _trigger_server() -> None
|
|
- :abstractmethod:
|
|
+.. class:: Controller(\
|
|
+ handler, \
|
|
+ hostname=None, \
|
|
+ port=8025, \
|
|
+ loop=None, \
|
|
+ *, \
|
|
+ ready_timeout=DEFAULT_READY_TIMEOUT, \
|
|
+ ssl_context=None, \
|
|
+ server_hostname=None, \
|
|
+ server_kwargs=None, \
|
|
+ **SMTP_parameters)
|
|
|
|
- The :meth:`asyncio.loop.create_server` method (or its parallel)
|
|
- invokes :meth:`factory` "lazily",
|
|
- so exceptions in :meth:`factory` can go undetected during :meth:`start`.
|
|
+ A concrete subclass of :class:`BaseController` that provides
|
|
+ a threaded, INET listener.
|
|
|
|
- This method will create a connection to the started server and 'exchange' some traffic,
|
|
- thus triggering :meth:`factory` invocation,
|
|
- allowing the Controller to catch exceptions during initialization.
|
|
+ :param hostname: Will be given to the event loop's :meth:`~asyncio.loop.create_server` method
|
|
+ as the ``host`` parameter, with a slight processing (see below)
|
|
+ :type hostname: Optional[str]
|
|
+ :param port: Will be passed-through to :meth:`~asyncio.loop.create_server` method
|
|
+ :type port: int
|
|
+ :param ready_timeout: How long to wait until server starts.
|
|
+ The :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` takes precedence over this parameter.
|
|
+ See :attr:`ready_timeout` for more information.
|
|
+ :type ready_timeout: float
|
|
|
|
- .. method:: start() -> None
|
|
+ Other parameters are defined in the :class:`BaseController` class.
|
|
|
|
- :raises TimeoutError: if the server takes too long to get ready,
|
|
- exceeding the ``ready_timeout`` parameter.
|
|
- :raises RuntimeError: if an unrecognized & unhandled error happened,
|
|
- resulting in non-creation of a server object
|
|
- (:attr:`smtpd` remains ``None``)
|
|
+ The ``hostname`` parameter will be passed to the event loop's
|
|
+ :meth:`~asyncio.loop.create_server` method as the ``host`` parameter,
|
|
+ :boldital:`except` ``None`` (default) will be translated to ``::1``.
|
|
|
|
- Start the server in the subthread.
|
|
- The subthread is always a :class:`daemon thread <threading.Thread>`
|
|
- (i.e., we always set ``thread.daemon=True``).
|
|
+ * To bind `dual-stack`_ locally, use ``localhost``.
|
|
+ * To bind `dual-stack`_ on all interfaces, use ``""`` (empty string).
|
|
|
|
- Exceptions can be raised
|
|
- if the server does not start within :attr:`ready_timeout` seconds,
|
|
- or if any other exception occurs in :meth:`factory` while creating the server.
|
|
+ .. important::
|
|
|
|
- .. important::
|
|
+ The ``hostname`` parameter does NOT get passed through to the SMTP instance;
|
|
+ if you want to give the SMTP instance a custom hostname
|
|
+ (e.g., for use in HELO/EHLO greeting),
|
|
+ you must pass it through the :attr:`server_hostname` parameter.
|
|
|
|
- If :meth:`start` raises an Exception,
|
|
- cleanup is not performed automatically,
|
|
- to support deep inspection post-exception (if you wish to do so.)
|
|
- Cleanup must still be performed manually by calling :meth:`stop`
|
|
+ Explicitly defined SMTP keyword arguments will override keyword arguments of the
|
|
+ same names defined in the (deprecated) ``server_kwargs`` argument.
|
|
|
|
- For example::
|
|
+ .. doctest:: controller_kwargs
|
|
|
|
- # Assume SomeController is a concrete subclass of BaseThreadedController
|
|
- controller = SomeController(handler)
|
|
- try:
|
|
- controller.start()
|
|
- except ...:
|
|
- ... exception handling and/or inspection ...
|
|
- finally:
|
|
- controller.stop()
|
|
+ >>> from aiosmtpd.controller import Controller
|
|
+ >>> from aiosmtpd.handlers import Sink
|
|
+ >>> controller = Controller(
|
|
+ ... Sink(), timeout=200, server_kwargs=dict(timeout=400)
|
|
+ ... )
|
|
+ >>> controller.SMTP_kwargs["timeout"]
|
|
+ 200
|
|
|
|
- .. method:: stop() -> None
|
|
+ Finally, setting the ``ssl_context`` parameter will switch the protocol to ``SMTPS`` mode,
|
|
+ implying unconditional encryption of the connection,
|
|
+ and preventing the use of the ``STARTTLS`` mechanism.
|
|
|
|
- :raises AssertionError: if :meth:`stop` is called before :meth:`start` is called successfully
|
|
+ Actual behavior depends on the subclass's implementation.
|
|
|
|
- Stop the server and the event loop, and cancel all tasks.
|
|
+ |
|
|
+ | :part:`Attributes`
|
|
|
|
- .. method:: factory() -> aiosmtpd.smtp.SMTP
|
|
+ In addition to those provided by :class:`BaseController`,
|
|
+ this class provides the following:
|
|
|
|
- You can override this method to create custom instances of the ``SMTP``
|
|
- class being controlled.
|
|
+ .. attribute:: hostname: str
|
|
+ port: int
|
|
|
|
- By default, this creates an ``SMTP`` instance,
|
|
- passing in your handler and setting flags from the :attr:`**SMTP_Parameters` parameter.
|
|
+ The values of the *hostname* and *port* arguments.
|
|
|
|
- Examples of why you would want to override this method include
|
|
- creating an :ref:`LMTP <LMTP>` server instance instead of the standard ``SMTP`` server.
|
|
+ .. attribute:: ready_timeout
|
|
+ :type: float
|
|
|
|
+ The timeout value used to wait for the server to start.
|
|
|
|
+ This will either be the value of
|
|
+ the :envvar:`AIOSMTPD_CONTROLLER_TIMEOUT` environment variable (converted to float),
|
|
+ or the :attr:`ready_timeout` parameter.
|
|
|
|
-.. class:: Controller(\
|
|
- handler, \
|
|
- hostname=None, port=8025, \
|
|
- loop=None, \
|
|
- *, \
|
|
- ready_timeout=3.0, \
|
|
- ssl_context=None, \
|
|
- server_hostname=None, server_kwargs=None, **SMTP_parameters)
|
|
-
|
|
- :param hostname: Will be given to the event loop's :meth:`~asyncio.loop.create_server` method
|
|
- as the ``host`` parameter, with a slight processing (see below)
|
|
- :type hostname: Optional[str]
|
|
- :param port: Will be passed-through to :meth:`~asyncio.loop.create_server` method
|
|
- :type port: int
|
|
+ Setting this to a high value will NOT slow down controller startup,
|
|
+ because it's a timeout limit rather than a sleep delay.
|
|
+ However, you may want to reduce the default value to something 'just enough'
|
|
+ so you don't have to wait too long for an exception, if problem arises.
|
|
|
|
- .. note::
|
|
+ If this timeout is breached, a :class:`TimeoutError` exception will be raised.
|
|
+
|
|
+ |
|
|
+ | :part:`Methods`
|
|
|
|
- The ``hostname`` parameter will be passed to the event loop's
|
|
- :meth:`~asyncio.loop.create_server` method as the ``host`` parameter,
|
|
- :boldital:`except` ``None`` (default) will be translated to ``::1``.
|
|
+ In addition to those provided by :class:`BaseController`,
|
|
+ this class provides the following:
|
|
|
|
- * To bind `dual-stack`_ locally, use ``localhost``.
|
|
+ .. method:: start() -> None
|
|
|
|
- * To bind `dual-stack`_ on all interfaces, use ``""`` (empty string).
|
|
+ :raises TimeoutError: if the server takes too long to get ready,
|
|
+ exceeding the ``ready_timeout`` parameter.
|
|
+ :raises RuntimeError: if an unrecognized & unhandled error happened,
|
|
+ resulting in non-creation of a server object
|
|
+ (:attr:`smtpd` remains ``None``)
|
|
|
|
- .. important::
|
|
+ Start the server in the subthread.
|
|
+ The subthread is always a :class:`daemon thread <threading.Thread>`
|
|
+ (i.e., we always set ``thread.daemon=True``).
|
|
|
|
- The ``hostname`` parameter does NOT get passed through to the SMTP instance;
|
|
- if you want to give the SMTP instance a custom hostname
|
|
- (e.g., for use in HELO/EHLO greeting),
|
|
- you must pass it through the :attr:`server_hostname` parameter.
|
|
+ Exceptions can be raised
|
|
+ if the server does not start within :attr:`ready_timeout` seconds,
|
|
+ or if any other exception occurs in :meth:`~BaseController.factory`
|
|
+ while creating the server.
|
|
|
|
- .. important::
|
|
+ .. important::
|
|
|
|
- Explicitly defined SMTP keyword arguments will override keyword arguments of the
|
|
- same names defined in the (deprecated) ``server_kwargs`` argument.
|
|
+ If :meth:`start` raises an Exception,
|
|
+ cleanup is not performed automatically,
|
|
+ to support deep inspection post-exception (if you wish to do so.)
|
|
+ Cleanup must still be performed manually by calling :meth:`stop`
|
|
|
|
- >>> from aiosmtpd.handlers import Sink
|
|
- >>> controller = Controller(Sink(), timeout=200, server_kwargs=dict(timeout=400))
|
|
- >>> controller.SMTP_kwargs["timeout"]
|
|
- 200
|
|
+ For example::
|
|
|
|
- One example is the ``enable_SMTPUTF8`` flag described in the
|
|
- :ref:`Enabling SMTPUTF8 section <enablesmtputf8>` above.
|
|
+ # Assume SomeController is a concrete subclass of BaseThreadedController
|
|
+ controller = SomeController(handler)
|
|
+ try:
|
|
+ controller.start()
|
|
+ except ...:
|
|
+ ... exception handling and/or inspection ...
|
|
+ finally:
|
|
+ controller.stop()
|
|
|
|
- |
|
|
- | :part:`Attributes`
|
|
+ .. method:: stop(no_assert=False) -> None
|
|
|
|
- .. attribute:: hostname: str
|
|
- port: int
|
|
- :noindex:
|
|
+ :param no_assert: If ``True``, skip the assertion step so an ``AssertionError`` will
|
|
+ not be raised if thread had not been started successfully.
|
|
+ :type no_assert: bool
|
|
|
|
- The values of the *hostname* and *port* arguments.
|
|
+ :raises AssertionError: if this method is called before
|
|
+ :meth:`start` is called successfully *AND* ``no_assert=False``
|
|
|
|
- Other parameters, attributes, and methods are identical to :class:`BaseThreadedController`
|
|
- and thus are not repeated nor explained here.
|
|
+ Stop the server and the event loop, and cancel all tasks
|
|
+ via :meth:`~BaseController.cancel_tasks`.
|
|
|
|
|
|
.. class:: UnixSocketController(\
|
|
- handler, \
|
|
- unix_socket, \
|
|
- loop=None, \
|
|
- *, \
|
|
- ready_timeout=3.0, \
|
|
- ssl_context=None, \
|
|
- server_hostname=None,\
|
|
- **SMTP_parameters)
|
|
+ handler, \
|
|
+ unix_socket, \
|
|
+ loop=None, \
|
|
+ *, \
|
|
+ ready_timeout=DEFAULT_READY_TIMEOUT, \
|
|
+ ssl_context=None, \
|
|
+ server_hostname=None, \
|
|
+ **SMTP_parameters)
|
|
+
|
|
+ A concrete subclass of :class:`BaseController` that provides
|
|
+ a threaded, Unix Socket listener.
|
|
+
|
|
+ :param unix_socket: Socket file,
|
|
+ will be passed-through to :meth:`asyncio.loop.create_unix_server`
|
|
+ :type unix_socket: Union[str, pathlib.Path]
|
|
+
|
|
+ For the other parameters, see the description under :class:`Controller`
|
|
+
|
|
+ |
|
|
+ | :part:`Attributes`
|
|
+
|
|
+ .. py:attribute:: unix_socket
|
|
+ :type: str
|
|
+
|
|
+ The stringified version of the ``unix_socket`` parameter
|
|
+
|
|
+ Other attributes (except ``hostname`` and ``port``) are identical to :class:`Controller`
|
|
+ and thus are not repeated nor explained here.
|
|
+
|
|
+ |
|
|
+ | :part:`Methods`
|
|
+
|
|
+ All methods are identical to :class:`Controller`
|
|
+ and thus are not repeated nor explained here.
|
|
+
|
|
+
|
|
+.. class:: UnthreadedController(\
|
|
+ handler, \
|
|
+ hostname=None, \
|
|
+ port=8025, \
|
|
+ loop=None, \
|
|
+ *, \
|
|
+ ssl_context=None, \
|
|
+ server_hostname=None, \
|
|
+ server_kwargs=None, \
|
|
+ **SMTP_parameters)
|
|
+
|
|
+ .. versionadded:: 1.5.0
|
|
+
|
|
+ A concrete subclass of :class:`BaseController` that provides
|
|
+ an UNthreaded, INET listener.
|
|
+
|
|
+ Parameters are identical to the :class:`Controller` class.
|
|
+
|
|
+ |
|
|
+ | :part:`Attributes`
|
|
+
|
|
+ Attributes are identical to the :class:`Controller` class with one addition:
|
|
+
|
|
+ .. py:attribute:: ended
|
|
+ :type: threading.Event
|
|
+
|
|
+ An ``Event`` that can be ``.wait()``-ed when ending the controller.
|
|
+ Please see the :ref:`Unthreaded Controllers <unthreaded>` section for more info.
|
|
+
|
|
+ |
|
|
+ | :part:`Methods`
|
|
+
|
|
+ In addition to those provided by :class:`BaseController`,
|
|
+ this class provides the following:
|
|
+
|
|
+ .. py:method:: begin
|
|
+
|
|
+ Initializes the server task and insert it into the asyncio event loop.
|
|
+
|
|
+ .. note::
|
|
+
|
|
+ The SMTP class itself will only be initialized upon first connection
|
|
+ to the server task.
|
|
+
|
|
+ .. py:method:: finalize
|
|
+ :async:
|
|
+
|
|
+ Perform orderly closing of the server listener.
|
|
+ If you need to close the server from a non-async function,
|
|
+ you can use the :meth:`~UnthreadedController.end` method instead.
|
|
+
|
|
+ Upon completion of this method, the :attr:`ended` attribute will be ``set()``.
|
|
+
|
|
+ .. py:method:: end
|
|
+
|
|
+ This is a convenience method that will asynchronously invoke the
|
|
+ :meth:`finalize` method.
|
|
+ This method non-async, and thus is callable from non-async functions.
|
|
+
|
|
+ .. note::
|
|
+
|
|
+ If the asyncio event loop has been stopped,
|
|
+ then it is safe to invoke this method directly.
|
|
+ Otherwise, it is recommended to invoke this method
|
|
+ using the :meth:`~asyncio.loop.call_soon_threadsafe` method.
|
|
+
|
|
+
|
|
+.. class:: UnixSocketUnthreadedController(\
|
|
+ handler, \
|
|
+ unix_socket, \
|
|
+ loop=None, \
|
|
+ *, \
|
|
+ ssl_context=None, \
|
|
+ server_hostname=None,\
|
|
+ server_kwargs=None, \
|
|
+ **SMTP_parameters)
|
|
+
|
|
+ .. versionadded:: 1.5.0
|
|
+
|
|
+ A concrete subclass of :class:`BaseController` that provides
|
|
+ an UNthreaded, Unix Socket listener.
|
|
+
|
|
+ Parameters are identical to the :class:`UnixSocketController` class.
|
|
+
|
|
+ |
|
|
+ | :part:`Attributes`
|
|
|
|
- :param unix_socket: Socket file,
|
|
- will be passed-through to :meth:`asyncio.loop.create_unix_server`
|
|
- :type unix_socket: Union[str, pathlib.Path]
|
|
+ Attributes are identical to the :class:`UnixSocketController` class,
|
|
+ with the following addition:
|
|
|
|
- |
|
|
- | :part:`Attributes`
|
|
+ .. py:attribute:: ended
|
|
+ :type: threading.Event
|
|
|
|
- .. py:attribute:: unix_socket
|
|
- :type: str
|
|
+ An ``Event`` that can be ``.wait()``-ed when ending the controller.
|
|
+ Please see the :ref:`Unthreaded Controllers <unthreaded>` section for more info.
|
|
|
|
- The stringified version of the ``unix_socket`` parameter
|
|
+ |
|
|
+ | :part:`Methods`
|
|
|
|
- Other parameters, attributes, and methods are identical to :class:`BaseThreadedController`
|
|
- and thus are not repeated nor explained here.
|
|
+ Methods are identical to the :class:`UnthreadedController` class.
|
|
|
|
|
|
.. _`asyncio event loop`: https://docs.python.org/3/library/asyncio-eventloop.html
|
|
diff --git a/aiosmtpd/docs/smtp.rst b/aiosmtpd/docs/smtp.rst
|
|
index f48b717..3305079 100644
|
|
--- a/aiosmtpd/docs/smtp.rst
|
|
+++ b/aiosmtpd/docs/smtp.rst
|
|
@@ -99,7 +99,8 @@ Server hooks
|
|
The ``SMTP`` server class also implements some hooks which your subclass can
|
|
override to provide additional responses.
|
|
|
|
-``ehlo_hook()``
|
|
+.. py:function:: 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
|
|
@@ -107,12 +108,17 @@ override to provide additional responses.
|
|
before the standard ``250 HELP`` which ends the ``EHLO`` response from the
|
|
server.
|
|
|
|
-``rset_hook()``
|
|
+ .. deprecated:: 1.2
|
|
+
|
|
+.. py:function:: 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.
|
|
|
|
+ .. deprecated:: 1.2
|
|
+
|
|
|
|
.. _smtp_api:
|
|
|
|
diff --git a/aiosmtpd/handlers.py b/aiosmtpd/handlers.py
|
|
index b13dd12..ada1e91 100644
|
|
--- a/aiosmtpd/handlers.py
|
|
+++ b/aiosmtpd/handlers.py
|
|
@@ -15,6 +15,7 @@ import mailbox
|
|
import re
|
|
import smtplib
|
|
import sys
|
|
+from abc import ABCMeta, abstractmethod
|
|
from email import message_from_bytes, message_from_string
|
|
|
|
from public import public
|
|
@@ -148,7 +149,7 @@ class Sink:
|
|
|
|
|
|
@public
|
|
-class Message:
|
|
+class Message(metaclass=ABCMeta):
|
|
def __init__(self, message_class=None):
|
|
self.message_class = message_class
|
|
|
|
@@ -172,12 +173,13 @@ class Message:
|
|
message['X-RcptTo'] = COMMASPACE.join(envelope.rcpt_tos)
|
|
return message
|
|
|
|
+ @abstractmethod
|
|
def handle_message(self, message):
|
|
- raise NotImplementedError # pragma: nocover
|
|
+ raise NotImplementedError
|
|
|
|
|
|
@public
|
|
-class AsyncMessage(Message):
|
|
+class AsyncMessage(Message, metaclass=ABCMeta):
|
|
def __init__(self, message_class=None, *, loop=None):
|
|
super().__init__(message_class)
|
|
self.loop = loop or asyncio.get_event_loop()
|
|
@@ -187,8 +189,9 @@ class AsyncMessage(Message):
|
|
await self.handle_message(message)
|
|
return '250 OK'
|
|
|
|
+ @abstractmethod
|
|
async def handle_message(self, message):
|
|
- raise NotImplementedError # pragma: nocover
|
|
+ raise NotImplementedError
|
|
|
|
|
|
@public
|
|
diff --git a/aiosmtpd/proxy_protocol.py b/aiosmtpd/proxy_protocol.py
|
|
index a171211..621098c 100644
|
|
--- a/aiosmtpd/proxy_protocol.py
|
|
+++ b/aiosmtpd/proxy_protocol.py
|
|
@@ -99,7 +99,7 @@ class UnknownTypeTLV(KeyError):
|
|
|
|
|
|
@public
|
|
-class AsyncReader(Protocol): # pragma: nocover
|
|
+class AsyncReader(Protocol):
|
|
async def read(self, num_bytes: Optional[int] = None) -> bytes:
|
|
...
|
|
return b""
|
|
diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py
|
|
index 08fc0e8..d0a6cd3 100644
|
|
--- a/aiosmtpd/tests/conftest.py
|
|
+++ b/aiosmtpd/tests/conftest.py
|
|
@@ -29,6 +29,7 @@ __all__ = [
|
|
"controller_data",
|
|
"handler_data",
|
|
"Global",
|
|
+ "AUTOSTOP_DELAY",
|
|
"SERVER_CRT",
|
|
"SERVER_KEY",
|
|
]
|
|
@@ -64,6 +65,9 @@ class Global:
|
|
cls.SrvAddr = HostPort(contr.hostname, contr.port)
|
|
|
|
|
|
+# If less than 1.0, might cause intermittent error if test system
|
|
+# is too busy/overloaded.
|
|
+AUTOSTOP_DELAY = 1.0
|
|
SERVER_CRT = resource_filename("aiosmtpd.tests.certs", "server.crt")
|
|
SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key")
|
|
|
|
@@ -204,6 +208,17 @@ def temp_event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
|
|
asyncio.set_event_loop(default_loop)
|
|
|
|
|
|
+@pytest.fixture
|
|
+def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None, None]:
|
|
+ # 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.
|
|
+ temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
|
|
+ #
|
|
+ yield temp_event_loop
|
|
+
|
|
+
|
|
@pytest.fixture
|
|
def plain_controller(get_handler, get_controller) -> Generator[Controller, None, None]:
|
|
"""
|
|
diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py
|
|
index f9ac424..36992f3 100644
|
|
--- a/aiosmtpd/tests/test_main.py
|
|
+++ b/aiosmtpd/tests/test_main.py
|
|
@@ -16,8 +16,9 @@ import pytest
|
|
from aiosmtpd import __version__
|
|
from aiosmtpd.handlers import Debugging
|
|
from aiosmtpd.main import main, parseargs
|
|
+from aiosmtpd.testing.helpers import catchup_delay
|
|
from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S
|
|
-from aiosmtpd.tests.conftest import SERVER_CRT, SERVER_KEY
|
|
+from aiosmtpd.tests.conftest import AUTOSTOP_DELAY, SERVER_CRT, SERVER_KEY
|
|
|
|
try:
|
|
import pwd
|
|
@@ -27,10 +28,6 @@ except ImportError:
|
|
HAS_SETUID = hasattr(os, "setuid")
|
|
MAIL_LOG = logging.getLogger("mail.log")
|
|
|
|
-# If less than 1.0, might cause intermittent error if test system
|
|
-# is too busy/overloaded.
|
|
-AUTOSTOP_DELAY = 1.0
|
|
-
|
|
|
|
# region ##### Custom Handlers ########################################################
|
|
|
|
@@ -53,17 +50,6 @@ class NullHandler:
|
|
# region ##### Fixtures ###############################################################
|
|
|
|
|
|
-@pytest.fixture
|
|
-def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None, None]:
|
|
- # 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.
|
|
- temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
|
|
- #
|
|
- yield temp_event_loop
|
|
-
|
|
-
|
|
@pytest.fixture
|
|
def nobody_uid() -> Generator[int, None, None]:
|
|
if pwd is None:
|
|
@@ -97,10 +83,10 @@ def watch_for_tls(ready_flag, retq: MP.Queue):
|
|
req_tls = False
|
|
ready_flag.set()
|
|
start = time.monotonic()
|
|
- delay = AUTOSTOP_DELAY * 1.5
|
|
+ delay = AUTOSTOP_DELAY * 4
|
|
while (time.monotonic() - start) <= delay:
|
|
try:
|
|
- with SMTPClient("localhost", 8025) as client:
|
|
+ with SMTPClient("localhost", 8025, timeout=0.1) as client:
|
|
resp = client.docmd("HELP", "HELO")
|
|
if resp == S.S530_STARTTLS_FIRST:
|
|
req_tls = True
|
|
@@ -121,7 +107,7 @@ def watch_for_smtps(ready_flag, retq: MP.Queue):
|
|
delay = AUTOSTOP_DELAY * 1.5
|
|
while (time.monotonic() - start) <= delay:
|
|
try:
|
|
- with SMTP_SSL("localhost", 8025) as client:
|
|
+ with SMTP_SSL("localhost", 8025, timeout=0.1) as client:
|
|
client.ehlo("exemple.org")
|
|
has_smtps = True
|
|
break
|
|
@@ -215,6 +201,7 @@ class TestMainByWatcher:
|
|
with watcher_process(watch_for_tls) as retq:
|
|
temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
|
|
main_n("--tlscert", str(SERVER_CRT), "--tlskey", str(SERVER_KEY))
|
|
+ catchup_delay()
|
|
has_starttls = retq.get()
|
|
assert has_starttls is True
|
|
require_tls = retq.get()
|
|
@@ -230,6 +217,7 @@ class TestMainByWatcher:
|
|
str(SERVER_KEY),
|
|
"--no-requiretls",
|
|
)
|
|
+ catchup_delay()
|
|
has_starttls = retq.get()
|
|
assert has_starttls is True
|
|
require_tls = retq.get()
|
|
@@ -239,6 +227,7 @@ class TestMainByWatcher:
|
|
with watcher_process(watch_for_smtps) as retq:
|
|
temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
|
|
main_n("--smtpscert", str(SERVER_CRT), "--smtpskey", str(SERVER_KEY))
|
|
+ catchup_delay()
|
|
has_smtps = retq.get()
|
|
assert has_smtps is True
|
|
|
|
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
|
|
index 99c5630..41225dc 100644
|
|
--- a/aiosmtpd/tests/test_server.py
|
|
+++ b/aiosmtpd/tests/test_server.py
|
|
@@ -3,16 +3,18 @@
|
|
|
|
"""Test other aspects of the server implementation."""
|
|
|
|
+import asyncio
|
|
import errno
|
|
import platform
|
|
import socket
|
|
-import ssl
|
|
import time
|
|
from contextlib import ExitStack
|
|
from functools import partial
|
|
from pathlib import Path
|
|
+from smtplib import SMTP as SMTPClient, SMTPServerDisconnected
|
|
from tempfile import mkdtemp
|
|
-from typing import Generator
|
|
+from threading import Thread
|
|
+from typing import Generator, Optional
|
|
|
|
import pytest
|
|
from pytest_mock import MockFixture
|
|
@@ -20,13 +22,17 @@ from pytest_mock import MockFixture
|
|
from aiosmtpd.controller import (
|
|
Controller,
|
|
UnixSocketController,
|
|
+ UnthreadedController,
|
|
+ UnixSocketMixin,
|
|
+ UnixSocketUnthreadedController,
|
|
_FakeServer,
|
|
get_localhost,
|
|
)
|
|
from aiosmtpd.handlers import Sink
|
|
from aiosmtpd.smtp import SMTP as Server
|
|
+from aiosmtpd.testing.helpers import catchup_delay
|
|
|
|
-from .conftest import Global
|
|
+from .conftest import Global, AUTOSTOP_DELAY
|
|
|
|
|
|
class SlowStartController(Controller):
|
|
@@ -91,6 +97,45 @@ def safe_socket_dir() -> Generator[Path, None, None]:
|
|
tmpdir.rmdir()
|
|
|
|
|
|
+def assert_smtp_socket(controller: UnixSocketMixin):
|
|
+ assert Path(controller.unix_socket).exists()
|
|
+ sockfile = controller.unix_socket
|
|
+ ssl_context = controller.ssl_context
|
|
+ with ExitStack() as stk:
|
|
+ sock: socket.socket = stk.enter_context(
|
|
+ socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
+ )
|
|
+ sock.settimeout(AUTOSTOP_DELAY)
|
|
+ sock.connect(str(sockfile))
|
|
+ if ssl_context:
|
|
+ sock = stk.enter_context(ssl_context.wrap_socket(sock))
|
|
+ catchup_delay()
|
|
+ try:
|
|
+ resp = sock.recv(1024)
|
|
+ except socket.timeout:
|
|
+ return False
|
|
+ if not resp:
|
|
+ return False
|
|
+ assert resp.startswith(b"220 ")
|
|
+ assert resp.endswith(b"\r\n")
|
|
+ sock.send(b"EHLO socket.test\r\n")
|
|
+ # We need to "build" resparr because, especially when socket is wrapped
|
|
+ # in SSL, the SMTP server takes it sweet time responding with the list
|
|
+ # of ESMTP features ...
|
|
+ resparr = bytearray()
|
|
+ while not resparr.endswith(b"250 HELP\r\n"):
|
|
+ catchup_delay()
|
|
+ resp = sock.recv(1024)
|
|
+ if not resp:
|
|
+ break
|
|
+ resparr += resp
|
|
+ assert resparr.endswith(b"250 HELP\r\n")
|
|
+ sock.send(b"QUIT\r\n")
|
|
+ catchup_delay()
|
|
+ resp = sock.recv(1024)
|
|
+ assert resp.startswith(b"221")
|
|
+
|
|
+
|
|
class TestServer:
|
|
"""Tests for the aiosmtpd.smtp.SMTP class"""
|
|
|
|
@@ -272,10 +317,7 @@ class TestController:
|
|
|
|
# Apparently errno.E* constants adapts to the OS, so on Windows they will
|
|
# automatically use the analogous WSAE* constants
|
|
- @pytest.mark.parametrize(
|
|
- "err",
|
|
- [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT]
|
|
- )
|
|
+ @pytest.mark.parametrize("err", [errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT])
|
|
def test_getlocalhost_6no(self, mocker, err):
|
|
mock_makesock: mocker.Mock = mocker.patch(
|
|
"aiosmtpd.controller.makesock",
|
|
@@ -320,70 +362,176 @@ class TestController:
|
|
@pytest.mark.skipif(in_cygwin(), reason="Cygwin AF_UNIX is problematic")
|
|
@pytest.mark.skipif(in_win32(), reason="Win32 does not yet fully implement AF_UNIX")
|
|
class TestUnixSocketController:
|
|
- sockfile: Path = None
|
|
-
|
|
- def _assert_good_server(self, ssl_context: ssl.SSLContext = None):
|
|
- # Note: all those time.sleep()s are necessary
|
|
- # Remember that we're running in "Threaded" mode, and there's the GIL...
|
|
- # The time.sleep()s lets go of the GIL allowing the asyncio loop to move
|
|
- # forward
|
|
- assert self.sockfile.exists()
|
|
- with ExitStack() as stk:
|
|
- sock: socket.socket = stk.enter_context(
|
|
- socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
- )
|
|
- sock.connect(str(self.sockfile))
|
|
- if ssl_context:
|
|
- sock = stk.enter_context(ssl_context.wrap_socket(sock))
|
|
- time.sleep(0.1)
|
|
- resp = sock.recv(1024)
|
|
- assert resp.startswith(b"220 ")
|
|
- assert resp.endswith(b"\r\n")
|
|
- sock.send(b"EHLO socket.test\r\n")
|
|
- # We need to "build" resparr because, especially when socket is wrapped
|
|
- # in SSL, the SMTP server takes it sweet time responding with the list
|
|
- # of ESMTP features ...
|
|
- resparr = bytearray()
|
|
- while not resparr.endswith(b"250 HELP\r\n"):
|
|
- time.sleep(0.1)
|
|
- resp = sock.recv(1024)
|
|
- if not resp:
|
|
- break
|
|
- resparr += resp
|
|
- assert resparr.endswith(b"250 HELP\r\n")
|
|
- sock.send(b"QUIT\r\n")
|
|
- time.sleep(0.1)
|
|
- resp = sock.recv(1024)
|
|
- assert resp.startswith(b"221")
|
|
-
|
|
def test_server_creation(self, safe_socket_dir):
|
|
- self.sockfile = safe_socket_dir / "smtp"
|
|
- cont = UnixSocketController(Sink(), unix_socket=self.sockfile)
|
|
+ sockfile = safe_socket_dir / "smtp"
|
|
+ cont = UnixSocketController(Sink(), unix_socket=sockfile)
|
|
try:
|
|
cont.start()
|
|
- self._assert_good_server()
|
|
+ assert_smtp_socket(cont)
|
|
finally:
|
|
cont.stop()
|
|
|
|
def test_server_creation_ssl(self, safe_socket_dir, ssl_context_server):
|
|
- self.sockfile = safe_socket_dir / "smtp"
|
|
+ sockfile = safe_socket_dir / "smtp"
|
|
cont = UnixSocketController(
|
|
- Sink(), unix_socket=self.sockfile, ssl_context=ssl_context_server
|
|
+ Sink(), unix_socket=sockfile, ssl_context=ssl_context_server
|
|
)
|
|
try:
|
|
cont.start()
|
|
# Allow additional time for SSL to kick in
|
|
- time.sleep(0.1)
|
|
- self._assert_good_server(ssl_context_server)
|
|
+ catchup_delay()
|
|
+ assert_smtp_socket(cont)
|
|
finally:
|
|
cont.stop()
|
|
|
|
|
|
+class TestUnthreaded:
|
|
+ @pytest.fixture
|
|
+ def runner(self):
|
|
+ thread: Optional[Thread] = None
|
|
+
|
|
+ def _runner(loop: asyncio.AbstractEventLoop):
|
|
+ loop.run_forever()
|
|
+
|
|
+ def starter(loop: asyncio.AbstractEventLoop):
|
|
+ nonlocal thread
|
|
+ thread = Thread(target=_runner, args=(loop,))
|
|
+ thread.setDaemon(True)
|
|
+ thread.start()
|
|
+ catchup_delay()
|
|
+
|
|
+ def joiner(timeout: float = None):
|
|
+ nonlocal thread
|
|
+ assert isinstance(thread, Thread)
|
|
+ thread.join(timeout=timeout)
|
|
+
|
|
+ def is_alive():
|
|
+ nonlocal thread
|
|
+ assert isinstance(thread, Thread)
|
|
+ return thread.is_alive()
|
|
+
|
|
+ starter.join = joiner
|
|
+ starter.is_alive = is_alive
|
|
+ return starter
|
|
+
|
|
+ @pytest.mark.skipif(in_cygwin(), reason="Cygwin AF_UNIX is problematic")
|
|
+ @pytest.mark.skipif(in_win32(), reason="Win32 does not yet fully implement AF_UNIX")
|
|
+ def test_unixsocket(self, safe_socket_dir, autostop_loop, runner):
|
|
+ sockfile = safe_socket_dir / "smtp"
|
|
+ cont = UnixSocketUnthreadedController(
|
|
+ Sink(), unix_socket=sockfile, loop=autostop_loop
|
|
+ )
|
|
+ cont.begin()
|
|
+ # Make sure event loop is not running (will be started in thread)
|
|
+ assert autostop_loop.is_running() is False
|
|
+ runner(autostop_loop)
|
|
+ # Make sure event loop is up and running (started within thread)
|
|
+ assert autostop_loop.is_running() is True
|
|
+ # Check we can connect
|
|
+ assert_smtp_socket(cont)
|
|
+ # Wait until thread ends, which it will be when the loop autostops
|
|
+ runner.join(timeout=AUTOSTOP_DELAY)
|
|
+ assert runner.is_alive() is False
|
|
+ catchup_delay()
|
|
+ assert autostop_loop.is_running() is False
|
|
+ # At this point, the loop _has_ stopped, but the task is still listening
|
|
+ assert assert_smtp_socket(cont) is False
|
|
+ # Stop the task
|
|
+ cont.end()
|
|
+ catchup_delay()
|
|
+ # Now the listener has gone away
|
|
+ # noinspection PyTypeChecker
|
|
+ with pytest.raises((socket.timeout, ConnectionError)):
|
|
+ assert_smtp_socket(cont)
|
|
+
|
|
+ @pytest.mark.filterwarnings(
|
|
+ "ignore::pytest.PytestUnraisableExceptionWarning"
|
|
+ )
|
|
+ def test_inet_loopstop(self, autostop_loop, runner):
|
|
+ """
|
|
+ Verify behavior when the loop is stopped before controller is stopped
|
|
+ """
|
|
+ autostop_loop.set_debug(True)
|
|
+ cont = UnthreadedController(Sink(), loop=autostop_loop)
|
|
+ cont.begin()
|
|
+ # Make sure event loop is not running (will be started in thread)
|
|
+ assert autostop_loop.is_running() is False
|
|
+ runner(autostop_loop)
|
|
+ # Make sure event loop is up and running (started within thread)
|
|
+ assert autostop_loop.is_running() is True
|
|
+ # Check we can connect
|
|
+ with SMTPClient(cont.hostname, cont.port, timeout=AUTOSTOP_DELAY) as client:
|
|
+ code, _ = client.helo("example.org")
|
|
+ assert code == 250
|
|
+ # Wait until thread ends, which it will be when the loop autostops
|
|
+ runner.join(timeout=AUTOSTOP_DELAY)
|
|
+ assert runner.is_alive() is False
|
|
+ catchup_delay()
|
|
+ assert autostop_loop.is_running() is False
|
|
+ # At this point, the loop _has_ stopped, but the task is still listening,
|
|
+ # so rather than socket.timeout, we'll get a refusal instead, thus causing
|
|
+ # SMTPServerDisconnected
|
|
+ with pytest.raises(SMTPServerDisconnected):
|
|
+ SMTPClient(cont.hostname, cont.port, timeout=0.1)
|
|
+ cont.end()
|
|
+ catchup_delay()
|
|
+ cont.ended.wait()
|
|
+ # Now the listener has gone away, and thus we will end up with socket.timeout
|
|
+ # or ConnectionError (depending on OS)
|
|
+ # noinspection PyTypeChecker
|
|
+ with pytest.raises((socket.timeout, ConnectionError)):
|
|
+ SMTPClient(cont.hostname, cont.port, timeout=0.1)
|
|
+
|
|
+ @pytest.mark.filterwarnings(
|
|
+ "ignore::pytest.PytestUnraisableExceptionWarning"
|
|
+ )
|
|
+ def test_inet_contstop(self, temp_event_loop, runner):
|
|
+ """
|
|
+ Verify behavior when the controller is stopped before loop is stopped
|
|
+ """
|
|
+ cont = UnthreadedController(Sink(), loop=temp_event_loop)
|
|
+ cont.begin()
|
|
+ # Make sure event loop is not running (will be started in thread)
|
|
+ assert temp_event_loop.is_running() is False
|
|
+ runner(temp_event_loop)
|
|
+ # Make sure event loop is up and running
|
|
+ assert temp_event_loop.is_running() is True
|
|
+ try:
|
|
+ # Check that we can connect
|
|
+ with SMTPClient(cont.hostname, cont.port, timeout=AUTOSTOP_DELAY) as client:
|
|
+ code, _ = client.helo("example.org")
|
|
+ assert code == 250
|
|
+ client.quit()
|
|
+ catchup_delay()
|
|
+ temp_event_loop.call_soon_threadsafe(cont.end)
|
|
+ for _ in range(10): # 10 is arbitrary
|
|
+ catchup_delay() # effectively yield to other threads/event loop
|
|
+ if cont.ended.wait(1.0):
|
|
+ break
|
|
+ assert temp_event_loop.is_running() is True
|
|
+ # Because we've called .end() there, the server listener should've gone
|
|
+ # away, so we should end up with a socket.timeout or ConnectionError or
|
|
+ # SMTPServerDisconnected (depending on lotsa factors)
|
|
+ expect_errs = (socket.timeout, ConnectionError, SMTPServerDisconnected)
|
|
+ # noinspection PyTypeChecker
|
|
+ with pytest.raises(expect_errs):
|
|
+ SMTPClient(cont.hostname, cont.port, timeout=0.1)
|
|
+ finally:
|
|
+ # Wrap up, or else we'll hang
|
|
+ temp_event_loop.call_soon_threadsafe(cont.cancel_tasks)
|
|
+ catchup_delay()
|
|
+ runner.join()
|
|
+ assert runner.is_alive() is False
|
|
+ assert temp_event_loop.is_running() is False
|
|
+ assert temp_event_loop.is_closed() is False
|
|
+
|
|
+
|
|
class TestFactory:
|
|
def test_normal_situation(self):
|
|
cont = Controller(Sink())
|
|
try:
|
|
cont.start()
|
|
+ catchup_delay()
|
|
assert cont.smtpd is not None
|
|
assert cont._thread_exception is None
|
|
finally:
|
|
diff --git a/pyproject.toml b/pyproject.toml
|
|
index b61bfa6..e067d36 100644
|
|
--- a/pyproject.toml
|
|
+++ b/pyproject.toml
|
|
@@ -3,7 +3,6 @@ requires = ["setuptools", "wheel"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[tool.pytest.ini_options]
|
|
-# addopts = """--doctest-glob="*.rst" --strict-markers -rfEX"""
|
|
addopts = """--strict-markers -rfEX"""
|
|
markers = [
|
|
"client_data",
|
|
@@ -37,6 +36,7 @@ source = [
|
|
[tool.coverage.coverage_conditional_plugin.rules]
|
|
# Here we specify our pragma rules:
|
|
py-ge-38 = "sys_version_info >= (3, 8)"
|
|
+py-lt-38 = "sys_version_info < (3, 8)"
|
|
py-gt-36 = "sys_version_info > (3, 6)"
|
|
has-mypy = "is_installed('mypy')"
|
|
has-pwd = "is_installed('pwd')"
|
|
@@ -47,10 +47,14 @@ on-wsl = "'Microsoft' in platform_release"
|
|
# As of 2021-02-07, only WSL has a kernel with "Microsoft" in the version.
|
|
on-not-win32 = "sys_platform != 'win32'"
|
|
on-cygwin = "sys_platform == 'cygwin'"
|
|
+no-unixsock = "sys_platform in {'win32', 'cygwin'}"
|
|
|
|
[tool.coverage.report]
|
|
exclude_lines = [
|
|
"pragma: nocover",
|
|
+ "pragma: no cover",
|
|
+ "@abstract",
|
|
+ 'class \S+\(Protocol\):'
|
|
]
|
|
fail_under = 100
|
|
show_missing = true
|
|
--
|
|
2.32.0
|
|
|