Skip to content

Commit 2404263

Browse files
refactor: smtpd->aiosmtpd (#8805)
* refactor: smtpd -> aiosmtpd * test: set mock return value for EmailOnFailureCommandTests The test has been working, but in a broken way, for as long as it has existed. The smtpd-based test_smtpserver was masking an exception that did not interfere with the test's effectiveness. * test: increase SMTP.line_length_limit
1 parent 4a5716e commit 2404263

File tree

4 files changed

+43
-78
lines changed

4 files changed

+43
-78
lines changed

ietf/utils/management/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ietf.utils.test_utils import TestCase
1313

1414

15-
@mock.patch.object(EmailOnFailureCommand, 'handle')
15+
@mock.patch.object(EmailOnFailureCommand, 'handle', return_value=None)
1616
class EmailOnFailureCommandTests(TestCase):
1717
def test_calls_handle(self, handle_method):
1818
call_command(EmailOnFailureCommand())

ietf/utils/test_runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@ def setup_test_environment(self, **kwargs):
863863
try:
864864
# remember the value so ietf.utils.mail.send_smtp() will use the same
865865
ietf.utils.mail.SMTP_ADDR['port'] = base + offset
866-
self.smtpd_driver = SMTPTestServerDriver((ietf.utils.mail.SMTP_ADDR['ip4'],ietf.utils.mail.SMTP_ADDR['port']),None)
866+
self.smtpd_driver = SMTPTestServerDriver(ietf.utils.mail.SMTP_ADDR['ip4'],ietf.utils.mail.SMTP_ADDR['port'], None)
867867
self.smtpd_driver.start()
868868
print((" Running an SMTP test server on %(ip4)s:%(port)s to catch outgoing email." % ietf.utils.mail.SMTP_ADDR))
869869
break

ietf/utils/test_smtpserver.py

Lines changed: 40 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,56 @@
1-
# Copyright The IETF Trust 2014-2020, All Rights Reserved
1+
# Copyright The IETF Trust 2014-2025, All Rights Reserved
22
# -*- coding: utf-8 -*-
33

4+
from aiosmtpd.controller import Controller
5+
from aiosmtpd.smtp import SMTP
6+
from email.utils import parseaddr
7+
from typing import Optional
48

5-
import smtpd
6-
import threading
7-
import asyncore
89

9-
import debug # pyflakes:ignore
10+
class SMTPTestHandler:
1011

11-
class AsyncCoreLoopThread(object):
12+
def __init__(self, inbox: list):
13+
self.inbox = inbox
1214

13-
def wrap_loop(self, exit_condition, timeout=1.0, use_poll=False, map=None):
14-
if map is None:
15-
map = asyncore.socket_map
16-
while map and not exit_condition:
17-
asyncore.loop(timeout=1.0, use_poll=False, map=map, count=1)
15+
async def handle_DATA(self, server, session, envelope):
16+
"""Handle the DATA command and 'deliver' the message"""
1817

19-
def start(self):
20-
"""Start the listening service"""
21-
self.exit_condition = []
22-
kwargs={'exit_condition':self.exit_condition,'timeout':1.0}
23-
self.thread = threading.Thread(target=self.wrap_loop, kwargs=kwargs)
24-
self.thread.daemon = True
25-
self.thread.daemon = True
26-
self.thread.start()
27-
28-
def stop(self):
29-
"""Stop the listening service"""
30-
self.exit_condition.append(True)
31-
self.thread.join()
32-
33-
34-
class SMTPTestChannel(smtpd.SMTPChannel):
18+
self.inbox.append(envelope.content)
19+
# Per RFC2033: https://datatracker.ietf.org/doc/html/rfc2033.html#section-4.2
20+
# ...after the final ".", the server returns one reply
21+
# for each previously successful RCPT command in the mail transaction,
22+
# in the order that the RCPT commands were issued. Even if there were
23+
# multiple successful RCPT commands giving the same forward-path, there
24+
# must be one reply for each successful RCPT command.
25+
return "\n".join("250 OK" for _ in envelope.rcpt_tos)
3526

36-
# mail_options = ['BODY=8BITMIME', 'SMTPUTF8']
37-
38-
def smtp_RCPT(self, arg):
39-
if not self.mailfrom:
40-
self.push(str('503 Error: need MAIL command'))
41-
return
42-
arg = self._strip_command_keyword('TO:', arg)
43-
address, __ = self._getaddr(arg)
44-
if not address:
45-
self.push(str('501 Syntax: RCPT TO: <address>'))
46-
return
27+
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
28+
"""Handle an RCPT command and add the address to the envelope if it is acceptable"""
29+
_, address = parseaddr(address)
30+
if address == "":
31+
return "501 Syntax: RCPT TO: <address>"
4732
if "poison" in address:
48-
self.push(str('550 Error: Not touching that'))
49-
return
50-
self.rcpt_options = []
51-
self.rcpttos.append(address)
52-
self.push(str('250 Ok'))
53-
54-
class SMTPTestServer(smtpd.SMTPServer):
55-
56-
def __init__(self,localaddr,remoteaddr,inbox):
57-
if inbox is not None:
58-
self.inbox=inbox
59-
else:
60-
self.inbox = []
61-
smtpd.SMTPServer.__init__(self,localaddr,remoteaddr)
33+
return "550 Error: Not touching that"
34+
# At this point the address is acceptable
35+
envelope.rcpt_tos.append(address)
36+
return "250 OK"
6237

63-
def handle_accept(self):
64-
pair = self.accept()
65-
if pair is not None:
66-
conn, addr = pair
67-
#channel = SMTPTestChannel(self, conn, addr)
68-
SMTPTestChannel(self, conn, addr)
6938

70-
def process_message(self, peer, mailfrom, rcpttos, data, mail_options=None, rcpt_options=None):
71-
self.inbox.append(data)
39+
class SMTPTestServerDriver:
7240

73-
74-
class SMTPTestServerDriver(object):
75-
def __init__(self, localaddr, remoteaddr, inbox=None):
76-
self.localaddr=localaddr
77-
self.remoteaddr=remoteaddr
78-
if inbox is not None:
79-
self.inbox = inbox
80-
else:
81-
self.inbox = []
82-
self.thread_driver = None
41+
def __init__(self, address: str, port: int, inbox: Optional[list] = None):
42+
# Allow longer lines than the 1001 that RFC 5321 requires. As of 2025-04-16 the
43+
# datatracker emits some non-compliant messages.
44+
# See https://aiosmtpd.aio-libs.org/en/latest/smtp.html
45+
SMTP.line_length_limit = 4000 # tests start failing between 3000 and 4000
46+
self.controller = Controller(
47+
hostname=address,
48+
port=port,
49+
handler=SMTPTestHandler(inbox=[] if inbox is None else inbox),
50+
)
8351

8452
def start(self):
85-
self.smtpserver = SMTPTestServer(self.localaddr,self.remoteaddr,self.inbox)
86-
self.thread_driver = AsyncCoreLoopThread()
87-
self.thread_driver.start()
53+
self.controller.start()
8854

8955
def stop(self):
90-
if self.thread_driver:
91-
self.thread_driver.stop()
92-
56+
self.controller.stop()

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- conf-mode -*-
22
setuptools>=51.1.0 # Require this first, to prevent later errors
33
#
4+
aiosmtpd>=1.4.6
45
argon2-cffi>=21.3.0 # For the Argon2 password hasher option
56
beautifulsoup4>=4.11.1 # Only used in tests
67
bibtexparser>=1.2.0 # Only used in tests

0 commit comments

Comments
 (0)