Repository: aio-libs/aiosmtpd

Issue:
Duplicated HELO/EHLO
As already mentioned in source code (https://github.com/aio-libs/aiosmtpd/blob/c25b878473b086150d32fcd21c35c58099947e5f/aiosmtpd/smtp.py#L264, also in original smtpd's code) a second HELO/EHLO is rejected but the standart says it should be handled as a RSET command.

Is there any special reason to not allow duplicated HELO/EHLO?  I think the best way is to do the same other SMTP servers do: Allowing a duplicated HELO/EHLO.
Another option is to have a configuration option.

Discussion:

Patch:
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index f123514..287875b 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -284,10 +284,6 @@ class SMTP(asyncio.StreamReaderProtocol):
         if not hostname:
             await self.push('501 Syntax: HELO hostname')
             return
-        # See issue #21783 for a discussion of this behavior.
-        if self.session.host_name:
-            await self.push('503 Duplicate HELO/EHLO')
-            return
         self._set_rset_state()
         self.session.extended_smtp = False
         status = await self._call_handler_hook('HELO', hostname)
@@ -300,11 +296,6 @@ class SMTP(asyncio.StreamReaderProtocol):
         if not hostname:
             await self.push('501 Syntax: EHLO hostname')
             return
-        # See https://bugs.python.org/issue21783 for a discussion of this
-        # behavior.
-        if self.session.host_name:
-            await self.push('503 Duplicate HELO/EHLO')
-            return
         self._set_rset_state()
         self.session.extended_smtp = True
         await self.push('250-%s' % self.hostname)


Test patch:
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index 8e61021..9f78c16 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -38,6 +38,15 @@ class ReceivingHandler:
         return '250 OK'
 
 
+class StoreEnvelopeOnVRFYHandler:
+    """Saves envelope for later inspection when handling VRFY."""
+    envelope = None
+
+    async def handle_VRFY(self, server, session, envelope, addr):
+        self.envelope = envelope
+        return '250 OK'
+
+
 class SizedController(Controller):
     def __init__(self, handler, size):
         self.size = size
@@ -201,8 +210,7 @@ class TestSMTP(unittest.TestCase):
             code, response = client.helo('example.com')
             self.assertEqual(code, 250)
             code, response = client.helo('example.org')
-            self.assertEqual(code, 503)
-            self.assertEqual(response, b'Duplicate HELO/EHLO')
+            self.assertEqual(code, 250)
 
     def test_ehlo(self):
         with SMTP(*self.address) as client:
@@ -219,8 +227,7 @@ class TestSMTP(unittest.TestCase):
             code, response = client.ehlo('example.com')
             self.assertEqual(code, 250)
             code, response = client.ehlo('example.org')
-            self.assertEqual(code, 503)
-            self.assertEqual(response, b'Duplicate HELO/EHLO')
+            self.assertEqual(code, 250)
 
     def test_ehlo_no_hostname(self):
         with SMTP(*self.address) as client:
@@ -235,16 +242,14 @@ class TestSMTP(unittest.TestCase):
             code, response = client.helo('example.com')
             self.assertEqual(code, 250)
             code, response = client.ehlo('example.org')
-            self.assertEqual(code, 503)
-            self.assertEqual(response, b'Duplicate HELO/EHLO')
+            self.assertEqual(code, 250)
 
     def test_ehlo_then_helo(self):
         with SMTP(*self.address) as client:
             code, response = client.ehlo('example.com')
             self.assertEqual(code, 250)
             code, response = client.helo('example.org')
-            self.assertEqual(code, 503)
-            self.assertEqual(response, b'Duplicate HELO/EHLO')
+            self.assertEqual(code, 250)
 
     def test_noop(self):
         with SMTP(*self.address) as client:
@@ -665,6 +670,80 @@ class TestSMTP(unittest.TestCase):
                 b'Error: command "FOOBAR" not recognized')
 
 
+class TestResetCommands(unittest.TestCase):
+    """Test that sender and recipients are reset on RSET, HELO, and EHLO.
+
+    The tests below issue each command twice with different addresses and
+    verify that mail_from and rcpt_tos have been replacecd.
+    """
+
+    expected_envelope_data = [{
+            'mail_from': 'from_first_run@example.com',
+            'rcpt_tos': ['to_first_run_1@example.com',
+                         'to_first_run_2@example.com']}, {
+            'mail_from': 'from_second_run@example.com',
+            'rcpt_tos': ['to_second_run_1@example.com',
+                         'to_second_run_2@example.com']}]
+
+    def send_envolope_data(self, client, mail_from, rcpt_tos):
+        client.mail(mail_from)
+        for rcpt in rcpt_tos:
+            client.rcpt(rcpt)
+
+    def test_helo(self):
+        handler = StoreEnvelopeOnVRFYHandler()
+        controller = DecodingController(handler)
+        controller.start()
+        self.addCleanup(controller.stop)
+
+        with SMTP(controller.hostname, controller.port) as client:
+            for data in self.expected_envelope_data:
+                client.helo('example.com')
+                client.vrfy('dummy@addr.example')  # Save envelope in handler
+                self.assertIsNone(handler.envelope.mail_from)
+                self.assertEqual(len(handler.envelope.rcpt_tos), 0)
+                self.send_envolope_data(client, **data)
+                client.vrfy('dummy@addr.example')  # Save envelope in handler
+                self.assertEqual(handler.envelope.mail_from, data['mail_from'])
+                self.assertEqual(handler.envelope.rcpt_tos, data['rcpt_tos'])
+
+    def test_ehlo(self):
+        handler = StoreEnvelopeOnVRFYHandler()
+        controller = DecodingController(handler)
+        controller.start()
+        self.addCleanup(controller.stop)
+
+        with SMTP(controller.hostname, controller.port) as client:
+            for data in self.expected_envelope_data:
+                client.ehlo('example.com')
+                client.vrfy('dummy@addr.example')  # Save envelope in handler
+                self.assertIsNone(handler.envelope.mail_from)
+                self.assertEqual(len(handler.envelope.rcpt_tos), 0)
+                self.send_envolope_data(client, **data)
+                client.vrfy('dummy@addr.example')  # Save envelope in handler
+                self.assertEqual(handler.envelope.mail_from, data['mail_from'])
+                self.assertEqual(handler.envelope.rcpt_tos, data['rcpt_tos'])
+
+    def test_rset(self):
+        handler = StoreEnvelopeOnVRFYHandler()
+        controller = DecodingController(handler)
+        controller.start()
+        self.addCleanup(controller.stop)
+
+        with SMTP(controller.hostname, controller.port) as client:
+            client.helo('example.com')
+
+            for data in self.expected_envelope_data:
+                self.send_envolope_data(client, **data)
+                client.vrfy('dummy@addr.example')  # Save envelope in handler
+                self.assertEqual(handler.envelope.mail_from, data['mail_from'])
+                self.assertEqual(handler.envelope.rcpt_tos, data['rcpt_tos'])
+                client.rset()
+                client.vrfy('dummy@addr.example')  # Save envelope in handler
+                self.assertIsNone(handler.envelope.mail_from)
+                self.assertEqual(len(handler.envelope.rcpt_tos), 0)
+
+
 class TestSMTPWithController(unittest.TestCase):
     def test_mail_with_size_too_large(self):
         controller = SizedController(Sink(), 9999)