Repository: aio-libs/aiosmtpd

Issue:
LMTP server fails to list any extended services
The LHLO service awaits smtp_HELO instead of smtp_EHLO, preventing the greeting from displaying the service menu.

This is a violation of the LMTP spec, RFC 2033.

Patch attached.  This affects https://gitlab.com/mailman/mailman/issues/365

[lmtp_lhlo.zip](https://github.com/aio-libs/aiosmtpd/files/1132381/lmtp_lhlo.zip)



Comments:

Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index 41d11f0..5a8020d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,3 @@
 graft aiosmtpd
-include .coveragerc LICENSE *.cfg *.ini *.py *.rst *.yml
+include LICENSE *.cfg *.ini *.py *.rst *.yml
 global-exclude *.py[oc]
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 05628e0..4b0cab1 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -2,6 +2,7 @@
  NEWS for aiosmtpd
 ===================
 
+
 1.2.3 (aiosmtpd-next)
 =====================
 
@@ -15,12 +16,14 @@ Fixed/Improved
 * Implement & enforce line-length-limit, thus becoming Compliant with RFC 5321 § 4.5.3.1.6
 * Delay all SMTP Status Code replies during ``DATA`` phase until the phase termination (Closes #9)
 * Now catches ``Controller.factory()`` failure during ``Controller.start()`` (Closes #212)
-* :class:`SMTP` no longer edits user-supplied SSL Context (closes #191)
-* Implement waiting for SSL setup/handshake within STARTTLS handler to be able to catch and handle
+* :class:`SMTP` no longer edits user-supplied SSL Context (Closes #191)
+* Implement waiting for SSL setup/handshake within ``STARTTLS`` handler to be able to catch and handle
   (log) errors and to avoid session hanging around until timeout in such cases
 * Add session peer information to some logging output where it was missing
 * Support AUTH mechanisms with dash(es) in their names (Closes #224)
 * Remove some double-logging of commands sent by clients
+* LMTP servers now correctly advertise extensions in reply to ``LHLO`` (Closes #123, #124)
+* ``NOOP`` now accepted before ``STARTTLS`` even if ``require_starttls=True`` (Closes #124)
 
 
 1.2.2 (2020-11-08)
diff --git a/aiosmtpd/lmtp.py b/aiosmtpd/lmtp.py
index 8d763fd..5a7ba40 100644
--- a/aiosmtpd/lmtp.py
+++ b/aiosmtpd/lmtp.py
@@ -9,7 +9,7 @@ class LMTP(SMTP):
     @syntax('LHLO hostname')
     async def smtp_LHLO(self, arg):
         """The LMTP greeting, used instead of HELO/EHLO."""
-        await super().smtp_HELO(arg)
+        await super().smtp_EHLO(arg)
 
     async def smtp_HELO(self, arg):
         """HELO is not a valid LMTP command."""
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index 1564d9b..5447640 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -59,7 +59,7 @@ __all__ = [
     "AuthMechanismType",
     "MISSING",
 ]  # Will be added to by @public
-__version__ = '1.2.3a4'
+__version__ = '1.2.3a5'
 __ident__ = 'Python SMTP {}'.format(__version__)
 log = logging.getLogger('mail.log')
 
@@ -71,6 +71,9 @@ MISSING = _Missing()
 NEWLINE = '\n'
 VALID_AUTHMECH = re.compile(r"[A-Z0-9_-]+\Z")
 
+# https://tools.ietf.org/html/rfc3207.html#page-3
+ALLOWED_BEFORE_STARTTLS = {"NOOP", "EHLO", "STARTTLS", "QUIT"}
+
 # endregion
 
 
@@ -447,7 +450,7 @@ class SMTP(asyncio.StreamReaderProtocol):
                     continue
                 if (self.require_starttls
                         and not self._tls_protocol
-                        and command not in ['EHLO', 'STARTTLS', 'QUIT']):
+                        and command not in ALLOWED_BEFORE_STARTTLS):
                     # RFC3207 part 4
                     await self.push('530 Must issue a STARTTLS command first')
                     continue
diff --git a/.coveragerc b/coverage.cfg
similarity index 100%
rename from .coveragerc
rename to coverage.cfg
diff --git a/housekeep.py b/housekeep.py
index f1e6acf..cf35a3f 100644
--- a/housekeep.py
+++ b/housekeep.py
@@ -31,6 +31,8 @@ except ImportError:
 TOX_ENV_NAME = os.environ.get("TOX_ENV_NAME", None)
 
 WORKDIRS = (
+    ".mypy_cache",
+    ".pytype",
     ".pytest-cache",
     ".pytest_cache",
     ".tox",
@@ -41,6 +43,14 @@ WORKDIRS = (
     "prof",
 )
 
+WORKFILES = (
+    ".coverage",
+    "coverage.xml",
+    "diffcov.html",
+    "coverage-*.xml",
+    "diffcov-*.html",
+)
+
 
 # region #### Helper funcs ############################################################
 
@@ -79,11 +89,13 @@ def dump_env():
 def move_prof():
     """Move profiling files to per-testenv dirs"""
     profpath = Path("prof")
+    # fmt: off
     prof_files = [
-        f
-        for p in ("*.prof", "*.svg")
-        for f in profpath.glob(p)
+        filepath
+        for fileglob in ("*.prof", "*.svg")
+        for filepath in profpath.glob(fileglob)
     ]
+    # fmt: on
     if not prof_files:
         return
     targpath = profpath / TOX_ENV_NAME
@@ -119,17 +131,10 @@ def rm_work():
         deldir(Path(dd))
         print(" ", end="", flush=True)
     print(f"\n{Style.BRIGHT}Removing work files ...", end="")
-    for fn in (".coverage", "coverage.xml", "diffcov.html"):
-        print(".", end="", flush=True)
-        fp = Path(fn)
-        if fp.exists():
-            fp.unlink()
-    for fp in Path(".").glob("coverage-*.xml"):
-        print(".", end="", flush=True)
-        fp.unlink()
-    for fp in Path(".").glob("diffcov-*.html"):
-        print(".", end="", flush=True)
-        fp.unlink()
+    for fnglob in WORKFILES:
+        for fp in Path(".").glob(fnglob):
+            print(".", end="", flush=True)
+            fp.exists() and fp.unlink()
     print()
 
 
@@ -197,10 +202,11 @@ def get_opts(argv):
     parser.add_argument(
         "--force", "-F", action="store_true", help="Force action even if in CI"
     )
+
+    # From: https://stackoverflow.com/a/49999185/149900
     parser.add_argument(
         "cmd", metavar="COMMAND", choices=sorted(dispers.keys()), help="(See below)"
     )
-
     cgrp = parser.add_argument_group(title="COMMAND is one of")
     for name, doc in sorted(dispers.items()):
         cgrp.add_argument(name, help=doc, action="no_action")
diff --git a/tox.ini b/tox.ini
index 4bee309..bb8fe53 100644
--- a/tox.ini
+++ b/tox.ini
@@ -30,7 +30,8 @@ deps =
     coverage
     diff_cover
 setenv =
-    cov: COVERAGE_PROCESS_START=.coveragerc
+    cov: COVERAGE_RCFILE=coverage.cfg
+    cov: COVERAGE_PROCESS_START=coverage.cfg
     cov: COVERAGE_OPTIONS="-p"
     cov: COVERAGE_FILE={toxinidir}/.coverage
     py36: INTERP=py36


Test patch:
diff --git a/aiosmtpd/tests/test_lmtp.py b/aiosmtpd/tests/test_lmtp.py
index 66fe8f3..f7cfac8 100644
--- a/aiosmtpd/tests/test_lmtp.py
+++ b/aiosmtpd/tests/test_lmtp.py
@@ -40,7 +40,15 @@ class TestLMTP(unittest.TestCase):
         with SMTP(*self.address) as client:
             code, response = client.docmd('LHLO', 'example.com')
             self.assertEqual(code, 250)
-            self.assertEqual(response, bytes(socket.getfqdn(), 'utf-8'))
+            lines = response.splitlines()
+            expecteds = (
+                bytes(socket.getfqdn(), 'utf-8'),
+                b'SIZE 33554432',
+                b'8BITMIME',
+                b'HELP',
+            )
+            for actual, expected in zip(lines, expecteds):
+                self.assertEqual(actual, expected)
 
     def test_helo(self):
         # HELO and EHLO are not valid LMTP commands.
diff --git a/aiosmtpd/tests/test_starttls.py b/aiosmtpd/tests/test_starttls.py
index d658b9a..e38e887 100644
--- a/aiosmtpd/tests/test_starttls.py
+++ b/aiosmtpd/tests/test_starttls.py
@@ -292,6 +292,18 @@ class TestRequireTLS(unittest.TestCase):
             code, response = client.docmd('DATA')
             self.assertEqual(code, 530)
 
+    def test_noop_okay(self):
+        with SMTP(*self.address) as client:
+            client.ehlo('example.com')
+            code, response = client.docmd('NOOP')
+            self.assertEqual(code, 250)
+
+    def test_quit_okay(self):
+        with SMTP(*self.address) as client:
+            client.ehlo('example.com')
+            code, response = client.docmd('QUIT')
+            self.assertEqual(code, 221)
+
 
 class TestRequireTLSAUTH(unittest.TestCase):
     def setUp(self):