Skip to content

Commit

Permalink
Relax limit for local part length (#258)
Browse files Browse the repository at this point in the history
* Implement customizable local part limit
* Add some tests
  * Test longest possible email for MAIL FROM and RCPT TO if
    local_part_limit=0
  * Verify if limit set to N, address with local part up to N works
* Suppress exception ignored during __del__
  • Loading branch information
pepoluan committed Mar 4, 2021
1 parent c52d3b6 commit 3eeeff5
Show file tree
Hide file tree
Showing 5 changed files with 58 additions and 6 deletions.
2 changes: 1 addition & 1 deletion aiosmtpd/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2014-2021 The aiosmtpd Developers
# SPDX-License-Identifier: Apache-2.0

__version__ = "1.4.0"
__version__ = "1.4.1a1"
9 changes: 9 additions & 0 deletions aiosmtpd/docs/NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
==================

Expand Down
10 changes: 10 additions & 0 deletions aiosmtpd/docs/smtp.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions aiosmtpd/smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -455,6 +462,18 @@ def max_command_size_limit(self):
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()
Expand Down Expand Up @@ -1112,10 +1131,9 @@ def _getaddr(self, arg) -> Tuple[Optional[str], Optional[str]]:
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
Expand Down
19 changes: 17 additions & 2 deletions aiosmtpd/tests/test_smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -1659,6 +1664,16 @@ def test_mail_invalid_body_param(self, plain_controller, client):
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(
Expand Down

0 comments on commit 3eeeff5

Please sign in to comment.