Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-aiosmtplib for
openSUSE:Factory checked in at 2026-08-21 17:01:41
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-aiosmtplib (Old)
and /work/SRC/openSUSE:Factory/.python-aiosmtplib.new.1258 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-aiosmtplib"
Fri Aug 21 17:01:41 2026 rev:19 rq:1372852 version:5.1.2
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-aiosmtplib/python-aiosmtplib.changes
2026-06-22 17:33:32.346363894 +0200
+++
/work/SRC/openSUSE:Factory/.python-aiosmtplib.new.1258/python-aiosmtplib.changes
2026-08-21 17:03:14.124118452 +0200
@@ -1,0 +2,28 @@
+Fri Aug 21 12:21:32 UTC 2026 - Nico Krapp <[email protected]>
+
+- Update to 5.1.2 (fixes CVE-2026-55558, bsc#1275982)
+ * Security: Discard any buffered server data before the STARTTLS handshake,
+ preventing a response-injection attack where a man-in-the-middle pre-stages
+ plaintext that would otherwise be read as part of the encrypted session
+ * Bugfix: Corrected SMTPResponse __repr__ result
+ * Bugfix: Calling connect() on an already-connected client now raises
+ SMTPException instead of deadlocking on the connection lock
+ * Feature: Poe command runner for dev tasks
+ * Bugfix: Timeout ignored during CRAM-MD5 verification
+ * Bugfix: Only parse EHLO response after validating success
+ * Bugfix: return None from extract_sender when address list is empty
+ * Bugfix: Enforce a maximum total response size, preventing unbounded memory
+ use if a server streams data with no line ending or endless multiline
+ continuation lines
+ * Bugfix: Use the invalid_response status code (-1) instead of 500 when a
+ server response line exceeds the maximum length
+ * Bugfix: A read timeout now closes the connection instead of leaving it in
+ a desynced state, where a late server response could be mispaired with a
+ subsequent command
+ * Bugfix: Correctly parse old-style AUTH= extension advertisements; all
+ advertised methods are now kept (e.g. both PLAIN and LOGIN from
+ AUTH=PLAIN LOGIN)
+ * Bugfix: ESMTP extension lines with leading whitespace are no longer ignored
+- enable testsuite again
+
+-------------------------------------------------------------------
Old:
----
aiosmtplib-5.1.1.tar.gz
New:
----
aiosmtplib-5.1.2.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-aiosmtplib.spec ++++++
--- /var/tmp/diff_new_pack.NTXRxg/_old 2026-08-21 17:03:14.956147910 +0200
+++ /var/tmp/diff_new_pack.NTXRxg/_new 2026-08-21 17:03:14.959148016 +0200
@@ -18,7 +18,7 @@
%{?sle15_python_module_pythons}
Name: python-aiosmtplib
-Version: 5.1.1
+Version: 5.1.2
Release: 0
Summary: Python asyncio SMTP client
License: MIT
@@ -58,15 +58,7 @@
%python_expand %fdupes %{buildroot}%{$python_sitelib}
%check
-# Disable tests, broken with pytest-asyncio >= 1.0.0
-# gh#cole/aiosmtplib@3b4bd0d7048c
-# https://github.com/cole/aiosmtplib/blob/main/requirements-dev.txt#L2
-# %%pytest -rs -k "not test_live and not test_tls"
-
-%{python_expand # just test import meantime tests are broken
-export PYTHONPATH=${PYTHONPATH:+$PYTHONPATH:}%{buildroot}%{$python_sitelib}
-$python -c "import aiosmtplib; assert aiosmtplib.__version__ == '%{version}'"
-}
+%pytest -rs -k "not test_live and not test_tls"
%files %{python_files}
%doc README.rst docs/*.rst
++++++ aiosmtplib-5.1.1.tar.gz -> aiosmtplib-5.1.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/.circleci/config.yml
new/aiosmtplib-5.1.2/.circleci/config.yml
--- old/aiosmtplib-5.1.1/.circleci/config.yml 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/.circleci/config.yml 2020-02-02 01:00:00.000000000
+0100
@@ -1,7 +1,7 @@
version: 2.1
orbs:
- codecov: codecov/[email protected]
+ codecov: codecov/[email protected]
executors:
cpython_executor:
@@ -45,29 +45,24 @@
executor:
name: cpython_executor
steps:
+ - checkout
- attach_workspace:
at: dist
- - run: uv tool run check-wheel-contents dist/*.whl
- - run: uv tool run twine check --strict dist/*
+ - run: uv tool run --from poethepoet poe buildcheck
typecheck:
executor:
name: cpython_executor
steps:
- checkout
- - run: uv tool run mypy src/aiosmtplib
- - run: uv tool run --with . mypy tests/type_usage.py
- - run: uv tool run pyright src/aiosmtplib
- - run: uv tool run --with . pyright tests/type_usage.py
- - run: uv tool run ty check src/aiosmtplib
- - run: uv tool run --with . ty check tests/type_usage.py
+ - run: uv tool run --from poethepoet poe typecheck
security:
executor:
name: cpython_executor
steps:
- checkout
- - run: uv tool run bandit -n 10 -x tests -r src/aiosmtplib
+ - run: uv tool run --from poethepoet poe security
docs:
executor:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/.pre-commit-config.yaml
new/aiosmtplib-5.1.2/.pre-commit-config.yaml
--- old/aiosmtplib-5.1.1/.pre-commit-config.yaml 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/.pre-commit-config.yaml 2020-02-02
01:00:00.000000000 +0100
@@ -12,7 +12,7 @@
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: "v0.15.12"
+ rev: "v0.15.15"
hooks:
- id: ruff-check
- id: ruff-format
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/CHANGELOG.rst
new/aiosmtplib-5.1.2/CHANGELOG.rst
--- old/aiosmtplib-5.1.1/CHANGELOG.rst 2020-02-02 01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/CHANGELOG.rst 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,35 @@
Changelog
=========
+5.1.2
+-----
+
+- Security: Discard any buffered server data before the STARTTLS handshake,
+ preventing a response-injection attack where a man-in-the-middle pre-stages
+ plaintext that would otherwise be read as part of the encrypted session.
+ More details:
https://github.com/cole/aiosmtplib/security/advisories/GHSA-vxj7-4xrp-5vr4
+- Bugfix: Corrected SMTPResponse ``__repr__`` result
+- Bugfix: Calling ``connect()`` on an already-connected client now raises
+ ``SMTPException`` instead of deadlocking on the connection lock
+- Feature: Poe command runner for dev tasks
+- Bugfix: Timeout ignored during CRAM-MD5 verification
+- Bugfix: Only parse EHLO response after validating success
+- Bugfix: return None from extract_sender when address list is empty
+- Bugfix: Enforce a maximum total response size, preventing unbounded memory
+ use if a server streams data with no line ending or endless multiline
+ continuation lines
+- Bugfix: Use the invalid_response status code (-1) instead of 500 when a
+ server response line exceeds the maximum length
+- Bugfix: A read timeout now closes the connection instead of leaving it in a
+ desynced state, where a late server response could be mispaired with a
+ subsequent command
+- Bugfix: Correctly parse old-style ``AUTH=`` extension advertisements; all
+ advertised methods are now kept (e.g. both PLAIN and LOGIN from
+ ``AUTH=PLAIN LOGIN``)
+- Bugfix: ESMTP extension lines with leading whitespace are no
+ longer ignored
+
+
5.1.1
-----
@@ -16,7 +45,6 @@
before the 221 reply is parsed (e.g. AWS SES closing TLS without
``close_notify``).
-
5.1.0
-----
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/PKG-INFO
new/aiosmtplib-5.1.2/PKG-INFO
--- old/aiosmtplib-5.1.1/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: aiosmtplib
-Version: 5.1.1
+Version: 5.1.2
Summary: asyncio SMTP client
Project-URL: Documentation, https://aiosmtplib.readthedocs.io/en/stable/
Project-URL: Changelog,
https://github.com/cole/aiosmtplib/blob/main/CHANGELOG.rst
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/pyproject.toml
new/aiosmtplib-5.1.2/pyproject.toml
--- old/aiosmtplib-5.1.1/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -43,6 +43,45 @@
[tool.hatch.version]
path = "src/aiosmtplib/__init__.py"
+[tool.poe.tasks]
+lint = { cmd = "uvx ruff check src tests", help = "Lint with ruff" }
+format = { cmd = "uvx ruff format src tests", help = "Format with ruff" }
+format-check = { cmd = "uvx ruff format --check src tests", help = "Check
formatting (no writes)" }
+security = { cmd = "uvx bandit -n 10 -x tests -r src/aiosmtplib", help = "Scan
for security issues with bandit" }
+test = { cmd = "pytest", help = "Run the test suite" }
+coverage-report = { cmd = "uvx coverage report --fail-under=100", help =
"Report coverage (fails under 100%)" }
+docs = { cmd = "uv run --with-requirements docs/requirements.txt --with .
python -m sphinx -nWT -b html docs docs/build/html", help = "Build the HTML
docs" }
+
+[tool.poe.tasks.typecheck-mypy]
+help = "Type check with mypy"
+default_item_type = "cmd"
+sequence = ["uvx mypy src/aiosmtplib", "uvx --with . mypy tests/type_usage.py"]
+
+[tool.poe.tasks.typecheck-pyright]
+help = "Type check with pyright"
+default_item_type = "cmd"
+sequence = ["uvx pyright src/aiosmtplib", "uvx --with . pyright
tests/type_usage.py"]
+
+[tool.poe.tasks.typecheck-ty]
+help = "Type check with ty"
+default_item_type = "cmd"
+sequence = ["uvx ty check src/aiosmtplib", "uvx --with . ty check
tests/type_usage.py"]
+
+[tool.poe.tasks.typecheck]
+help = "Run all type checkers (mypy, pyright, ty)"
+default_item_type = "ref"
+sequence = ["typecheck-mypy", "typecheck-pyright", "typecheck-ty"]
+
+[tool.poe.tasks.buildcheck]
+help = "Validate built artifacts (run `uv build` first)"
+default_item_type = "cmd"
+sequence = ["uvx check-wheel-contents dist", "uvx twine check --strict dist/*"]
+
+[tool.poe.tasks.check]
+help = "Run all lint/type/security/test checks"
+default_item_type = "ref"
+sequence = ["lint", "format-check", "typecheck", "security", "test"]
+
[tool.pytest.ini_options]
pythonpath = "src"
asyncio_mode = "auto"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/requirements-dev.txt
new/aiosmtplib-5.1.2/requirements-dev.txt
--- old/aiosmtplib-5.1.1/requirements-dev.txt 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/requirements-dev.txt 2020-02-02 01:00:00.000000000
+0100
@@ -6,3 +6,4 @@
hypothesis>=6.56
aiosmtpd>=1.4.2
trustme>=0.9.0
+poethepoet>=0.24
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/src/aiosmtplib/__init__.py
new/aiosmtplib-5.1.2/src/aiosmtplib/__init__.py
--- old/aiosmtplib-5.1.1/src/aiosmtplib/__init__.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/src/aiosmtplib/__init__.py 2020-02-02
01:00:00.000000000 +0100
@@ -33,7 +33,7 @@
from .typing import SMTPStatus, SMTPTokenGenerator
__title__ = "aiosmtplib"
-__version__ = "5.1.1"
+__version__ = "5.1.2"
__author__ = "Cole Maclean"
__license__ = "MIT"
__copyright__ = "Copyright 2022 Cole Maclean"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/src/aiosmtplib/email.py
new/aiosmtplib-5.1.2/src/aiosmtplib/email.py
--- old/aiosmtplib-5.1.1/src/aiosmtplib/email.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/src/aiosmtplib/email.py 2020-02-02
01:00:00.000000000 +0100
@@ -141,7 +141,11 @@
if sender_header is None:
return None
- return extract_addresses(sender_header)[0]
+ addresses = extract_addresses(sender_header)
+ if not addresses:
+ return None
+
+ return addresses[0]
def extract_recipients(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/src/aiosmtplib/esmtp.py
new/aiosmtplib-5.1.2/src/aiosmtplib/esmtp.py
--- old/aiosmtplib-5.1.1/src/aiosmtplib/esmtp.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/src/aiosmtplib/esmtp.py 2020-02-02
01:00:00.000000000 +0100
@@ -9,7 +9,6 @@
OLDSTYLE_AUTH_REGEX = re.compile(r"auth=(?P<auth>.*)", flags=re.I)
-EXTENSIONS_REGEX = re.compile(r"(?P<ext>[A-Za-z0-9][A-Za-z0-9\-]*) ?")
def parse_esmtp_extensions(message: str) -> tuple[dict[str, str], list[str]]:
@@ -41,32 +40,32 @@
esmtp_extensions: dict[str, str] = {}
auth_types: list[str] = []
- response_lines = message.split("\n")
+ # Skip the greeting line; each remaining line is "KEYWORD [params]".
+ for line in message.split("\n")[1:]:
+ line = line.strip()
+ if not line:
+ continue
- # ignore the first line
- for line in response_lines[1:]:
# To be able to communicate with as many SMTP servers as possible,
- # we have to take the old-style auth advertisement into account,
- # because:
- # 1) Else our SMTP feature parser gets confused.
- # 2) There are some servers that only advertise the auth methods we
- # support using the old style.
- auth_match = OLDSTYLE_AUTH_REGEX.match(line)
- if auth_match is not None:
- auth_type = auth_match.group("auth")
- auth_types.append(auth_type.lower().strip())
-
- # RFC 1869 requires a space between ehlo keyword and parameters.
- # It's actually stricter, in that only spaces are allowed between
- # parameters, but were not going to check for that here. Note
- # that the space isn't present if there are no parameters.
- extensions = EXTENSIONS_REGEX.match(line)
- if extensions is not None:
- extension = extensions.group("ext").lower()
- params = extensions.string[extensions.end("ext") :].strip()
- esmtp_extensions[extension] = params
+ # we have to take the old-style "AUTH=method[ method...]" advertisement
+ # into account. Some servers only advertise the auth methods we support
+ # using the old style, so register the extension here too (not just the
+ # methods) to keep supports_extension("auth") accurate.
+ oldstyle_auth = OLDSTYLE_AUTH_REGEX.fullmatch(line)
+ if oldstyle_auth is not None:
+ params = oldstyle_auth["auth"]
+ esmtp_extensions["auth"] = params
+ auth_types.extend(method.lower() for method in params.split())
+ continue
+
+ # RFC 1869 requires a space between the ehlo keyword and its parameters
+ # (and only spaces between parameters, though we don't enforce that).
+ # The space isn't present when there are no parameters.
+ keyword, _, params = line.partition(" ")
+ keyword = keyword.lower()
+ esmtp_extensions[keyword] = params
- if extension == "auth":
- auth_types.extend([param.strip().lower() for param in
params.split()])
+ if keyword == "auth":
+ auth_types.extend(method.lower() for method in params.split())
return esmtp_extensions, auth_types
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/src/aiosmtplib/protocol.py
new/aiosmtplib-5.1.2/src/aiosmtplib/protocol.py
--- old/aiosmtplib-5.1.1/src/aiosmtplib/protocol.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/src/aiosmtplib/protocol.py 2020-02-02
01:00:00.000000000 +0100
@@ -23,6 +23,10 @@
MAX_LINE_LENGTH = 8192
+# A whole response (including multiline continuations) may not exceed this.
+# Bounds memory if a server streams data with no line ending or endless
+# continuation lines; generous over any real EHLO, which is a few KB.
+MAX_RESPONSE_LENGTH = MAX_LINE_LENGTH * 4
LINE_ENDINGS_REGEX = re.compile(rb"(?:\r\n|\n|\r(?!\n))")
PERIOD_REGEX = re.compile(rb"(?m)^\.")
# Reject all C0 controls + DEL; CR/LF/NUL in particular enable injection.
@@ -162,6 +166,15 @@
self._buffer.extend(data)
+ if len(self._buffer) > MAX_RESPONSE_LENGTH:
+ del self._buffer[:]
+ self._response_waiter.set_exception(
+ SMTPResponseException(
+ SMTPStatus.invalid_response.value, "Response too long"
+ )
+ )
+ return
+
# If we got an obvious partial message, don't try to parse the buffer
last_linebreak = data.rfind(b"\n")
if (
@@ -217,7 +230,7 @@
if len(line) > MAX_LINE_LENGTH:
raise SMTPResponseException(
- SMTPStatus.unrecognized_command, "Response too long"
+ SMTPStatus.invalid_response.value, "Response too long"
)
try:
@@ -366,6 +379,13 @@
if self.transport is None or self.transport.is_closing():
raise SMTPServerDisconnected("Connection lost")
+ # STARTTLS injection defense (RFC 3207 section 4.2): a compliant
+ # server sends nothing after its 220 reply until TLS is negotiated.
+ # Any bytes still buffered here are plaintext a MITM may have
+ # injected; discard them so they cannot be misread as part of the
+ # encrypted session once the handshake completes.
+ del self._buffer[:]
+
try:
tls_transport = await self._loop.start_tls(
cast(asyncio.WriteTransport, self.transport),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/src/aiosmtplib/response.py
new/aiosmtplib-5.1.2/src/aiosmtplib/response.py
--- old/aiosmtplib-5.1.1/src/aiosmtplib/response.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/src/aiosmtplib/response.py 2020-02-02
01:00:00.000000000 +0100
@@ -27,8 +27,5 @@
code: int
message: str
- def __repr__(self) -> str:
- return f"({self.code}, {self.message})"
-
def __str__(self) -> str:
return f"{self.code} {self.message}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/src/aiosmtplib/smtp.py
new/aiosmtplib-5.1.2/src/aiosmtplib/smtp.py
--- old/aiosmtplib-5.1.1/src/aiosmtplib/smtp.py 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/src/aiosmtplib/smtp.py 2020-02-02 01:00:00.000000000
+0100
@@ -440,6 +440,11 @@
self.loop = asyncio.get_running_loop()
if self._connect_lock is None:
self._connect_lock = asyncio.Lock()
+
+ if self.is_connected:
+ raise SMTPException("SMTP instance is already connected")
+
+ # The lock is held until close(), serializing concurrent connect calls.
await self._connect_lock.acquire()
# If we're not using a socket, default to port and hostname
@@ -595,7 +600,10 @@
response = await self.protocol.execute_command(
*args, timeout=self.timeout if timeout is Default.token else
timeout
)
- except SMTPServerDisconnected:
+ except (SMTPServerDisconnected, SMTPTimeoutError):
+ # A read timeout leaves the protocol desynced (a partial buffer and
+ # a server response we gave up on), so close rather than risk
+ # mispairing that response with a later command.
self.close()
raise
@@ -938,7 +946,7 @@
try:
return await self.protocol.execute_data_command(message,
timeout=timeout)
- except SMTPServerDisconnected:
+ except (SMTPServerDisconnected, SMTPTimeoutError):
self.close()
raise
@@ -967,11 +975,12 @@
response = await self.execute_command(
b"EHLO", hostname.encode("ascii"), timeout=timeout
)
- self.last_ehlo_response = response
if response.code != SMTPStatus.completed:
raise SMTPHeloError(response.code, response.message)
+ self.last_ehlo_response = response
+
return response
def supports_extension(self, extension: str, /) -> bool:
@@ -1067,7 +1076,7 @@
response = await self.protocol.start_tls(
tls_context, server_hostname=server_hostname, timeout=timeout
)
- except SMTPServerDisconnected:
+ except (SMTPServerDisconnected, SMTPTimeoutError):
self.close()
raise
@@ -1165,7 +1174,7 @@
verification_bytes = auth_crammd5_verify(
username, password, initial_response.message
)
- response = await self.execute_command(verification_bytes)
+ response = await self.execute_command(verification_bytes,
timeout=timeout)
if response.code != SMTPStatus.auth_successful:
raise SMTPAuthenticationError(response.code, response.message)
@@ -1337,7 +1346,7 @@
... await smtp.sendmail("[email protected]", recipients, message)
... return await smtp.quit()
>>> asyncio.run(connect_and_send())
- (221, Bye)
+ SMTPResponse(code=221, message='Bye')
In the above example, the message was accepted for delivery for all
three addresses. If delivery had been only successful to two
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/auth.py
new/aiosmtplib-5.1.2/tests/auth.py
--- old/aiosmtplib-5.1.1/tests/auth.py 2020-02-02 01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/tests/auth.py 2020-02-02 01:00:00.000000000 +0100
@@ -12,6 +12,7 @@
super().__init__(*args, **kwargs)
self.received_commands: list[bytes] = []
+ self.received_kwargs: list[dict[str, Any]] = []
self.responses: deque[tuple[int, str]] = deque()
self.esmtp_extensions = {"auth": ""}
self.server_auth_methods = ["cram-md5", "login", "plain"]
@@ -19,6 +20,7 @@
async def execute_command(self, *args: Any, **kwargs: Any) -> SMTPResponse:
self.received_commands.append(b" ".join(args))
+ self.received_kwargs.append(kwargs)
response = self.responses.popleft()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_auth_methods.py
new/aiosmtplib-5.1.2/tests/test_auth_methods.py
--- old/aiosmtplib-5.1.1/tests/test_auth_methods.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/tests/test_auth_methods.py 2020-02-02
01:00:00.000000000 +0100
@@ -304,3 +304,22 @@
encoded = auth_xoauth2_encode("[email protected]", "test_oauth_token")
assert mock_auth.received_commands == [b"AUTH XOAUTH2 " + encoded]
+
+
+async def test_auth_crammd5_passes_timeout(mock_auth: DummySMTPAuth) -> None:
+ """
+ Test that auth_crammd5 passes timeout to the verification command.
+
+ Both execute_command calls in auth_crammd5 should receive the timeout
parameter.
+ """
+ continue_response = (
+ SMTPStatus.auth_continue,
+ base64.b64encode(b"challenge").decode("utf-8"),
+ )
+ mock_auth.responses.extend([continue_response, SUCCESS_RESPONSE])
+ await mock_auth.auth_crammd5("user", "pass", timeout=5.0)
+
+ # Both commands should have received the timeout
+ assert len(mock_auth.received_kwargs) == 2
+ assert mock_auth.received_kwargs[0].get("timeout") == 5.0
+ assert mock_auth.received_kwargs[1].get("timeout") == 5.0
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_commands.py
new/aiosmtplib-5.1.2/tests/test_commands.py
--- old/aiosmtplib-5.1.1/tests/test_commands.py 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/tests/test_commands.py 2020-02-02 01:00:00.000000000
+0100
@@ -94,6 +94,25 @@
assert exception_info.value.code == SMTPStatus.unrecognized_command
[email protected]_mocks(smtp_EHLO=mock_response_unrecognized_command)
+async def test_ehlo_error_does_not_set_supports_esmtp(smtp_client: SMTP) ->
None:
+ """
+ Test that a failed EHLO response does not set supports_esmtp to True.
+
+ The EHLO response should only be parsed for extensions after validating
+ that the response code indicates success.
+ """
+ async with smtp_client:
+ assert smtp_client.supports_esmtp is False
+
+ with pytest.raises(SMTPHeloError):
+ await smtp_client.ehlo()
+
+ assert smtp_client.supports_esmtp is False
+ assert smtp_client.last_ehlo_response is None
+ assert smtp_client.esmtp_extensions == {}
+
+
@pytest.mark.smtpd_mocks(smtp_EHLO=mock_response_ehlo_full)
async def test_ehlo_parses_esmtp_extensions(smtp_client: SMTP) -> None:
async with smtp_client:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_connect.py
new/aiosmtplib-5.1.2/tests/test_connect.py
--- old/aiosmtplib-5.1.1/tests/test_connect.py 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/tests/test_connect.py 2020-02-02 01:00:00.000000000
+0100
@@ -69,6 +69,22 @@
assert response.code == SMTPStatus.completed
+async def test_connect_while_connected_raises(
+ smtp_client: SMTP, smtpd_server: asyncio.AbstractServer
+) -> None:
+ await smtp_client.connect()
+ assert smtp_client.is_connected
+
+ # Connecting an already-connected client is an error, not a deadlock.
+ with pytest.raises(SMTPException, match="already connected"):
+ await asyncio.wait_for(smtp_client.connect(), 1.0)
+
+ # The existing connection is untouched.
+ assert smtp_client.is_connected
+ response = await smtp_client.noop()
+ assert response.code == SMTPStatus.completed
+
+
@pytest.mark.smtpd_mocks(_handle_client=mock_response_unavailable)
async def test_bad_connect_response_raises_error(smtp_client: SMTP) -> None:
with pytest.raises(SMTPConnectError):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_email_utils.py
new/aiosmtplib-5.1.2/tests/test_email_utils.py
--- old/aiosmtplib-5.1.1/tests/test_email_utils.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/tests/test_email_utils.py 2020-02-02
01:00:00.000000000 +0100
@@ -362,3 +362,18 @@
with pytest.raises(ValueError):
extract_sender(message)
+
+
+def test_extract_sender_empty_address_returns_none() -> None:
+ """
+ Test that extract_sender returns None when the From header contains no
valid addresses.
+
+ If extract_addresses() returns an empty list (e.g., malformed From header),
+ extract_sender should return None instead of raising IndexError.
+ """
+ message = EmailMessage()
+ # Use undisclosed-recipients which parses to an empty list
+ message["From"] = "undisclosed-recipients:;"
+
+ sender = extract_sender(message)
+ assert sender is None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_esmtp_utils.py
new/aiosmtplib-5.1.2/tests/test_esmtp_utils.py
--- old/aiosmtplib-5.1.1/tests/test_esmtp_utils.py 2020-02-02
01:00:00.000000000 +0100
+++ new/aiosmtplib-5.1.2/tests/test_esmtp_utils.py 2020-02-02
01:00:00.000000000 +0100
@@ -42,10 +42,10 @@
def test_auth_type_parsing() -> None:
- response = """blah blah blah
+ response = """mail.example.com Hello [127.0.0.1]
AUTH FOO BAR
"""
- extensions, auth_types = parse_esmtp_extensions(response)
+ _, auth_types = parse_esmtp_extensions(response)
assert "foo" in auth_types
assert "bar" in auth_types
@@ -53,21 +53,59 @@
def test_old_school_auth_type_parsing() -> None:
- response = """blah blah blah
+ response = """mail.example.com Hello [127.0.0.1]
AUTH=PLAIN
"""
- extensions, auth_types = parse_esmtp_extensions(response)
+ _, auth_types = parse_esmtp_extensions(response)
assert "plain" in auth_types
assert "cram-md5" not in auth_types
def test_mixed_auth_type_parsing() -> None:
- response = """blah blah blah
+ response = """mail.example.com Hello [127.0.0.1]
AUTH=PLAIN
AUTH CRAM-MD5
"""
- extensions, auth_types = parse_esmtp_extensions(response)
+ _, auth_types = parse_esmtp_extensions(response)
assert "plain" in auth_types
assert "cram-md5" in auth_types
+
+
+def test_old_school_multiple_auth_type_parsing() -> None:
+ response = """mail.example.com Hello [127.0.0.1]
+AUTH=PLAIN LOGIN
+ """
+ extensions, auth_types = parse_esmtp_extensions(response)
+
+ # Every space-separated method in the advertisement is kept.
+ assert auth_types == ["plain", "login"]
+ # login() requires the "auth" extension to be registered for old-style
+ # advertisements, so supports_extension("auth") stays accurate.
+ assert "auth" in extensions
+
+
+def test_old_school_only_auth_no_junk() -> None:
+ response = """mail.example.com Hello [127.0.0.1]
+AUTH=CRAM-MD5
+ """
+ extensions, auth_types = parse_esmtp_extensions(response)
+
+ assert "auth" in extensions
+ assert auth_types == ["cram-md5"]
+
+
+def test_leading_whitespace_extension_parsing() -> None:
+ response = "mail.example.com Hello [127.0.0.1]\n SIZE 1000"
+ extensions, _ = parse_esmtp_extensions(response)
+
+ assert extensions["size"] == "1000"
+
+
+def test_blank_line_no_empty_keyword() -> None:
+ response = "mail.example.com Hello [127.0.0.1]\n\n8BITMIME\n"
+ extensions, _ = parse_esmtp_extensions(response)
+
+ assert "" not in extensions
+ assert "8bitmime" in extensions
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_protocol.py
new/aiosmtplib-5.1.2/tests/test_protocol.py
--- old/aiosmtplib-5.1.1/tests/test_protocol.py 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/tests/test_protocol.py 2020-02-02 01:00:00.000000000
+0100
@@ -63,7 +63,82 @@
with pytest.raises(SMTPResponseException) as exc_info:
await protocol.execute_command(b"TEST", timeout=1.0) # type: ignore
- assert exc_info.value.code == 500
+ assert exc_info.value.code == -1
+ assert "Response too long" in exc_info.value.message
+
+ server.close()
+ await cleanup_server(server)
+
+
+async def test_protocol_response_no_newline_overrun(
+ bind_address: str,
+ hostname: str,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ event_loop = asyncio.get_running_loop()
+
+ async def client_connected(
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
+ ) -> None:
+ await reader.read(1000)
+ # No line ending at all, so the per-line cap is never reached.
+ writer.write(b"2" * 500)
+ await writer.drain()
+
+ server = await asyncio.start_server(
+ client_connected, host=bind_address, port=0, family=socket.AF_INET
+ )
+ server_port = server.sockets[0].getsockname()[1] if server.sockets else 0
+ connect_future = event_loop.create_connection(
+ SMTPProtocol, host=hostname, port=server_port
+ )
+
+ _, protocol = await asyncio.wait_for(connect_future, timeout=1.0)
+
+ monkeypatch.setattr("aiosmtplib.protocol.MAX_RESPONSE_LENGTH", 128)
+
+ with pytest.raises(SMTPResponseException) as exc_info:
+ await protocol.execute_command(b"TEST", timeout=1.0) # type: ignore
+
+ assert exc_info.value.code == -1
+ assert "Response too long" in exc_info.value.message
+
+ server.close()
+ await cleanup_server(server)
+
+
+async def test_protocol_response_continuation_overrun(
+ bind_address: str,
+ hostname: str,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ event_loop = asyncio.get_running_loop()
+
+ async def client_connected(
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
+ ) -> None:
+ await reader.read(1000)
+ # Endless multiline continuation; each line is well under the per-line
+ # cap, so only the total response cap can stop it.
+ writer.write(b"250-spam\r\n" * 100)
+ await writer.drain()
+
+ server = await asyncio.start_server(
+ client_connected, host=bind_address, port=0, family=socket.AF_INET
+ )
+ server_port = server.sockets[0].getsockname()[1] if server.sockets else 0
+ connect_future = event_loop.create_connection(
+ SMTPProtocol, host=hostname, port=server_port
+ )
+
+ _, protocol = await asyncio.wait_for(connect_future, timeout=1.0)
+
+ monkeypatch.setattr("aiosmtplib.protocol.MAX_RESPONSE_LENGTH", 128)
+
+ with pytest.raises(SMTPResponseException) as exc_info:
+ await protocol.execute_command(b"TEST", timeout=1.0) # type: ignore
+
+ assert exc_info.value.code == -1
assert "Response too long" in exc_info.value.message
server.close()
@@ -162,6 +237,53 @@
transport.close()
+async def test_protocol_discards_buffer_before_tls_handshake(
+ bind_address: str,
+ hostname: str,
+ client_tls_context: ssl.SSLContext,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """
+ Bytes a MITM injects after the 220 STARTTLS reply must not survive into the
+ encrypted session.
+ """
+ event_loop = asyncio.get_running_loop()
+
+ async def client_connected(
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter
+ ) -> None:
+ await reader.readuntil(b"\r\n")
+ # 220 reply plus injected plaintext, in a single segment.
+ writer.write(b"220 Go ahead\r\n250-mx.evil\r\n250 AUTH LOGIN\r\n")
+ await writer.drain()
+ await reader.read() # keep the connection open through start_tls
+
+ server = await asyncio.start_server(
+ client_connected, host=bind_address, port=0, family=socket.AF_INET
+ )
+ server_port = server.sockets[0].getsockname()[1] if server.sockets else 0
+ connect_future = event_loop.create_connection(
+ SMTPProtocol, host=hostname, port=server_port
+ )
+ _, protocol = await asyncio.wait_for(connect_future, timeout=1.0)
+
+ captured: dict[str, bytes] = {}
+
+ async def mock_start_tls(transport, proto, *args, **kwargs): # type:
ignore[no-untyped-def]
+ captured["buffer"] = bytes(proto._buffer)
+ return transport
+
+ monkeypatch.setattr(event_loop, "start_tls", mock_start_tls)
+
+ response = await protocol.start_tls(client_tls_context, timeout=1.0) #
type: ignore[union-attr]
+
+ assert response.code == 220
+ assert captured["buffer"] == b""
+
+ server.close()
+ await cleanup_server(server)
+
+
async def test_error_on_readline_with_partial_line(
bind_address: str, hostname: str
) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_response.py
new/aiosmtplib-5.1.2/tests/test_response.py
--- old/aiosmtplib-5.1.1/tests/test_response.py 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/tests/test_response.py 2020-02-02 01:00:00.000000000
+0100
@@ -7,7 +7,11 @@
@given(integers(), text())
def test_response_repr(code: int, message: str) -> None:
response = SMTPResponse(code, message)
- assert repr(response) == f"({response.code}, {response.message})"
+ assert (
+ repr(response)
+ == f"SMTPResponse(code={response.code!r},
message={response.message!r})"
+ )
+ assert eval(repr(response)) == response
@given(integers(), text())
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/aiosmtplib-5.1.1/tests/test_timeouts.py
new/aiosmtplib-5.1.2/tests/test_timeouts.py
--- old/aiosmtplib-5.1.1/tests/test_timeouts.py 2020-02-02 01:00:00.000000000
+0100
+++ new/aiosmtplib-5.1.2/tests/test_timeouts.py 2020-02-02 01:00:00.000000000
+0100
@@ -27,6 +27,24 @@
with pytest.raises(SMTPTimeoutError):
await smtp_client.ehlo(hostname="example.com", timeout=0.0)
+ # A read timeout closes the connection, since the protocol is now desynced.
+ assert smtp_client.protocol is None
+ assert smtp_client.transport is None
+ assert not smtp_client.is_connected
+
+
[email protected]_mocks(smtp_EHLO=mock_response_delayed_ok)
+async def test_command_timeout_closes_connection(smtp_client: SMTP) -> None:
+ await smtp_client.connect()
+
+ with pytest.raises(SMTPTimeoutError):
+ await smtp_client.ehlo(hostname="example.com", timeout=0.0)
+
+ # Reusing a timed-out connection must fail cleanly rather than mispair a
+ # late server response with the next command.
+ with pytest.raises(SMTPServerDisconnected):
+ await smtp_client.noop()
+
@pytest.mark.smtpd_mocks(smtp_DATA=mock_response_delayed_ok)
async def test_data_timeout_error(smtp_client: SMTP) -> None:
@@ -37,6 +55,10 @@
with pytest.raises(SMTPTimeoutError):
await smtp_client.data("HELLO WORLD", timeout=0.0)
+ assert smtp_client.protocol is None
+ assert smtp_client.transport is None
+ assert not smtp_client.is_connected
+
@pytest.mark.smtpd_mocks(_handle_client=mock_response_delayed_ok)
async def test_timeout_error_on_connect(smtp_client: SMTP) -> None:
@@ -62,6 +84,10 @@
with pytest.raises(SMTPTimeoutError):
await smtp_client.starttls(timeout=0.0)
+ assert smtp_client.protocol is None
+ assert smtp_client.transport is None
+ assert not smtp_client.is_connected
+
async def test_protocol_read_response_with_timeout_times_out(
echo_server: asyncio.AbstractServer,