Repository: aio-libs/aiosmtpd

Issue:
SMTP doesn't accept RCPT with local part > 64 octets.
smtp.py in the code in _getaddr() at lines 1097 - 1118 contains at lines 1115,1116
```
        if len(localpart) > 64:  # RFC 5321 § 4.5.3.1.1
            return None, None
```
While it is true that RFC 5321 § 4.5.3.1.1 specifies 64 octets as the maximum length of a local part, § 4.5.3.1
says in part `To the  maximum extent possible, implementation techniques that impose no  limits on the length of these objects should be used.`

Also, the stdlib smtpd.py _getaddr() contains no such restriction.

Thus, I think SMTP should be more forgiving since I see no reason to enforce this limit.

This restriction results in issues for GNU Mailman which uses the LMTP class to receive messages and receives confirmation messages addressed to `LISTNAME-confirm+TOKEN@example .com. The length of the token is 40 bytes which means if the length of the list name is > 15 bytes, the local part will exceed 64 bytes and be rejected.

Comments:

Patch:
diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py
index 778795f..d8a299d 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.0"
+__version__ = "1.4.1a1"
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 5cd2841..ac079d4 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -3,6 +3,15 @@
 ###################
 
 
+1.4.1 (aiosmtpd-next)
+=====================
+
+Fixed/Improved
+--------------
+* Maximum length of email address local part is customizable, defaults to no limit. (Closes #257)
+
+
+
 1.4.0 (2021-02-26)
 ==================
 
diff --git a/aiosmtpd/docs/smtp.rst b/aiosmtpd/docs/smtp.rst
index a1246d8..f48b717 100644
--- a/aiosmtpd/docs/smtp.rst
+++ b/aiosmtpd/docs/smtp.rst
@@ -390,6 +390,16 @@ aiosmtpd.smtp
          :attr:`line_length_limit` to ``2**16`` *before* instantiating the
          :class:`SMTP` class.
 
+   .. py:attribute:: local_part_limit
+
+      The maximum lengh (in octets) of the local part of email addresses.
+
+      :rfc:`RFC 5321 § 4.5.3.1.1 <5321#section-4.5.3.1.1>` specifies a maximum length of 64 octets,
+      but this requirement is flexible and can be relaxed at the server's discretion
+      (see :rfc:`§ 4.5.3.1 <5321#section-4.5.3.1>`).
+
+      Setting this to `0` (the default) disables this limit completely.
+
    .. py:attribute:: AuthLoginUsernameChallenge
 
       A ``str`` containing the base64-encoded challenge to be sent as the first challenge
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index 702faad..667b4af 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -277,12 +277,19 @@ class SMTP(asyncio.StreamReaderProtocol):
     command_size_limit = 512
     command_size_limits: Dict[str, int] = collections.defaultdict(
         lambda x=command_size_limit: x)
+
     line_length_limit = 1001
     """Maximum line length according to RFC 5321 s 4.5.3.1.6"""
     # The number comes from this calculation:
     # (RFC 5322 s 2.1.1 + RFC 6532 s 3.4) 998 octets + CRLF = 1000 octets
     # (RFC 5321 s 4.5.3.1.6) 1000 octets + "transparent dot" = 1001 octets
 
+    local_part_limit: int = 0
+    """
+    Maximum local part length. (RFC 5321 § 4.5.3.1.1 specifies 64, but lenient)
+    If 0 or Falsey, local part length is unlimited.
+    """
+
     AuthLoginUsernameChallenge = "User Name\x00"
     AuthLoginPasswordChallenge = "Password\x00"
 
@@ -455,6 +462,18 @@ class SMTP(asyncio.StreamReaderProtocol):
         except ValueError:
             return self.command_size_limit
 
+    def __del__(self):  # pragma: nocover
+        # This is nocover-ed because the contents *totally* does NOT affect function-
+        # ality, and in addition this comes directly from StreamReaderProtocol.__del__()
+        # but with a getattr()+check addition to stop the annoying (but harmless)
+        # "exception ignored" messages caused by AttributeError when self._closed is
+        # missing (which seems to happen randomly).
+        closed = getattr(self, "_closed", None)
+        if closed is None:
+            return
+        if closed.done() and not closed.cancelled():
+            closed.exception()
+
     def connection_made(self, transport):
         # Reset state due to rfc3207 part 4.2.
         self._set_rset_state()
@@ -1112,10 +1131,9 @@ class SMTP(asyncio.StreamReaderProtocol):
             return None, None
         address = address.addr_spec
         localpart, atsign, domainpart = address.rpartition("@")
-        if len(localpart) > 64:  # RFC 5321 § 4.5.3.1.1
+        if self.local_part_limit and len(localpart) > self.local_part_limit:
             return None, None
-        else:
-            return address, rest
+        return address, rest
 
     def _getparams(self, params):
         # Return params as dictionary. Return None if not all parameters


Test patch:
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index ee2c614..b614bc1 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -489,16 +489,21 @@ class TestSMTP(_CommonMethods):
         "customer/department=shipping@example.com",
         "$A12345@example.com",
         "!def!xyz%abc@example.com",
+        "a" * 65 + "@example.com",  # local-part > 64 chars -- see Issue#257
+        "b" * 488 + "@example.com",  # practical longest for MAIL FROM
+        "c" * 500,  # practical longest domainless for MAIL FROM
     ]
 
     valid_rcptto_addresses = valid_mailfrom_addresses + [
         # Postmaster -- RFC5321 § 4.1.1.3
         "<Postmaster>",
+        "b" * 490 + "@example.com",  # practical longest for RCPT TO
+        "c" * 502,  # practical longest domainless for RCPT TO
     ]
 
     invalid_email_addresses = [
-        "<@example.com>",  # no local part
-        "a" * 65 + "@example.com",  # local-part > 64 chars
+        "<@example.com>",  # null local part
+        "<johnathon@>",  # null domain part
     ]
 
     @pytest.mark.parametrize("data", [b"\x80FAIL\r\n", b"\x80 FAIL\r\n"])
@@ -1659,6 +1664,16 @@ class TestCustomization(_CommonMethods):
         resp = client.docmd("MAIL FROM: <anne@example.com> BODY=FOOBAR")
         assert resp == S.S501_MAIL_BODY
 
+    def test_limitlocalpart(self, plain_controller, client):
+        plain_controller.smtpd.local_part_limit = 64
+        client.ehlo("example.com")
+        locpart = "a" * 64
+        resp = client.docmd(f"MAIL FROM: {locpart}@example.com")
+        assert resp == S.S250_OK
+        locpart = "b" * 65
+        resp = client.docmd(f"RCPT TO: {locpart}@example.com")
+        assert resp == S.S553_MALFORMED
+
 
 class TestClientCrash(_CommonMethods):
     def test_connection_reset_during_DATA(