Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ietf/utils/management/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ietf.utils.test_utils import TestCase


@mock.patch.object(EmailOnFailureCommand, 'handle')
@mock.patch.object(EmailOnFailureCommand, 'handle', return_value=None)
class EmailOnFailureCommandTests(TestCase):
def test_calls_handle(self, handle_method):
call_command(EmailOnFailureCommand())
Expand Down
2 changes: 1 addition & 1 deletion ietf/utils/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,7 @@ def setup_test_environment(self, **kwargs):
try:
# remember the value so ietf.utils.mail.send_smtp() will use the same
ietf.utils.mail.SMTP_ADDR['port'] = base + offset
self.smtpd_driver = SMTPTestServerDriver((ietf.utils.mail.SMTP_ADDR['ip4'],ietf.utils.mail.SMTP_ADDR['port']),None)
self.smtpd_driver = SMTPTestServerDriver(ietf.utils.mail.SMTP_ADDR['ip4'],ietf.utils.mail.SMTP_ADDR['port'], None)
self.smtpd_driver.start()
print((" Running an SMTP test server on %(ip4)s:%(port)s to catch outgoing email." % ietf.utils.mail.SMTP_ADDR))
break
Expand Down
116 changes: 40 additions & 76 deletions ietf/utils/test_smtpserver.py
Original file line number Diff line number Diff line change
@@ -1,92 +1,56 @@
# Copyright The IETF Trust 2014-2020, All Rights Reserved
# Copyright The IETF Trust 2014-2025, All Rights Reserved
# -*- coding: utf-8 -*-

from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP
from email.utils import parseaddr
from typing import Optional

import smtpd
import threading
import asyncore

import debug # pyflakes:ignore
class SMTPTestHandler:

class AsyncCoreLoopThread(object):
def __init__(self, inbox: list):
self.inbox = inbox

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

def start(self):
"""Start the listening service"""
self.exit_condition = []
kwargs={'exit_condition':self.exit_condition,'timeout':1.0}
self.thread = threading.Thread(target=self.wrap_loop, kwargs=kwargs)
self.thread.daemon = True
self.thread.daemon = True
self.thread.start()

def stop(self):
"""Stop the listening service"""
self.exit_condition.append(True)
self.thread.join()


class SMTPTestChannel(smtpd.SMTPChannel):
self.inbox.append(envelope.content)
# Per RFC2033: https://datatracker.ietf.org/doc/html/rfc2033.html#section-4.2
# ...after the final ".", the server returns one reply
# for each previously successful RCPT command in the mail transaction,
# in the order that the RCPT commands were issued. Even if there were
# multiple successful RCPT commands giving the same forward-path, there
# must be one reply for each successful RCPT command.
return "\n".join("250 OK" for _ in envelope.rcpt_tos)

# mail_options = ['BODY=8BITMIME', 'SMTPUTF8']

def smtp_RCPT(self, arg):
if not self.mailfrom:
self.push(str('503 Error: need MAIL command'))
return
arg = self._strip_command_keyword('TO:', arg)
address, __ = self._getaddr(arg)
if not address:
self.push(str('501 Syntax: RCPT TO: <address>'))
return
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
"""Handle an RCPT command and add the address to the envelope if it is acceptable"""
_, address = parseaddr(address)
if address == "":
return "501 Syntax: RCPT TO: <address>"
if "poison" in address:
self.push(str('550 Error: Not touching that'))
return
self.rcpt_options = []
self.rcpttos.append(address)
self.push(str('250 Ok'))

class SMTPTestServer(smtpd.SMTPServer):

def __init__(self,localaddr,remoteaddr,inbox):
if inbox is not None:
self.inbox=inbox
else:
self.inbox = []
smtpd.SMTPServer.__init__(self,localaddr,remoteaddr)
return "550 Error: Not touching that"
# At this point the address is acceptable
envelope.rcpt_tos.append(address)
return "250 OK"

def handle_accept(self):
pair = self.accept()
if pair is not None:
conn, addr = pair
#channel = SMTPTestChannel(self, conn, addr)
SMTPTestChannel(self, conn, addr)

def process_message(self, peer, mailfrom, rcpttos, data, mail_options=None, rcpt_options=None):
self.inbox.append(data)
class SMTPTestServerDriver:


class SMTPTestServerDriver(object):
def __init__(self, localaddr, remoteaddr, inbox=None):
self.localaddr=localaddr
self.remoteaddr=remoteaddr
if inbox is not None:
self.inbox = inbox
else:
self.inbox = []
self.thread_driver = None
def __init__(self, address: str, port: int, inbox: Optional[list] = None):
# Allow longer lines than the 1001 that RFC 5321 requires. As of 2025-04-16 the
# datatracker emits some non-compliant messages.
# See https://aiosmtpd.aio-libs.org/en/latest/smtp.html
SMTP.line_length_limit = 4000 # tests start failing between 3000 and 4000
self.controller = Controller(
hostname=address,
port=port,
handler=SMTPTestHandler(inbox=[] if inbox is None else inbox),
)

def start(self):
self.smtpserver = SMTPTestServer(self.localaddr,self.remoteaddr,self.inbox)
self.thread_driver = AsyncCoreLoopThread()
self.thread_driver.start()
self.controller.start()

def stop(self):
if self.thread_driver:
self.thread_driver.stop()

self.controller.stop()
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- conf-mode -*-
setuptools>=51.1.0 # Require this first, to prevent later errors
#
aiosmtpd>=1.4.6

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO moving forward, we should try to separate test only dependencies. Not saying we should start with this PR, but better to start somewhere?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will help us to have clear SBOM for production server.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the idea, though have some concerns about how we would validate changes to the production dependency list prior to deployment.

I don't think it's practical to start in this PR because there will be quite a few tooling and deployment adjustments needed regardless of how we do it. It'll need to be its own project.

argon2-cffi>=21.3.0 # For the Argon2 password hasher option
beautifulsoup4>=4.11.1 # Only used in tests
bibtexparser>=1.2.0 # Only used in tests
Expand Down