summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--port_me/checkdbs.py2
-rw-r--r--setup.py2
-rw-r--r--src/mailman/app/moderator.py2
-rw-r--r--src/mailman/app/subscriptions.py7
-rw-r--r--src/mailman/app/tests/test_moderation.py21
-rw-r--r--src/mailman/app/tests/test_subscriptions.py54
-rw-r--r--src/mailman/app/tests/test_workflow.py53
-rw-r--r--src/mailman/app/workflow.py8
-rw-r--r--src/mailman/bin/master.py10
-rw-r--r--src/mailman/chains/hold.py2
-rw-r--r--src/mailman/chains/tests/test_accept.py2
-rw-r--r--src/mailman/chains/tests/test_headers.py2
-rw-r--r--src/mailman/chains/tests/test_hold.py11
-rw-r--r--src/mailman/chains/tests/test_owner.py2
-rw-r--r--src/mailman/commands/cli_control.py2
-rwxr-xr-xsrc/mailman/compat/smtpd.py977
-rw-r--r--src/mailman/config/tests/test_configuration.py2
-rw-r--r--src/mailman/core/logging.py4
-rw-r--r--src/mailman/database/tests/test_migrations.py2
-rw-r--r--src/mailman/docs/NEWS.rst9
-rw-r--r--src/mailman/email/message.py12
-rw-r--r--src/mailman/handlers/cook_headers.py2
-rw-r--r--src/mailman/interfaces/template.py1
-rw-r--r--src/mailman/model/tests/test_domain.py2
-rw-r--r--src/mailman/model/tests/test_requests.py2
-rw-r--r--src/mailman/mta/base.py4
-rw-r--r--src/mailman/mta/tests/test_connection.py47
-rw-r--r--src/mailman/rest/tests/test_paginate.py2
-rw-r--r--src/mailman/rest/users.py2
-rw-r--r--src/mailman/runners/docs/lmtp.rst2
-rw-r--r--src/mailman/runners/lmtp.py97
-rw-r--r--src/mailman/runners/rest.py2
-rw-r--r--src/mailman/runners/tests/test_owner.py2
-rw-r--r--src/mailman/testing/helpers.py2
-rw-r--r--src/mailman/testing/mta.py237
-rw-r--r--src/mailman/testing/nose.py2
-rw-r--r--src/mailman/tests/test_configfile.py2
-rw-r--r--src/mailman/utilities/tests/test_templates.py10
-rw-r--r--src/mailman/utilities/tests/test_uid.py2
39 files changed, 388 insertions, 1218 deletions
diff --git a/port_me/checkdbs.py b/port_me/checkdbs.py
index 9e5213b4c..7f46e97d4 100644
--- a/port_me/checkdbs.py
+++ b/port_me/checkdbs.py
@@ -199,7 +199,7 @@ def main():
mlist.GetBouncesEmail(),
subject, text,
mlist.preferred_language)
- msg.send(mlist, **{'tomoderators': True})
+ msg.send(mlist, to_moderators=True)
finally:
mlist.Unlock()
diff --git a/setup.py b/setup.py
index 4891ff7b9..2d182cdc7 100644
--- a/setup.py
+++ b/setup.py
@@ -104,6 +104,7 @@ case second `m'. Any other spelling is incorrect.""",
'flake8.extension': ['B4 = mailman.testing.flake8:ImportOrder'],
},
install_requires = [
+ 'aiosmtpd',
'alembic',
'falcon>=1.0.0rc1',
'flufl.bounce',
@@ -111,7 +112,6 @@ case second `m'. Any other spelling is incorrect.""",
'flufl.lock',
'httplib2',
'lazr.config',
- 'lazr.smtptest',
'nose2',
'passlib',
'requests',
diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py
index c3347c1ff..9d3856f33 100644
--- a/src/mailman/app/moderator.py
+++ b/src/mailman/app/moderator.py
@@ -204,7 +204,7 @@ def hold_unsubscription(mlist, email):
msg = UserNotification(
mlist.owner_address, mlist.owner_address,
subject, text, mlist.preferred_language)
- msg.send(mlist, tomoderators=True)
+ msg.send(mlist, to_moderators=True)
return request_id
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index 9e915a25d..f88a967c2 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -102,7 +102,8 @@ class _SubscriptionWorkflowCommon(Workflow):
# For restore.
uid = uuid.UUID(hex_key)
self.user = getUtility(IUserManager).get_user_by_id(uid)
- assert self.user is not None
+ if self.user is None:
+ self.user = self.address.user
@property
def address_key(self):
@@ -286,7 +287,7 @@ class SubscriptionWorkflow(_SubscriptionWorkflowCommon):
msg = UserNotification(
self.mlist.owner_address, self.mlist.owner_address,
subject, text, self.mlist.preferred_language)
- msg.send(self.mlist, tomoderators=True)
+ msg.send(self.mlist, to_moderators=True)
# The workflow must stop running here.
raise StopIteration
@@ -434,7 +435,7 @@ class UnSubscriptionWorkflow(_SubscriptionWorkflowCommon):
msg = UserNotification(
self.mlist.owner_address, self.mlist.owner_address,
subject, text, self.mlist.preferred_language)
- msg.send(self.mlist, tomoderators=True)
+ msg.send(self.mlist, to_moderators=True)
# The workflow must stop running here
raise StopIteration
diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py
index 20584da49..bb3958cd7 100644
--- a/src/mailman/app/tests/test_moderation.py
+++ b/src/mailman/app/tests/test_moderation.py
@@ -23,6 +23,7 @@ from mailman.app.lifecycle import create_list
from mailman.app.moderator import (
handle_message, handle_unsubscription, hold_message, hold_unsubscription)
from mailman.interfaces.action import Action
+from mailman.interfaces.member import MemberRole
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests
from mailman.interfaces.subscriptions import ISubscriptionManager
@@ -31,7 +32,7 @@ from mailman.runners.incoming import IncomingRunner
from mailman.runners.outgoing import OutgoingRunner
from mailman.runners.pipeline import PipelineRunner
from mailman.testing.helpers import (
- get_queue_messages, make_testable_runner,
+ get_queue_messages, make_testable_runner, set_preferred,
specialized_message_from_string as mfs)
from mailman.testing.layers import SMTPLayer
from mailman.utilities.datetime import now
@@ -158,15 +159,29 @@ class TestUnsubscription(unittest.TestCase):
def test_unsubscribe_defer(self):
# When unsubscriptions must be approved by the moderator, but the
# moderator defers this decision.
- anne = getUtility(IUserManager).create_address(
- 'anne@example.org', 'Anne Person')
+ user_manager = getUtility(IUserManager)
+ anne = user_manager.create_address('anne@example.org', 'Anne Person')
token, token_owner, member = self._manager.register(
anne, pre_verified=True, pre_confirmed=True, pre_approved=True)
self.assertIsNone(token)
self.assertEqual(member.address.email, 'anne@example.org')
+ bart = user_manager.create_user('bart@example.com', 'Bart User')
+ address = set_preferred(bart)
+ self._mlist.subscribe(address, MemberRole.moderator)
# Now hold and handle an unsubscription request.
token = hold_unsubscription(self._mlist, 'anne@example.org')
handle_unsubscription(self._mlist, token, Action.defer)
+ items = get_queue_messages('virgin', expected_count=2)
+ # Find the moderator message.
+ for item in items:
+ if item.msg['to'] == 'test-owner@example.com':
+ break
+ else:
+ raise AssertionError('No moderator email found')
+ self.assertEqual(item.msgdata['recipients'], {'bart@example.com'})
+ self.assertEqual(
+ item.msg['subject'],
+ 'New unsubscription request from Test by anne@example.org')
def test_bogus_token(self):
# Try to handle an unsubscription with a bogus token.
diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py
index 19198307d..b960b7904 100644
--- a/src/mailman/app/tests/test_subscriptions.py
+++ b/src/mailman/app/tests/test_subscriptions.py
@@ -24,7 +24,7 @@ from mailman.app.lifecycle import create_list
from mailman.app.subscriptions import SubscriptionWorkflow
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.mailinglist import SubscriptionPolicy
-from mailman.interfaces.member import MembershipIsBannedError
+from mailman.interfaces.member import MemberRole, MembershipIsBannedError
from mailman.interfaces.pending import IPendings
from mailman.interfaces.subscriptions import TokenOwner
from mailman.interfaces.usermanager import IUserManager
@@ -436,12 +436,22 @@ class TestSubscriptionWorkflow(unittest.TestCase):
self._mlist.admin_immed_notify = True
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._user_manager.create_address(self._anne)
+ bart = self._user_manager.create_user('bart@example.com', 'Bart User')
+ address = set_preferred(bart)
+ self._mlist.subscribe(address, MemberRole.moderator)
workflow = SubscriptionWorkflow(self._mlist, anne,
pre_verified=True,
pre_confirmed=True)
# Consume the entire state machine.
list(workflow)
+ # Find the moderator message.
items = get_queue_messages('virgin', expected_count=1)
+ for item in items:
+ if item.msg['to'] == 'test-owner@example.com':
+ break
+ else:
+ raise AssertionError('No moderator email found')
+ self.assertEqual(item.msgdata['recipients'], {'bart@example.com'})
message = items[0].msg
self.assertEqual(message['From'], 'test-owner@example.com')
self.assertEqual(message['To'], 'test-owner@example.com')
@@ -665,3 +675,45 @@ approval:
self.assertIsNone(workflow.token)
self.assertEqual(workflow.token_owner, TokenOwner.no_one)
self.assertEqual(workflow.member.address, anne)
+
+ def test_restore_user_absorbed(self):
+ # The subscribing user is absorbed (and thus deleted) before the
+ # moderator approves the subscription.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_user(self._anne)
+ bill = self._user_manager.create_user('bill@example.com')
+ set_preferred(bill)
+ # anne subscribes.
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ list(workflow)
+ # bill absorbs anne.
+ bill.absorb(anne)
+ # anne's subscription request is approved.
+ approved_workflow = SubscriptionWorkflow(self._mlist)
+ approved_workflow.token = workflow.token
+ approved_workflow.restore()
+ self.assertEqual(approved_workflow.user, bill)
+ # Run the workflow through.
+ list(approved_workflow)
+
+ def test_restore_address_absorbed(self):
+ # The subscribing user is absorbed (and thus deleted) before the
+ # moderator approves the subscription.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_user(self._anne)
+ anne_address = anne.addresses[0]
+ bill = self._user_manager.create_user('bill@example.com')
+ # anne subscribes.
+ workflow = SubscriptionWorkflow(
+ self._mlist, anne_address, pre_verified=True)
+ list(workflow)
+ # bill absorbs anne.
+ bill.absorb(anne)
+ self.assertIn(anne_address, bill.addresses)
+ # anne's subscription request is approved.
+ approved_workflow = SubscriptionWorkflow(self._mlist)
+ approved_workflow.token = workflow.token
+ approved_workflow.restore()
+ self.assertEqual(approved_workflow.user, bill)
+ # Run the workflow through.
+ list(approved_workflow)
diff --git a/src/mailman/app/tests/test_workflow.py b/src/mailman/app/tests/test_workflow.py
index a5bbd0792..98f47e036 100644
--- a/src/mailman/app/tests/test_workflow.py
+++ b/src/mailman/app/tests/test_workflow.py
@@ -17,10 +17,13 @@
"""App-level workflow tests."""
+import json
import unittest
from mailman.app.workflow import Workflow
+from mailman.interfaces.workflow import IWorkflowStateManager
from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
class MyWorkflow(Workflow):
@@ -47,6 +50,26 @@ class MyWorkflow(Workflow):
return 'three'
+class DependentWorkflow(MyWorkflow):
+ SAVE_ATTRIBUTES = ('ant', 'bee', 'cat', 'elf')
+
+ def __init__(self):
+ super().__init__()
+ self._elf = 5
+
+ @property
+ def elf(self):
+ return self._elf
+
+ @elf.setter
+ def elf(self, value):
+ # This attribute depends on other attributes.
+ assert self.ant is not None
+ assert self.bee is not None
+ assert self.cat is not None
+ self._elf = value
+
+
class TestWorkflow(unittest.TestCase):
layer = ConfigLayer
@@ -111,6 +134,36 @@ class TestWorkflow(unittest.TestCase):
self.assertEqual(new_workflow.cat, 7)
self.assertEqual(new_workflow.dog, 4)
+ def test_save_and_restore_dependant_attributes(self):
+ # Attributes must be restored in the order they are declared in
+ # SAVE_ATTRIBUTES.
+ workflow = iter(DependentWorkflow())
+ workflow.elf = 6
+ workflow.save()
+ new_workflow = DependentWorkflow()
+ # The elf attribute must be restored last, set triggering values for
+ # attributes it depends on.
+ new_workflow.ant = new_workflow.bee = new_workflow.cat = None
+ new_workflow.restore()
+ self.assertEqual(new_workflow.elf, 6)
+
+ def test_save_and_restore_obsolete_attributes(self):
+ # Obsolete saved attributes are ignored.
+ state_manager = getUtility(IWorkflowStateManager)
+ # Save the state of an old version of the workflow that would not have
+ # the cat attribute.
+ state_manager.save(
+ self._workflow.token, 'first',
+ json.dumps({'ant': 1, 'bee': 2}))
+ # Restore in the current version that needs the cat attribute.
+ new_workflow = MyWorkflow()
+ try:
+ new_workflow.restore()
+ except KeyError:
+ self.fail('Restore does not handle obsolete attributes')
+ # Restoring must not raise an exception, the default value is kept.
+ self.assertEqual(new_workflow.cat, 3)
+
def test_run_thru(self):
# Run all steps through the given one.
results = self._workflow.run_thru('second')
diff --git a/src/mailman/app/workflow.py b/src/mailman/app/workflow.py
index 36fd7d611..cd9124993 100644
--- a/src/mailman/app/workflow.py
+++ b/src/mailman/app/workflow.py
@@ -143,5 +143,9 @@ class Workflow:
self._next.clear()
if state.step:
self._next.append(state.step)
- for attr, value in json.loads(state.data).items():
- setattr(self, attr, value)
+ data = json.loads(state.data)
+ for attr in self.SAVE_ATTRIBUTES:
+ try:
+ setattr(self, attr, data[attr])
+ except KeyError:
+ pass
diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py
index 543af4ff4..8e0b1265f 100644
--- a/src/mailman/bin/master.py
+++ b/src/mailman/bin/master.py
@@ -299,33 +299,33 @@ class Loop:
# Set up our signal handlers. Also set up a SIGALRM handler to
# refresh the lock once per day. The lock lifetime is 1 day + 6 hours
# so this should be plenty.
- def sigalrm_handler(signum, frame): # noqa: E301
+ def sigalrm_handler(signum, frame): # noqa: E306
self._lock.refresh()
signal.alarm(SECONDS_IN_A_DAY)
signal.signal(signal.SIGALRM, sigalrm_handler)
signal.alarm(SECONDS_IN_A_DAY)
# SIGHUP tells the runners to close and reopen their log files.
- def sighup_handler(signum, frame): # noqa: E301
+ def sighup_handler(signum, frame): # noqa: E306
reopen()
for pid in self._kids:
os.kill(pid, signal.SIGHUP)
log.info('Master watcher caught SIGHUP. Re-opening log files.')
signal.signal(signal.SIGHUP, sighup_handler)
# SIGUSR1 is used by 'mailman restart'.
- def sigusr1_handler(signum, frame): # noqa: E301
+ def sigusr1_handler(signum, frame): # noqa: E306
for pid in self._kids:
os.kill(pid, signal.SIGUSR1)
log.info('Master watcher caught SIGUSR1. Exiting.')
signal.signal(signal.SIGUSR1, sigusr1_handler)
# SIGTERM is what init will kill this process with when changing run
# levels. It's also the signal 'mailman stop' uses.
- def sigterm_handler(signum, frame): # noqa: E301
+ def sigterm_handler(signum, frame): # noqa: E306
for pid in self._kids:
os.kill(pid, signal.SIGTERM)
log.info('Master watcher caught SIGTERM. Exiting.')
signal.signal(signal.SIGTERM, sigterm_handler)
# SIGINT is what control-C gives.
- def sigint_handler(signum, frame): # noqa: E301
+ def sigint_handler(signum, frame): # noqa: E306
for pid in self._kids:
os.kill(pid, signal.SIGINT)
log.info('Master watcher caught SIGINT. Restarting.')
diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py
index f1bd42961..f111aee3c 100644
--- a/src/mailman/chains/hold.py
+++ b/src/mailman/chains/hold.py
@@ -246,7 +246,7 @@ also appear in the first line of the body of the reply.""")),
nmsg.attach(text)
nmsg.attach(MIMEMessage(msg))
nmsg.attach(MIMEMessage(dmsg))
- nmsg.send(mlist, **dict(tomoderators=True))
+ nmsg.send(mlist, to_moderators=True)
# Log the held message. Log messages are not translated, so recast
# the reasons in the English.
with _.using('en'):
diff --git a/src/mailman/chains/tests/test_accept.py b/src/mailman/chains/tests/test_accept.py
index 07ed958b0..7643818e7 100644
--- a/src/mailman/chains/tests/test_accept.py
+++ b/src/mailman/chains/tests/test_accept.py
@@ -60,7 +60,7 @@ Subject: Ignore
config.chains['mine'] = MyChain()
self.addCleanup(config.chains.pop, 'mine')
hits = None
- def handler(event): # noqa: E301
+ def handler(event): # noqa: E306
nonlocal hits
if isinstance(event, AcceptEvent):
hits = event.msg['x-mailman-rule-hits']
diff --git a/src/mailman/chains/tests/test_headers.py b/src/mailman/chains/tests/test_headers.py
index 7e7219118..e31111c6e 100644
--- a/src/mailman/chains/tests/test_headers.py
+++ b/src/mailman/chains/tests/test_headers.py
@@ -302,7 +302,7 @@ A message body.
header_matches = IHeaderMatchList(self._mlist)
header_matches.append('Header2', 'b+')
header_matches.append('Header3', 'c+')
- def get_links(): # noqa: E301
+ def get_links(): # noqa: E306
return [
link for link in chain.get_links(self._mlist, Message(), {})
if link.rule.name != 'any'
diff --git a/src/mailman/chains/tests/test_hold.py b/src/mailman/chains/tests/test_hold.py
index 0957fe880..13dd1b40e 100644
--- a/src/mailman/chains/tests/test_hold.py
+++ b/src/mailman/chains/tests/test_hold.py
@@ -24,10 +24,11 @@ from mailman.app.lifecycle import create_list
from mailman.chains.hold import autorespond_to_sender
from mailman.core.chains import process as process_chain
from mailman.interfaces.autorespond import IAutoResponseSet, Response
+from mailman.interfaces.member import MemberRole
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
- LogFileMark, configuration, get_queue_messages,
+ LogFileMark, configuration, get_queue_messages, set_preferred,
specialized_message_from_string as mfs)
from mailman.testing.layers import ConfigLayer
from pkg_resources import resource_filename
@@ -94,6 +95,7 @@ class TestHoldChain(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
+ self._user_manager = getUtility(IUserManager)
def test_hold_chain(self):
msg = mfs("""\
@@ -133,6 +135,9 @@ A message body.
# Issue #144 - UnicodeEncodeError in the hold chain.
self._mlist.admin_immed_notify = True
self._mlist.respond_to_post_requests = False
+ bart = self._user_manager.create_user('bart@example.com', 'Bart User')
+ address = set_preferred(bart)
+ self._mlist.subscribe(address, MemberRole.moderator)
path = resource_filename('mailman.chains.tests', 'issue144.eml')
with open(path, 'rb') as fp:
msg = mfb(fp.read())
@@ -142,8 +147,8 @@ A message body.
# delivery to the moderators.
items = get_queue_messages('virgin', expected_count=1)
msgdata = items[0].msgdata
- self.assertTrue(msgdata['tomoderators'])
- self.assertEqual(msgdata['recipients'], {'test-owner@example.com'})
+ # Should get sent to moderators.
+ self.assertEqual(msgdata['recipients'], {'bart@example.com'})
# Ensure that the subject looks correct in the postauth.txt.
msg = items[0].msg
value = None
diff --git a/src/mailman/chains/tests/test_owner.py b/src/mailman/chains/tests/test_owner.py
index 70328303b..d00f61edd 100644
--- a/src/mailman/chains/tests/test_owner.py
+++ b/src/mailman/chains/tests/test_owner.py
@@ -50,7 +50,7 @@ Message-ID: <ant>
# This event subscriber records the event that occurs when the message
# is processed by the owner chain.
events = []
- def catch_event(event): # noqa: E301
+ def catch_event(event): # noqa: E306
if isinstance(event, AcceptOwnerEvent):
events.append(event)
with event_subscribers(catch_event):
diff --git a/src/mailman/commands/cli_control.py b/src/mailman/commands/cli_control.py
index af22c3b06..5ba0b5427 100644
--- a/src/mailman/commands/cli_control.py
+++ b/src/mailman/commands/cli_control.py
@@ -93,7 +93,7 @@ class Start:
self.parser.error(
_('A previous run of GNU Mailman did not exit '
'cleanly. Try using --force.'))
- def log(message): # noqa: E301
+ def log(message): # noqa: E306
if not args.quiet:
print(message)
# Try to find the path to a valid, existing configuration file, and
diff --git a/src/mailman/compat/smtpd.py b/src/mailman/compat/smtpd.py
deleted file mode 100755
index 813624729..000000000
--- a/src/mailman/compat/smtpd.py
+++ /dev/null
@@ -1,977 +0,0 @@
-#! /usr/bin/env python3
-"""An RFC 5321 smtp proxy with optional RFC 1870 and RFC 6531 extensions.
-
-Usage: %(program)s [options] [localhost:localport [remotehost:remoteport]]
-
-Options:
-
- --nosetuid
- -n
- This program generally tries to setuid `nobody', unless this flag is
- set. The setuid call will fail if this program is not run as root (in
- which case, use this flag).
-
- --version
- -V
- Print the version number and exit.
-
- --class classname
- -c classname
- Use `classname' as the concrete SMTP proxy class. Uses `PureProxy' by
- default.
-
- --size limit
- -s limit
- Restrict the total size of the incoming message to "limit" number of
- bytes via the RFC 1870 SIZE extension. Defaults to 33554432 bytes.
-
- --smtputf8
- -u
- Enable the SMTPUTF8 extension and behave as an RFC 6531 smtp proxy.
-
- --debug
- -d
- Turn on debugging prints.
-
- --help
- -h
- Print this message and exit.
-
-Version: %(__version__)s
-
-If localhost is not given then `localhost' is used, and if localport is not
-given then 8025 is used. If remotehost is not given then `localhost' is used,
-and if remoteport is not given, then 25 is used.
-"""
-
-# Overview:
-#
-# This file implements the minimal SMTP protocol as defined in RFC 5321. It
-# has a hierarchy of classes which implement the backend functionality for the
-# smtpd. A number of classes are provided:
-#
-# SMTPServer - the base class for the backend. Raises NotImplementedError
-# if you try to use it.
-#
-# DebuggingServer - simply prints each message it receives on stdout.
-#
-# PureProxy - Proxies all messages to a real smtpd which does final
-# delivery. One known problem with this class is that it doesn't handle
-# SMTP errors from the backend server at all. This should be fixed
-# (contributions are welcome!).
-#
-# MailmanProxy - An experimental hack to work with GNU Mailman
-# <www.list.org>. Using this server as your real incoming smtpd, your
-# mailhost will automatically recognize and accept mail destined to Mailman
-# lists when those lists are created. Every message not destined for a list
-# gets forwarded to a real backend smtpd, as with PureProxy. Again, errors
-# are not handled correctly yet.
-#
-#
-# Author: Barry Warsaw <barry@python.org>
-#
-# TODO:
-#
-# - support mailbox delivery
-# - alias files
-# - Handle more ESMTP extensions
-# - handle error codes from the backend smtpd
-
-import sys
-import os
-import errno
-import getopt
-import time
-import socket
-import asyncore
-import asynchat
-import collections
-from warnings import warn
-from email._header_value_parser import get_addr_spec, get_angle_addr
-
-__all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"]
-
-program = sys.argv[0]
-__version__ = 'Python SMTP proxy version 0.3'
-
-
-class Devnull:
- def write(self, msg): pass
- def flush(self): pass
-
-
-DEBUGSTREAM = Devnull()
-NEWLINE = '\n'
-COMMASPACE = ', '
-DATA_SIZE_DEFAULT = 33554432
-
-
-def usage(code, msg=''):
- print(__doc__ % globals(), file=sys.stderr)
- if msg:
- print(msg, file=sys.stderr)
- sys.exit(code)
-
-
-class SMTPChannel(asynchat.async_chat):
- COMMAND = 0
- DATA = 1
-
- command_size_limit = 512
- command_size_limits = collections.defaultdict(lambda x=command_size_limit: x)
-
- @property
- def max_command_size_limit(self):
- try:
- return max(self.command_size_limits.values())
- except ValueError:
- return self.command_size_limit
-
- def __init__(self, server, conn, addr, data_size_limit=DATA_SIZE_DEFAULT,
- map=None, enable_SMTPUTF8=False, decode_data=None):
- asynchat.async_chat.__init__(self, conn, map=map)
- self.smtp_server = server
- self.conn = conn
- self.addr = addr
- self.data_size_limit = data_size_limit
- self.enable_SMTPUTF8 = enable_SMTPUTF8
- if enable_SMTPUTF8:
- if decode_data:
- ValueError("decode_data and enable_SMTPUTF8 cannot be set to"
- " True at the same time")
- decode_data = False
- if decode_data is None:
- warn("The decode_data default of True will change to False in 3.6;"
- " specify an explicit value for this keyword",
- DeprecationWarning, 2)
- decode_data = True
- self._decode_data = decode_data
- if decode_data:
- self._emptystring = ''
- self._linesep = '\r\n'
- self._dotsep = '.'
- self._newline = NEWLINE
- else:
- self._emptystring = b''
- self._linesep = b'\r\n'
- self._dotsep = ord(b'.')
- self._newline = b'\n'
- self._set_rset_state()
- self.seen_greeting = ''
- self.extended_smtp = False
- self.command_size_limits.clear()
- self.fqdn = socket.getfqdn()
- try:
- self.peer = conn.getpeername()
- except OSError as err:
- # a race condition may occur if the other end is closing
- # before we can get the peername
- self.close()
- if err.args[0] != errno.ENOTCONN:
- raise
- return
- print('Peer:', repr(self.peer), file=DEBUGSTREAM)
- self.push('220 %s %s' % (self.fqdn, __version__))
-
- def _set_post_data_state(self):
- """Reset state variables to their post-DATA state."""
- self.smtp_state = self.COMMAND
- self.mailfrom = None
- self.rcpttos = []
- self.require_SMTPUTF8 = False
- self.num_bytes = 0
- self.set_terminator(b'\r\n')
-
- def _set_rset_state(self):
- """Reset all state variables except the greeting."""
- self._set_post_data_state()
- self.received_data = ''
- self.received_lines = []
-
-
- # properties for backwards-compatibility
- @property
- def __server(self):
- warn("Access to __server attribute on SMTPChannel is deprecated, "
- "use 'smtp_server' instead", DeprecationWarning, 2)
- return self.smtp_server
- @__server.setter
- def __server(self, value):
- warn("Setting __server attribute on SMTPChannel is deprecated, "
- "set 'smtp_server' instead", DeprecationWarning, 2)
- self.smtp_server = value
-
- @property
- def __line(self):
- warn("Access to __line attribute on SMTPChannel is deprecated, "
- "use 'received_lines' instead", DeprecationWarning, 2)
- return self.received_lines
- @__line.setter
- def __line(self, value):
- warn("Setting __line attribute on SMTPChannel is deprecated, "
- "set 'received_lines' instead", DeprecationWarning, 2)
- self.received_lines = value
-
- @property
- def __state(self):
- warn("Access to __state attribute on SMTPChannel is deprecated, "
- "use 'smtp_state' instead", DeprecationWarning, 2)
- return self.smtp_state
- @__state.setter
- def __state(self, value):
- warn("Setting __state attribute on SMTPChannel is deprecated, "
- "set 'smtp_state' instead", DeprecationWarning, 2)
- self.smtp_state = value
-
- @property
- def __greeting(self):
- warn("Access to __greeting attribute on SMTPChannel is deprecated, "
- "use 'seen_greeting' instead", DeprecationWarning, 2)
- return self.seen_greeting
- @__greeting.setter
- def __greeting(self, value):
- warn("Setting __greeting attribute on SMTPChannel is deprecated, "
- "set 'seen_greeting' instead", DeprecationWarning, 2)
- self.seen_greeting = value
-
- @property
- def __mailfrom(self):
- warn("Access to __mailfrom attribute on SMTPChannel is deprecated, "
- "use 'mailfrom' instead", DeprecationWarning, 2)
- return self.mailfrom
- @__mailfrom.setter
- def __mailfrom(self, value):
- warn("Setting __mailfrom attribute on SMTPChannel is deprecated, "
- "set 'mailfrom' instead", DeprecationWarning, 2)
- self.mailfrom = value
-
- @property
- def __rcpttos(self):
- warn("Access to __rcpttos attribute on SMTPChannel is deprecated, "
- "use 'rcpttos' instead", DeprecationWarning, 2)
- return self.rcpttos
- @__rcpttos.setter
- def __rcpttos(self, value):
- warn("Setting __rcpttos attribute on SMTPChannel is deprecated, "
- "set 'rcpttos' instead", DeprecationWarning, 2)
- self.rcpttos = value
-
- @property
- def __data(self):
- warn("Access to __data attribute on SMTPChannel is deprecated, "
- "use 'received_data' instead", DeprecationWarning, 2)
- return self.received_data
- @__data.setter
- def __data(self, value):
- warn("Setting __data attribute on SMTPChannel is deprecated, "
- "set 'received_data' instead", DeprecationWarning, 2)
- self.received_data = value
-
- @property
- def __fqdn(self):
- warn("Access to __fqdn attribute on SMTPChannel is deprecated, "
- "use 'fqdn' instead", DeprecationWarning, 2)
- return self.fqdn
- @__fqdn.setter
- def __fqdn(self, value):
- warn("Setting __fqdn attribute on SMTPChannel is deprecated, "
- "set 'fqdn' instead", DeprecationWarning, 2)
- self.fqdn = value
-
- @property
- def __peer(self):
- warn("Access to __peer attribute on SMTPChannel is deprecated, "
- "use 'peer' instead", DeprecationWarning, 2)
- return self.peer
- @__peer.setter
- def __peer(self, value):
- warn("Setting __peer attribute on SMTPChannel is deprecated, "
- "set 'peer' instead", DeprecationWarning, 2)
- self.peer = value
-
- @property
- def __conn(self):
- warn("Access to __conn attribute on SMTPChannel is deprecated, "
- "use 'conn' instead", DeprecationWarning, 2)
- return self.conn
- @__conn.setter
- def __conn(self, value):
- warn("Setting __conn attribute on SMTPChannel is deprecated, "
- "set 'conn' instead", DeprecationWarning, 2)
- self.conn = value
-
- @property
- def __addr(self):
- warn("Access to __addr attribute on SMTPChannel is deprecated, "
- "use 'addr' instead", DeprecationWarning, 2)
- return self.addr
- @__addr.setter
- def __addr(self, value):
- warn("Setting __addr attribute on SMTPChannel is deprecated, "
- "set 'addr' instead", DeprecationWarning, 2)
- self.addr = value
-
- # Overrides base class for convenience.
- def push(self, msg):
- asynchat.async_chat.push(self, bytes(
- msg + '\r\n', 'utf-8' if self.require_SMTPUTF8 else 'ascii'))
-
- # Implementation of base class abstract method
- def collect_incoming_data(self, data):
- limit = None
- if self.smtp_state == self.COMMAND:
- limit = self.max_command_size_limit
- elif self.smtp_state == self.DATA:
- limit = self.data_size_limit
- if limit and self.num_bytes > limit:
- return
- elif limit:
- self.num_bytes += len(data)
- if self._decode_data:
- self.received_lines.append(str(data, 'utf-8'))
- else:
- self.received_lines.append(data)
-
- # Implementation of base class abstract method
- def found_terminator(self):
- line = self._emptystring.join(self.received_lines)
- print('Data:', repr(line), file=DEBUGSTREAM)
- self.received_lines = []
- if self.smtp_state == self.COMMAND:
- sz, self.num_bytes = self.num_bytes, 0
- if not line:
- self.push('500 Error: bad syntax')
- return
- if not self._decode_data:
- line = str(line, 'utf-8')
- i = line.find(' ')
- if i < 0:
- command = line.upper()
- arg = None
- else:
- command = line[:i].upper()
- arg = line[i+1:].strip()
- max_sz = (self.command_size_limits[command]
- if self.extended_smtp else self.command_size_limit)
- if sz > max_sz:
- self.push('500 Error: line too long')
- return
- method = getattr(self, 'smtp_' + command, None)
- if not method:
- self.push('500 Error: command "%s" not recognized' % command)
- return
- method(arg)
- return
- else:
- if self.smtp_state != self.DATA:
- self.push('451 Internal confusion')
- self.num_bytes = 0
- return
- if self.data_size_limit and self.num_bytes > self.data_size_limit:
- self.push('552 Error: Too much mail data')
- self.num_bytes = 0
- return
- # Remove extraneous carriage returns and de-transparency according
- # to RFC 5321, Section 4.5.2.
- data = []
- for text in line.split(self._linesep):
- if text and text[0] == self._dotsep:
- data.append(text[1:])
- else:
- data.append(text)
- self.received_data = self._newline.join(data)
- args = (self.peer, self.mailfrom, self.rcpttos, self.received_data)
- kwargs = {}
- if not self._decode_data:
- kwargs = {
- 'mail_options': self.mail_options,
- 'rcpt_options': self.rcpt_options,
- }
- status = self.smtp_server.process_message(*args, **kwargs)
- self._set_post_data_state()
- if not status:
- self.push('250 OK')
- else:
- self.push(status)
-
- # SMTP and ESMTP commands
- def smtp_HELO(self, arg):
- if not arg:
- self.push('501 Syntax: HELO hostname')
- return
- # See issue #21783 for a discussion of this behavior.
- if self.seen_greeting:
- self.push('503 Duplicate HELO/EHLO')
- return
- self._set_rset_state()
- self.seen_greeting = arg
- self.push('250 %s' % self.fqdn)
-
- def smtp_EHLO(self, arg):
- if not arg:
- self.push('501 Syntax: EHLO hostname')
- return
- # See issue #21783 for a discussion of this behavior.
- if self.seen_greeting:
- self.push('503 Duplicate HELO/EHLO')
- return
- self._set_rset_state()
- self.seen_greeting = arg
- self.extended_smtp = True
- self.push('250-%s' % self.fqdn)
- if self.data_size_limit:
- self.push('250-SIZE %s' % self.data_size_limit)
- self.command_size_limits['MAIL'] += 26
- if not self._decode_data:
- self.push('250-8BITMIME')
- if self.enable_SMTPUTF8:
- self.push('250-SMTPUTF8')
- self.command_size_limits['MAIL'] += 10
- self.push('250 HELP')
-
- def smtp_NOOP(self, arg):
- if arg:
- self.push('501 Syntax: NOOP')
- else:
- self.push('250 OK')
-
- def smtp_QUIT(self, arg):
- # args is ignored
- self.push('221 Bye')
- self.close_when_done()
-
- def _strip_command_keyword(self, keyword, arg):
- keylen = len(keyword)
- if arg[:keylen].upper() == keyword:
- return arg[keylen:].strip()
- return ''
-
- def _getaddr(self, arg):
- if not arg:
- return '', ''
- if arg.lstrip().startswith('<'):
- address, rest = get_angle_addr(arg)
- else:
- address, rest = get_addr_spec(arg)
- if not address:
- return address, rest
- return address.addr_spec, rest
-
- def _getparams(self, params):
- # Return params as dictionary. Return None if not all parameters
- # appear to be syntactically valid according to RFC 1869.
- result = {}
- for param in params:
- param, eq, value = param.partition('=')
- if not param.isalnum() or eq and not value:
- return None
- result[param] = value if eq else True
- return result
-
- def smtp_HELP(self, arg):
- if arg:
- extended = ' [SP <mail-parameters>]'
- lc_arg = arg.upper()
- if lc_arg == 'EHLO':
- self.push('250 Syntax: EHLO hostname')
- elif lc_arg == 'HELO':
- self.push('250 Syntax: HELO hostname')
- elif lc_arg == 'MAIL':
- msg = '250 Syntax: MAIL FROM: <address>'
- if self.extended_smtp:
- msg += extended
- self.push(msg)
- elif lc_arg == 'RCPT':
- msg = '250 Syntax: RCPT TO: <address>'
- if self.extended_smtp:
- msg += extended
- self.push(msg)
- elif lc_arg == 'DATA':
- self.push('250 Syntax: DATA')
- elif lc_arg == 'RSET':
- self.push('250 Syntax: RSET')
- elif lc_arg == 'NOOP':
- self.push('250 Syntax: NOOP')
- elif lc_arg == 'QUIT':
- self.push('250 Syntax: QUIT')
- elif lc_arg == 'VRFY':
- self.push('250 Syntax: VRFY <address>')
- else:
- self.push('501 Supported commands: EHLO HELO MAIL RCPT '
- 'DATA RSET NOOP QUIT VRFY')
- else:
- self.push('250 Supported commands: EHLO HELO MAIL RCPT DATA '
- 'RSET NOOP QUIT VRFY')
-
- def smtp_VRFY(self, arg):
- if arg:
- address, params = self._getaddr(arg)
- if address:
- self.push('252 Cannot VRFY user, but will accept message '
- 'and attempt delivery')
- else:
- self.push('502 Could not VRFY %s' % arg)
- else:
- self.push('501 Syntax: VRFY <address>')
-
- def smtp_MAIL(self, arg):
- if not self.seen_greeting:
- self.push('503 Error: send HELO first')
- return
- print('===> MAIL', arg, file=DEBUGSTREAM)
- syntaxerr = '501 Syntax: MAIL FROM: <address>'
- if self.extended_smtp:
- syntaxerr += ' [SP <mail-parameters>]'
- if arg is None:
- self.push(syntaxerr)
- return
- arg = self._strip_command_keyword('FROM:', arg)
- address, params = self._getaddr(arg)
- if not address:
- self.push(syntaxerr)
- return
- if not self.extended_smtp and params:
- self.push(syntaxerr)
- return
- if self.mailfrom:
- self.push('503 Error: nested MAIL command')
- return
- self.mail_options = params.upper().split()
- params = self._getparams(self.mail_options)
- if params is None:
- self.push(syntaxerr)
- return
- if not self._decode_data:
- body = params.pop('BODY', '7BIT')
- if body not in ['7BIT', '8BITMIME']:
- self.push('501 Error: BODY can only be one of 7BIT, 8BITMIME')
- return
- if self.enable_SMTPUTF8:
- smtputf8 = params.pop('SMTPUTF8', False)
- if smtputf8 is True:
- self.require_SMTPUTF8 = True
- elif smtputf8 is not False:
- self.push('501 Error: SMTPUTF8 takes no arguments')
- return
- size = params.pop('SIZE', None)
- if size:
- if not size.isdigit():
- self.push(syntaxerr)
- return
- elif self.data_size_limit and int(size) > self.data_size_limit:
- self.push('552 Error: message size exceeds fixed maximum message size')
- return
- if len(params.keys()) > 0:
- self.push('555 MAIL FROM parameters not recognized or not implemented')
- return
- self.mailfrom = address
- print('sender:', self.mailfrom, file=DEBUGSTREAM)
- self.push('250 OK')
-
- def smtp_RCPT(self, arg):
- if not self.seen_greeting:
- self.push('503 Error: send HELO first');
- return
- print('===> RCPT', arg, file=DEBUGSTREAM)
- if not self.mailfrom:
- self.push('503 Error: need MAIL command')
- return
- syntaxerr = '501 Syntax: RCPT TO: <address>'
- if self.extended_smtp:
- syntaxerr += ' [SP <mail-parameters>]'
- if arg is None:
- self.push(syntaxerr)
- return
- arg = self._strip_command_keyword('TO:', arg)
- address, params = self._getaddr(arg)
- if not address:
- self.push(syntaxerr)
- return
- if not self.extended_smtp and params:
- self.push(syntaxerr)
- return
- self.rcpt_options = params.upper().split()
- params = self._getparams(self.rcpt_options)
- if params is None:
- self.push(syntaxerr)
- return
- # XXX currently there are no options we recognize.
- if len(params.keys()) > 0:
- self.push('555 RCPT TO parameters not recognized or not implemented')
- return
- self.rcpttos.append(address)
- print('recips:', self.rcpttos, file=DEBUGSTREAM)
- self.push('250 OK')
-
- def smtp_RSET(self, arg):
- if arg:
- self.push('501 Syntax: RSET')
- return
- self._set_rset_state()
- self.push('250 OK')
-
- def smtp_DATA(self, arg):
- if not self.seen_greeting:
- self.push('503 Error: send HELO first');
- return
- if not self.rcpttos:
- self.push('503 Error: need RCPT command')
- return
- if arg:
- self.push('501 Syntax: DATA')
- return
- self.smtp_state = self.DATA
- self.set_terminator(b'\r\n.\r\n')
- self.push('354 End data with <CR><LF>.<CR><LF>')
-
- # Commands that have not been implemented
- def smtp_EXPN(self, arg):
- self.push('502 EXPN not implemented')
-
-
-class SMTPServer(asyncore.dispatcher):
- # SMTPChannel class to use for managing client connections
- channel_class = SMTPChannel
-
- def __init__(self, localaddr, remoteaddr,
- data_size_limit=DATA_SIZE_DEFAULT, map=None,
- enable_SMTPUTF8=False, decode_data=None):
- self._localaddr = localaddr
- self._remoteaddr = remoteaddr
- self.data_size_limit = data_size_limit
- self.enable_SMTPUTF8 = enable_SMTPUTF8
- if enable_SMTPUTF8:
- if decode_data:
- raise ValueError("The decode_data and enable_SMTPUTF8"
- " parameters cannot be set to True at the"
- " same time.")
- decode_data = False
- if decode_data is None:
- warn("The decode_data default of True will change to False in 3.6;"
- " specify an explicit value for this keyword",
- DeprecationWarning, 2)
- decode_data = True
- self._decode_data = decode_data
- asyncore.dispatcher.__init__(self, map=map)
- try:
- gai_results = socket.getaddrinfo(*localaddr,
- type=socket.SOCK_STREAM)
- self.create_socket(gai_results[0][0], gai_results[0][1])
- # try to re-use a server port if possible
- self.set_reuse_addr()
- self.bind(localaddr)
- self.listen(5)
- except:
- self.close()
- raise
- else:
- print('%s started at %s\n\tLocal addr: %s\n\tRemote addr:%s' % (
- self.__class__.__name__, time.ctime(time.time()),
- localaddr, remoteaddr), file=DEBUGSTREAM)
-
- def handle_accepted(self, conn, addr):
- print('Incoming connection from %s' % repr(addr), file=DEBUGSTREAM)
- channel = self.channel_class(self,
- conn,
- addr,
- self.data_size_limit,
- self._map,
- self.enable_SMTPUTF8,
- self._decode_data)
-
- # API for "doing something useful with the message"
- def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
- """Override this abstract method to handle messages from the client.
-
- peer is a tuple containing (ipaddr, port) of the client that made the
- socket connection to our smtp port.
-
- mailfrom is the raw address the client claims the message is coming
- from.
-
- rcpttos is a list of raw addresses the client wishes to deliver the
- message to.
-
- data is a string containing the entire full text of the message,
- headers (if supplied) and all. It has been `de-transparencied'
- according to RFC 821, Section 4.5.2. In other words, a line
- containing a `.' followed by other text has had the leading dot
- removed.
-
- kwargs is a dictionary containing additional information. It is empty
- unless decode_data=False or enable_SMTPUTF8=True was given as init
- parameter, in which case ut will contain the following keys:
- 'mail_options': list of parameters to the mail command. All
- elements are uppercase strings. Example:
- ['BODY=8BITMIME', 'SMTPUTF8'].
- 'rcpt_options': same, for the rcpt command.
-
- This function should return None for a normal `250 Ok' response;
- otherwise, it should return the desired response string in RFC 821
- format.
-
- """
- raise NotImplementedError
-
-
-class DebuggingServer(SMTPServer):
-
- def _print_message_content(self, peer, data):
- inheaders = 1
- lines = data.splitlines()
- for line in lines:
- # headers first
- if inheaders and not line:
- peerheader = 'X-Peer: ' + peer[0]
- if not isinstance(data, str):
- # decoded_data=false; make header match other binary output
- peerheader = repr(peerheader.encode('utf-8'))
- print(peerheader)
- inheaders = 0
- if not isinstance(data, str):
- # Avoid spurious 'str on bytes instance' warning.
- line = repr(line)
- print(line)
-
- def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
- print('---------- MESSAGE FOLLOWS ----------')
- if kwargs:
- if kwargs.get('mail_options'):
- print('mail options: %s' % kwargs['mail_options'])
- if kwargs.get('rcpt_options'):
- print('rcpt options: %s\n' % kwargs['rcpt_options'])
- self._print_message_content(peer, data)
- print('------------ END MESSAGE ------------')
-
-
-class PureProxy(SMTPServer):
- def __init__(self, *args, **kwargs):
- if 'enable_SMTPUTF8' in kwargs and kwargs['enable_SMTPUTF8']:
- raise ValueError("PureProxy does not support SMTPUTF8.")
- super().__init__(*args, **kwargs)
-
- def process_message(self, peer, mailfrom, rcpttos, data):
- lines = data.split('\n')
- # Look for the last header
- i = 0
- for line in lines:
- if not line:
- break
- i += 1
- lines.insert(i, 'X-Peer: %s' % peer[0])
- data = NEWLINE.join(lines)
- refused = self._deliver(mailfrom, rcpttos, data)
- # TBD: what to do with refused addresses?
- print('we got some refusals:', refused, file=DEBUGSTREAM)
-
- def _deliver(self, mailfrom, rcpttos, data):
- import smtplib
- refused = {}
- try:
- s = smtplib.SMTP()
- s.connect(self._remoteaddr[0], self._remoteaddr[1])
- try:
- refused = s.sendmail(mailfrom, rcpttos, data)
- finally:
- s.quit()
- except smtplib.SMTPRecipientsRefused as e:
- print('got SMTPRecipientsRefused', file=DEBUGSTREAM)
- refused = e.recipients
- except (OSError, smtplib.SMTPException) as e:
- print('got', e.__class__, file=DEBUGSTREAM)
- # All recipients were refused. If the exception had an associated
- # error code, use it. Otherwise,fake it with a non-triggering
- # exception code.
- errcode = getattr(e, 'smtp_code', -1)
- errmsg = getattr(e, 'smtp_error', 'ignore')
- for r in rcpttos:
- refused[r] = (errcode, errmsg)
- return refused
-
-
-class MailmanProxy(PureProxy):
- def __init__(self, *args, **kwargs):
- if 'enable_SMTPUTF8' in kwargs and kwargs['enable_SMTPUTF8']:
- raise ValueError("MailmanProxy does not support SMTPUTF8.")
- super().__init__(*args, **kwargs)
-
- def process_message(self, peer, mailfrom, rcpttos, data):
- from io import StringIO
- from Mailman import Utils
- from Mailman import Message
- from Mailman import MailList
- # If the message is to a Mailman mailing list, then we'll invoke the
- # Mailman script directly, without going through the real smtpd.
- # Otherwise we'll forward it to the local proxy for disposition.
- listnames = []
- for rcpt in rcpttos:
- local = rcpt.lower().split('@')[0]
- # We allow the following variations on the theme
- # listname
- # listname-admin
- # listname-owner
- # listname-request
- # listname-join
- # listname-leave
- parts = local.split('-')
- if len(parts) > 2:
- continue
- listname = parts[0]
- if len(parts) == 2:
- command = parts[1]
- else:
- command = ''
- if not Utils.list_exists(listname) or command not in (
- '', 'admin', 'owner', 'request', 'join', 'leave'):
- continue
- listnames.append((rcpt, listname, command))
- # Remove all list recipients from rcpttos and forward what we're not
- # going to take care of ourselves. Linear removal should be fine
- # since we don't expect a large number of recipients.
- for rcpt, listname, command in listnames:
- rcpttos.remove(rcpt)
- # If there's any non-list destined recipients left,
- print('forwarding recips:', ' '.join(rcpttos), file=DEBUGSTREAM)
- if rcpttos:
- refused = self._deliver(mailfrom, rcpttos, data)
- # TBD: what to do with refused addresses?
- print('we got refusals:', refused, file=DEBUGSTREAM)
- # Now deliver directly to the list commands
- mlists = {}
- s = StringIO(data)
- msg = Message.Message(s)
- # These headers are required for the proper execution of Mailman. All
- # MTAs in existence seem to add these if the original message doesn't
- # have them.
- if not msg.get('from'):
- msg['From'] = mailfrom
- if not msg.get('date'):
- msg['Date'] = time.ctime(time.time())
- for rcpt, listname, command in listnames:
- print('sending message to', rcpt, file=DEBUGSTREAM)
- mlist = mlists.get(listname)
- if not mlist:
- mlist = MailList.MailList(listname, lock=0)
- mlists[listname] = mlist
- # dispatch on the type of command
- if command == '':
- # post
- msg.Enqueue(mlist, tolist=1)
- elif command == 'admin':
- msg.Enqueue(mlist, toadmin=1)
- elif command == 'owner':
- msg.Enqueue(mlist, toowner=1)
- elif command == 'request':
- msg.Enqueue(mlist, torequest=1)
- elif command in ('join', 'leave'):
- # TBD: this is a hack!
- if command == 'join':
- msg['Subject'] = 'subscribe'
- else:
- msg['Subject'] = 'unsubscribe'
- msg.Enqueue(mlist, torequest=1)
-
-
-class Options:
- setuid = True
- classname = 'PureProxy'
- size_limit = None
- enable_SMTPUTF8 = False
-
-
-def parseargs():
- global DEBUGSTREAM
- try:
- opts, args = getopt.getopt(
- sys.argv[1:], 'nVhc:s:du',
- ['class=', 'nosetuid', 'version', 'help', 'size=', 'debug',
- 'smtputf8'])
- except getopt.error as e:
- usage(1, e)
-
- options = Options()
- for opt, arg in opts:
- if opt in ('-h', '--help'):
- usage(0)
- elif opt in ('-V', '--version'):
- print(__version__)
- sys.exit(0)
- elif opt in ('-n', '--nosetuid'):
- options.setuid = False
- elif opt in ('-c', '--class'):
- options.classname = arg
- elif opt in ('-d', '--debug'):
- DEBUGSTREAM = sys.stderr
- elif opt in ('-u', '--smtputf8'):
- options.enable_SMTPUTF8 = True
- elif opt in ('-s', '--size'):
- try:
- int_size = int(arg)
- options.size_limit = int_size
- except:
- print('Invalid size: ' + arg, file=sys.stderr)
- sys.exit(1)
-
- # parse the rest of the arguments
- if len(args) < 1:
- localspec = 'localhost:8025'
- remotespec = 'localhost:25'
- elif len(args) < 2:
- localspec = args[0]
- remotespec = 'localhost:25'
- elif len(args) < 3:
- localspec = args[0]
- remotespec = args[1]
- else:
- usage(1, 'Invalid arguments: %s' % COMMASPACE.join(args))
-
- # split into host/port pairs
- i = localspec.find(':')
- if i < 0:
- usage(1, 'Bad local spec: %s' % localspec)
- options.localhost = localspec[:i]
- try:
- options.localport = int(localspec[i+1:])
- except ValueError:
- usage(1, 'Bad local port: %s' % localspec)
- i = remotespec.find(':')
- if i < 0:
- usage(1, 'Bad remote spec: %s' % remotespec)
- options.remotehost = remotespec[:i]
- try:
- options.remoteport = int(remotespec[i+1:])
- except ValueError:
- usage(1, 'Bad remote port: %s' % remotespec)
- return options
-
-
-if __name__ == '__main__':
- options = parseargs()
- # Become nobody
- classname = options.classname
- if "." in classname:
- lastdot = classname.rfind(".")
- mod = __import__(classname[:lastdot], globals(), locals(), [""])
- classname = classname[lastdot+1:]
- else:
- import __main__ as mod
- class_ = getattr(mod, classname)
- proxy = class_((options.localhost, options.localport),
- (options.remotehost, options.remoteport),
- options.size_limit, enable_SMTPUTF8=options.enable_SMTPUTF8)
- if options.setuid:
- try:
- import pwd
- except ImportError:
- print('Cannot import module "pwd"; try running with -n option.', file=sys.stderr)
- sys.exit(1)
- nobody = pwd.getpwnam('nobody')[2]
- try:
- os.setuid(nobody)
- except PermissionError:
- print('Cannot setuid "nobody"; try running with -n option.', file=sys.stderr)
- sys.exit(1)
- try:
- asyncore.loop()
- except KeyboardInterrupt:
- pass
diff --git a/src/mailman/config/tests/test_configuration.py b/src/mailman/config/tests/test_configuration.py
index 24f2804fa..5fb8a27b9 100644
--- a/src/mailman/config/tests/test_configuration.py
+++ b/src/mailman/config/tests/test_configuration.py
@@ -39,7 +39,7 @@ class TestConfiguration(unittest.TestCase):
# Pushing a new configuration onto the stack triggers a
# post-processing event.
events = []
- def on_event(event): # noqa: E301
+ def on_event(event): # noqa: E306
if isinstance(event, ConfigurationUpdatedEvent):
# Record both the event and the top overlay.
events.append(event.config.overlays[0].name)
diff --git a/src/mailman/core/logging.py b/src/mailman/core/logging.py
index 298923d3f..a7e8b2554 100644
--- a/src/mailman/core/logging.py
+++ b/src/mailman/core/logging.py
@@ -147,6 +147,10 @@ def initialize(propagate=None):
log = logging.getLogger('sqlalchemy')
_init_logger(propagate, sub_name, log, logger_config)
log = logging.getLogger('alembic')
+ elif sub_name == 'smtp':
+ log = logging.getLogger('mail.log')
+ _init_logger(propagate, sub_name, log, logger_config)
+ log = logging.getLogger('mailman.smtp')
else:
logger_name = 'mailman.' + sub_name
log = logging.getLogger(logger_name)
diff --git a/src/mailman/database/tests/test_migrations.py b/src/mailman/database/tests/test_migrations.py
index 874f3a9ce..ee45d1a00 100644
--- a/src/mailman/database/tests/test_migrations.py
+++ b/src/mailman/database/tests/test_migrations.py
@@ -130,7 +130,7 @@ class TestMigrations(unittest.TestCase):
sa.sql.column('value', SAUnicode),
sa.sql.column('pended_id', sa.Integer),
)
- def get_from_db(): # noqa: E301
+ def get_from_db(): # noqa: E306
results = {}
for i in range(1, 6):
query = sa.sql.select(
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index f72498a1d..80cadd1b7 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -97,6 +97,8 @@ Bugs
(Closes: #283)
* Remove the digest mbox files after the digests are sent. Given by Aurélien
Bompard. (Closes: #259)
+ * Messages sent to the list's moderators now include the actual recipient
+ addresses. Given by Tom Briles. (Closes: #68)
Configuration
-------------
@@ -143,10 +145,13 @@ Interfaces
* ``ISubscriptionService`` now supports mass unsubscribes. Given by Harshit
Bansal.
-Internal API
-------------
+Internal
+--------
* A handful of unused legacy exceptions have been removed. The redundant
`MailmanException` has been removed; use `MailmanError` everywhere.
+ * Drop the use of the `lazr.smtptest` library, which is based on the
+ asynchat/asyncore-based smtpd.py stdlib module. Instead, use the
+ asyncio-based aiosmtpd package.
Message handling
----------------
diff --git a/src/mailman/email/message.py b/src/mailman/email/message.py
index 994d44cce..ebfef9d9b 100644
--- a/src/mailman/email/message.py
+++ b/src/mailman/email/message.py
@@ -31,6 +31,7 @@ from email.header import Header
from email.mime.multipart import MIMEMultipart
from mailman import public
from mailman.config import config
+from mailman.interfaces.member import DeliveryStatus
COMMASPACE = ', '
@@ -131,7 +132,7 @@ class UserNotification(Message):
self['To'] = recipients
self.recipients = set([recipients])
- def send(self, mlist, add_precedence=True, **_kws):
+ def send(self, mlist, *, add_precedence=True, to_moderators=False, **_kws):
"""Sends the message by enqueuing it to the 'virgin' queue.
This is used for all internally crafted messages.
@@ -141,6 +142,9 @@ class UserNotification(Message):
:param add_precedence: Flag indicating whether a `Precedence: bulk`
header should be added to the message or not.
:type add_precedence: bool
+ :param to_moderators: Flag indicating whether the message should be
+ sent to the list's moderators instead of the list's membership.
+ :type to_moderators: bool
This function also accepts arbitrary keyword arguments. The key/value
pairs for **kws is added to the metadata dictionary associated with
@@ -158,6 +162,12 @@ class UserNotification(Message):
# don't override an existing Precedence: header.
if 'precedence' not in self and add_precedence:
self['Precedence'] = 'bulk'
+ if to_moderators:
+ self.recipients = set(
+ member.address.email
+ for member in mlist.moderators.members
+ if member.delivery_status is DeliveryStatus.enabled)
+ self['To'] = COMMASPACE.join(self.recipients)
self._enqueue(mlist, **_kws)
def _enqueue(self, mlist, **_kws):
diff --git a/src/mailman/handlers/cook_headers.py b/src/mailman/handlers/cook_headers.py
index 78109bb77..5f203fcbd 100644
--- a/src/mailman/handlers/cook_headers.py
+++ b/src/mailman/handlers/cook_headers.py
@@ -107,7 +107,7 @@ def process(mlist, msg, msgdata):
# A convenience function, requires nested scopes. pair is (name, addr)
new = []
d = {}
- def add(pair): # noqa: E301
+ def add(pair): # noqa: E306
lcaddr = pair[1].lower()
if lcaddr in d:
return
diff --git a/src/mailman/interfaces/template.py b/src/mailman/interfaces/template.py
index e576eb882..24f956b78 100644
--- a/src/mailman/interfaces/template.py
+++ b/src/mailman/interfaces/template.py
@@ -157,6 +157,7 @@ class ITemplateManager(Interface):
:type context: str
"""
+
# Mapping of template names to their in-source file names. A None value means
# that there is no file in the tree for that template.
diff --git a/src/mailman/model/tests/test_domain.py b/src/mailman/model/tests/test_domain.py
index fb7896c6e..f1bf56edf 100644
--- a/src/mailman/model/tests/test_domain.py
+++ b/src/mailman/model/tests/test_domain.py
@@ -122,7 +122,7 @@ class TestDomainManager(unittest.TestCase):
self.assertEqual(
sorted(owner.addresses[0].email for owner in domain.owners),
['anne@example.com', 'bart@example.com'])
- def sort_key(owner): # noqa: E301
+ def sort_key(owner): # noqa: E306
return owner.addresses[0].email
self.assertEqual(sorted(domain.owners, key=sort_key), [anne, bart])
diff --git a/src/mailman/model/tests/test_requests.py b/src/mailman/model/tests/test_requests.py
index 3d4f64783..63795179e 100644
--- a/src/mailman/model/tests/test_requests.py
+++ b/src/mailman/model/tests/test_requests.py
@@ -104,7 +104,7 @@ Something else.
# value in a descending counter.
request_ids = []
counter = count(200, -1)
- def id_hacker(session, flush_context, instances): # noqa: E301
+ def id_hacker(session, flush_context, instances): # noqa: E306
for instance in session.new:
if isinstance(instance, _Request):
instance.id = next(counter)
diff --git a/src/mailman/mta/base.py b/src/mailman/mta/base.py
index a04ee4b46..679faefd5 100644
--- a/src/mailman/mta/base.py
+++ b/src/mailman/mta/base.py
@@ -63,9 +63,11 @@ class BaseDelivery:
# Do the actual sending.
sender = self._get_sender(mlist, msg, msgdata)
message_id = msg['message-id']
+ # Since the recipients can be a set or a list, sort the recipients by
+ # email address for predictability and testability.
try:
refused = self._connection.sendmail(
- sender, recipients, msg.as_string())
+ sender, sorted(recipients), msg.as_string())
except smtplib.SMTPRecipientsRefused as error:
log.error('%s recipients refused: %s', message_id, error)
refused = error.recipients
diff --git a/src/mailman/mta/tests/test_connection.py b/src/mailman/mta/tests/test_connection.py
index 3c4114634..7e4d556e1 100644
--- a/src/mailman/mta/tests/test_connection.py
+++ b/src/mailman/mta/tests/test_connection.py
@@ -22,7 +22,7 @@ import unittest
from mailman.config import config
from mailman.mta.connection import Connection
from mailman.testing.layers import SMTPLayer
-from smtplib import SMTPAuthenticationError
+from smtplib import SMTP, SMTPAuthenticationError
class TestConnection(unittest.TestCase):
@@ -57,3 +57,48 @@ Subject: aardvarks
""")
self.assertEqual(self.layer.smtpd.get_authentication_credentials(),
'AHRlc3R1c2VyAHRlc3RwYXNz')
+
+
+class TestConnectionCount(unittest.TestCase):
+ layer = SMTPLayer
+
+ def setUp(self):
+ self.connection = Connection(
+ config.mta.smtp_host, int(config.mta.smtp_port), 0)
+ self.msg_text = """\
+From: anne@example.com
+To: bart@example.com
+Subject: aardvarks
+
+"""
+
+ def test_count_0(self):
+ # So far, no connections.
+ self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 0)
+
+ def test_count_1(self):
+ self.connection.sendmail(
+ 'anne@example.com', ['bart@example.com'], self.msg_text)
+ self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 1)
+
+ def test_count_2(self):
+ self.connection.sendmail(
+ 'anne@example.com', ['bart@example.com'], self.msg_text)
+ self.connection.quit()
+ self.connection.sendmail(
+ 'cate@example.com', ['dave@example.com'], self.msg_text)
+ self.connection.quit()
+ self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 2)
+
+ def test_count_reset(self):
+ self.connection.sendmail(
+ 'anne@example.com', ['bart@example.com'], self.msg_text)
+ self.connection.quit()
+ self.connection.sendmail(
+ 'cate@example.com', ['dave@example.com'], self.msg_text)
+ self.connection.quit()
+ # Issue the fake SMTP command to reset the count.
+ client = SMTP()
+ client.connect(config.mta.smtp_host, int(config.mta.smtp_port))
+ client.docmd('RSET')
+ self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 0)
diff --git a/src/mailman/rest/tests/test_paginate.py b/src/mailman/rest/tests/test_paginate.py
index 5f28d093a..cd2b76d7a 100644
--- a/src/mailman/rest/tests/test_paginate.py
+++ b/src/mailman/rest/tests/test_paginate.py
@@ -48,7 +48,7 @@ class TestPaginateHelper(unittest.TestCase):
class Resource(CollectionMixin):
def _get_collection(self, request):
return ['one', 'two', 'three', 'four', 'five']
- def _resource_as_dict(self, res): # noqa: E301
+ def _resource_as_dict(self, res): # noqa: E306
return {'value': res}
return Resource()
diff --git a/src/mailman/rest/users.py b/src/mailman/rest/users.py
index c405a1ede..f9ce9b132 100644
--- a/src/mailman/rest/users.py
+++ b/src/mailman/rest/users.py
@@ -55,7 +55,7 @@ class ListOfDomainOwners(GetterSetter):
def get(self, domain, attribute):
assert attribute == 'owner', (
'Unexpected attribute: {}'.format(attribute))
- def sort_key(owner): # noqa: E301
+ def sort_key(owner): # noqa: E306
return owner.addresses[0].email
return sorted(domain.owners, key=sort_key)
diff --git a/src/mailman/runners/docs/lmtp.rst b/src/mailman/runners/docs/lmtp.rst
index 8243b62de..26939e9e5 100644
--- a/src/mailman/runners/docs/lmtp.rst
+++ b/src/mailman/runners/docs/lmtp.rst
@@ -20,7 +20,7 @@ Let's start a testable LMTP runner.
It also helps to have a nice LMTP client.
>>> lmtp = helpers.get_lmtp_client()
- (220, b'... GNU Mailman LMTP runner 1.1')
+ (220, b'... GNU Mailman LMTP runner 2.0')
>>> lmtp.lhlo('remote.example.org')
(250, ...)
diff --git a/src/mailman/runners/lmtp.py b/src/mailman/runners/lmtp.py
index 35cff4c94..21fb44c5e 100644
--- a/src/mailman/runners/lmtp.py
+++ b/src/mailman/runners/lmtp.py
@@ -34,11 +34,14 @@ so that the peer mail server can provide better diagnostics.
http://www.faqs.org/rfcs/rfc2033.html
"""
-import sys
import email
+import socket
import logging
-import asyncore
+import aiosmtpd
+import aiosmtpd.smtp
+from aiosmtpd.controller import Controller
+from aiosmtpd.lmtp import LMTP
from email.utils import parseaddr
from mailman import public
from mailman.config import config
@@ -50,14 +53,6 @@ from mailman.utilities.datetime import now
from mailman.utilities.email import add_message_hash
from zope.component import getUtility
-# Python 3.4's smtpd module can't handle non-UTF-8 byte input. Unfortunately
-# we do get such emails in the wild. Python 3.5's version of the module does
-# handle it correctly. We vendor a version to use in the Python 3.4 case.
-if sys.version_info < (3, 5):
- from mailman.compat import smtpd
-else:
- import smtpd
-
elog = logging.getLogger('mailman.error')
qlog = logging.getLogger('mailman.runner')
@@ -99,7 +94,7 @@ ERR_550 = '550 Requested action not taken: mailbox unavailable'
ERR_550_MID = '550 No Message-ID header provided'
# XXX Blech
-smtpd.__version__ = 'GNU Mailman LMTP runner 1.1'
+aiosmtpd.smtp.__version__ = 'GNU Mailman LMTP runner 2.0'
def split_recipient(address):
@@ -129,48 +124,7 @@ def split_recipient(address):
return listname, subaddress, domain
-class Channel(smtpd.SMTPChannel):
- """An LMTP channel."""
-
- def __init__(self, server, conn, addr):
- super().__init__(server, conn, addr, decode_data=False)
- # Stash this here since the subclass uses private attributes. :(
- self._server = server
-
- def smtp_LHLO(self, arg):
- """The LMTP greeting, used instead of HELO/EHLO."""
- super().smtp_HELO(arg)
-
- def smtp_HELO(self, arg):
- """HELO is not a valid LMTP command."""
- self.push(ERR_502)
-
- # def push(self, arg):
- # import pdb; pdb.set_trace()
- # return super().push(arg)
-
-
-@public
-class LMTPRunner(Runner, smtpd.SMTPServer):
- # Only __init__ is called on startup. Asyncore is responsible for later
- # connections from the MTA. slice and numslices are ignored and are
- # necessary only to satisfy the API.
-
- is_queue_runner = False
-
- def __init__(self, name, slice=None):
- localaddr = config.mta.lmtp_host, int(config.mta.lmtp_port)
- # Do not call Runner's constructor because there's no QDIR to create
- qlog.debug('LMTP server listening on %s:%s',
- localaddr[0], localaddr[1])
- smtpd.SMTPServer.__init__(self, localaddr, remoteaddr=None)
- super().__init__(name, slice)
-
- def handle_accept(self):
- conn, addr = self.accept()
- Channel(self, conn, addr)
- slog.debug('LMTP accept from %s', addr)
-
+class LMTPHandler:
@transactional
def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
try:
@@ -261,12 +215,35 @@ class LMTPRunner(Runner, smtpd.SMTPServer):
# response to the LMTP client.
return CRLF.join(status)
- def run(self):
- """See `IRunner`."""
- asyncore.loop(use_poll=True)
- def stop(self):
+class LMTPController(Controller):
+ def factory(self):
+ return LMTP(self.handler)
+
+ def make_socket(self):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
+ return sock
+
+
+@public
+class LMTPRunner(Runner):
+ # Only __init__ is called on startup. Asyncore is responsible for later
+ # connections from the MTA. slice and numslices are ignored and are
+ # necessary only to satisfy the API.
+
+ is_queue_runner = False
+
+ def __init__(self, name, slice=None):
+ super().__init__(name, slice)
+ hostname = config.mta.lmtp_host
+ port = int(config.mta.lmtp_port)
+ self.lmtp = LMTPController(LMTPHandler(), hostname=hostname, port=port)
+ qlog.debug('LMTP server listening on %s:%s', hostname, port)
+
+ def run(self):
"""See `IRunner`."""
- asyncore.socket_map.clear()
- asyncore.close_all()
- self.close()
+ self.lmtp.start()
+ while not self._stop:
+ self._snooze(0)
+ self.lmtp.stop()
diff --git a/src/mailman/runners/rest.py b/src/mailman/runners/rest.py
index 36b03aa5a..037b6adf8 100644
--- a/src/mailman/runners/rest.py
+++ b/src/mailman/runners/rest.py
@@ -50,7 +50,7 @@ class RESTRunner(Runner):
# server.
self._server = make_server()
self._event = threading.Event()
- def stopper(event, server): # noqa: E301
+ def stopper(event, server): # noqa: E306
event.wait()
server.shutdown()
self._thread = threading.Thread(
diff --git a/src/mailman/runners/tests/test_owner.py b/src/mailman/runners/tests/test_owner.py
index 1aa430b28..c95833932 100644
--- a/src/mailman/runners/tests/test_owner.py
+++ b/src/mailman/runners/tests/test_owner.py
@@ -113,7 +113,7 @@ Can you help me?
# All three messages will have two X-MailFrom headers. One is added
# by the LMTP server accepting Zuzu's original message, and will
# contain her posting address, i.e. zuzu@example.com. The second one
- # is added by the lazr.smtptest server that accepts Mailman's VERP'd
+ # is added by the aiosmtpd server that accepts Mailman's VERP'd
# message to the individual recipient. By verifying both, we prove
# that Zuzu sent the original message, and that Mailman is VERP'ing
# the copy to all the owners.
diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py
index d9cf974f5..2ce6956b7 100644
--- a/src/mailman/testing/helpers.py
+++ b/src/mailman/testing/helpers.py
@@ -248,7 +248,7 @@ def get_nntp_server(cleanups):
cleanups.append(patcher.stop)
nntpd = server_class()
# A class for more convenient access to the posted message.
- class NNTPProxy: # noqa: E301
+ class NNTPProxy: # noqa: E306
def get_message(self):
args = nntpd.post.call_args
return specialized_message_from_string(args[0][0].read())
diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py
index c1327f428..81e4b8a62 100644
--- a/src/mailman/testing/mta.py
+++ b/src/mailman/testing/mta.py
@@ -17,19 +17,19 @@
"""Fake MTA for testing purposes."""
-import logging
+import socket
+import asyncio
+import smtplib
-from lazr.smtptest.controller import QueueController
-from lazr.smtptest.server import Channel, QueueServer
+from aiosmtpd.controller import Controller
+from aiosmtpd.handlers import Message as MessageHandler
+from aiosmtpd.smtp import SMTP
from mailman import public
from mailman.interfaces.mta import IMailTransportAgentLifecycle
from queue import Empty, Queue
from zope.interface import implementer
-log = logging.getLogger('lazr.smtptest')
-
-
@public
@implementer(IMailTransportAgentLifecycle)
class FakeMTA:
@@ -45,113 +45,73 @@ class FakeMTA:
pass
-class StatisticsChannel(Channel):
- """A channel that can answers to the fake STAT command."""
+class ConnectionCountingHandler(MessageHandler):
+ def __init__(self, msg_queue):
+ super().__init__()
+ self._msg_queue = msg_queue
+ self.connection_count = 0
- def __init__(self, server, connection, address):
- super().__init__(server, connection, address)
- self._auth_response = None
- self._waiting_for_auth_response = False
+ def handle_message(self, message):
+ self._msg_queue.put(message)
- def smtp_EHLO(self, arg):
- if not arg:
- self.push('501 Syntax: HELO hostname')
- return
- if self._SMTPChannel__greeting:
- self.push('503 Duplicate HELO/EHLO')
- else:
- self._SMTPChannel__greeting = arg
- self.push('250-%s' % self._SMTPChannel__fqdn)
- self.push('250 AUTH PLAIN')
- def smtp_STAT(self, arg):
- """Cause the server to send statistics to its controller."""
- self._server.send_statistics()
- self.push('250 Ok')
+class ConnectionCountingSMTP(SMTP):
+ def __init__(self, handler, oob_queue, err_queue, *args, **kws):
+ super().__init__(handler, *args, **kws)
+ self._auth_response = None
+ self._waiting_for_auth_response = False
+ self._oob_queue = oob_queue
+ self._err_queue = err_queue
+ self._last_error = None
- def _check_auth(self, response):
- # Base 64 for "testuser:testpass"
- if response == 'AHRlc3R1c2VyAHRlc3RwYXNz':
- self.push('235 Ok')
- self._server.send_auth(response)
- else:
- self.push('571 Bad authentication')
+ def connection_made(self, transport):
+ super().connection_made(transport)
+ # We can't keep the connection count on self here because the
+ # controller (via the factory() method) will create a new instance of
+ # this class for every connection. The handler instance is always the
+ # same though, so it's fine to stash this value away there.
+ self.event_handler.connection_count += 1
+ @asyncio.coroutine
def smtp_AUTH(self, arg):
"""Record that the AUTH occurred."""
args = arg.split()
if args[0].lower() == 'plain':
if len(args) == 2:
+ response = args[1]
# The second argument is the AUTH PLAIN <initial-response>
# which must be equal to the base 64 equivalent of the
# expected login string "testuser:testpass".
- self._check_auth(args[1])
+ if response == 'AHRlc3R1c2VyAHRlc3RwYXNz':
+ yield from self.push('235 Ok')
+ self._oob_queue.put(response)
+ else:
+ yield from self.push('571 Bad authentication')
else:
assert len(args) == 1, args
# Send a challenge and set us up to wait for the response.
- self.push('334 ')
+ yield from self.push('334 ')
self._waiting_for_auth_response = True
else:
- self.push('571 Bad authentication')
-
- def smtp_RCPT(self, arg):
- """For testing, sometimes cause a non-25x response."""
- code = self._server.next_error('rcpt')
- if code is None:
- # Everything's cool.
- Channel.smtp_RCPT(self, arg)
- else:
- # The test suite wants this to fail. The message corresponds to
- # the exception we expect smtplib.SMTP to raise.
- self.push('%d Error: SMTPRecipientsRefused' % code)
-
- def smtp_MAIL(self, arg):
- """For testing, sometimes cause a non-25x response."""
- code = self._server.next_error('mail')
- if code is None:
- # Everything's cool.
- Channel.smtp_MAIL(self, arg)
- else:
- # The test suite wants this to fail. The message corresponds to
- # the exception we expect smtplib.SMTP to raise.
- self.push('%d Error: SMTPResponseException' % code)
-
- def found_terminator(self):
- # Are we're waiting for the AUTH challenge response?
- if self._waiting_for_auth_response:
- line = self._emptystring.join(self.received_lines)
- self._auth_response = line
- self._waiting_for_auth_response = False
- self.received_lines = []
- # Now check to see if they authenticated correctly.
- self._check_auth(line)
- else:
- super().found_terminator()
-
+ yield from self.push('571 Bad authentication')
-class ConnectionCountingServer(QueueServer):
- """Count the number of SMTP connections opened."""
+ @asyncio.coroutine
+ def ehlo_hook(self):
+ yield from self.push('250-AUTH PLAIN')
- def __init__(self, host, port, queue, oob_queue, err_queue):
- """See `lazr.smtptest.server.QueueServer`.
+ @asyncio.coroutine
+ def rset_hook(self):
+ self.event_handler.connection_count = 0
- :param oob_queue: A queue for communicating information back to the
- controller, e.g. statistics.
- :type oob_queue: `Queue.Queue`
- :param err_queue: A queue for allowing the controller to request SMTP
- errors from the server.
- :type err_queue: `Queue.Queue`
- """
- QueueServer.__init__(self, host, port, queue)
- self._connection_count = 0
- self.last_auth = None
- # The out-of-band queue is where the server sends statistics to the
- # controller upon request.
- self._oob_queue = oob_queue
- self._err_queue = err_queue
- self._last_error = None
+ @asyncio.coroutine
+ def smtp_STAT(self, arg):
+ """Cause the server to send statistics to its controller."""
+ # Do not count the connection caused by the STAT connect.
+ self.event_handler.connection_count -= 1
+ self._oob_queue.put(self.event_handler.connection_count)
+ yield from self.push('250 Ok')
- def next_error(self, command):
+ def _next_error(self, command):
"""Return the next error for the SMTP command, if there is one.
:param command: The SMTP command for which an error might be
@@ -176,70 +136,83 @@ class ConnectionCountingServer(QueueServer):
return code
return None
- def handle_accept(self):
- """See `lazr.smtp.server.Server`."""
- connection, address = self.accept()
- self._connection_count += 1
- log.info('[ConnectionCountingServer] accepted: %s', address)
- StatisticsChannel(self, connection, address)
-
- def process_message(self, peer, mailfrom, rcpttos, data):
- # Provide a guaranteed order to recpttos.
- QueueServer.process_message(
- self, peer, mailfrom, sorted(rcpttos), data)
-
- def reset(self):
- """See `lazr.smtp.server.Server`."""
- QueueServer.reset(self)
- self._connection_count = 0
-
- def send_statistics(self):
- """Send the current connection statistics to the controller."""
- # Do not count the connection caused by the STAT connect.
- self._connection_count -= 1
- self._oob_queue.put(self._connection_count)
+ @asyncio.coroutine
+ def smtp_RCPT(self, arg):
+ """For testing, sometimes cause a non-25x response."""
+ code = self._next_error('rcpt')
+ if code is None:
+ # Everything's cool.
+ yield from super().smtp_RCPT(arg)
+ else:
+ # The test suite wants this to fail. The message corresponds to
+ # the exception we expect smtplib.SMTP to raise.
+ yield from self.push('%d Error: SMTPRecipientsRefused' % code)
- def send_auth(self, arg):
- """Echo back the authentication data."""
- self._oob_queue.put(arg)
+ @asyncio.coroutine
+ def smtp_MAIL(self, arg):
+ """For testing, sometimes cause a non-25x response."""
+ code = self._next_error('mail')
+ if code is None:
+ # Everything's cool.
+ yield from super().smtp_MAIL(arg)
+ else:
+ # The test suite wants this to fail. The message corresponds to
+ # the exception we expect smtplib.SMTP to raise.
+ yield from self.push('%d Error: SMTPResponseException' % code)
-class ConnectionCountingController(QueueController):
+class ConnectionCountingController(Controller):
"""Count the number of SMTP connections opened."""
def __init__(self, host, port):
- """See `lazr.smtptest.controller.QueueController`."""
- self.oob_queue = Queue()
+ self._msg_queue = Queue()
+ self._oob_queue = Queue()
self.err_queue = Queue()
- QueueController.__init__(self, host, port)
+ handler = ConnectionCountingHandler(self._msg_queue)
+ super().__init__(handler, hostname=host, port=port)
+
+ def factory(self):
+ return ConnectionCountingSMTP(
+ self.handler, self._oob_queue, self.err_queue)
- def _make_server(self, host, port):
- """See `lazr.smtptest.controller.QueueController`."""
- self.server = ConnectionCountingServer(
- host, port, self.queue, self.oob_queue, self.err_queue)
+ def make_socket(self):
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
+ return sock
def start(self):
- """See `lazr.smtptest.controller.QueueController`."""
- QueueController.start(self)
+ super().start()
# Reset the connection statistics, since the base class's start()
# method causes a connection to occur.
self.reset()
+ def _connect(self):
+ client = smtplib.SMTP()
+ client.connect(self.hostname, self.port)
+ return client
+
def get_connection_count(self):
"""Retrieve the number of connections.
:return: The number of connections to the server that have been made.
:rtype: integer
"""
- smtpd = self._connect()
- smtpd.docmd('STAT')
+ client = self._connect()
+ client.docmd('STAT')
# An Empty exception will occur if the data isn't available in 10
# seconds. Let that propagate.
- return self.oob_queue.get(block=True, timeout=10)
+ return self._oob_queue.get(block=True, timeout=10)
def get_authentication_credentials(self):
"""Retrieve the last authentication credentials."""
- return self.oob_queue.get(block=True, timeout=10)
+ return self._oob_queue.get(block=True, timeout=10)
+
+ def __iter__(self):
+ while True:
+ try:
+ yield self._msg_queue.get_nowait()
+ except Empty:
+ raise StopIteration
@property
def messages(self):
@@ -251,5 +224,5 @@ class ConnectionCountingController(QueueController):
list(self)
def reset(self):
- smtpd = self._connect()
- smtpd.docmd('RSET')
+ client = self._connect()
+ client.docmd('RSET')
diff --git a/src/mailman/testing/nose.py b/src/mailman/testing/nose.py
index 6a2e0d6ba..bbd361390 100644
--- a/src/mailman/testing/nose.py
+++ b/src/mailman/testing/nose.py
@@ -42,7 +42,7 @@ class NosePlugin(Plugin):
super().__init__()
self.patterns = []
self.stderr = False
- def set_stderr(ignore): # noqa: E301
+ def set_stderr(ignore): # noqa: E306
self.stderr = True
self.addArgument(self.patterns, 'P', 'pattern',
'Add a test matching pattern')
diff --git a/src/mailman/tests/test_configfile.py b/src/mailman/tests/test_configfile.py
index 0112f78bd..36b9abdcf 100644
--- a/src/mailman/tests/test_configfile.py
+++ b/src/mailman/tests/test_configfile.py
@@ -116,7 +116,7 @@ class TestConfigFileSearchWithChroot(TestConfigFileBase):
# system that we can write to and test is to hack os.path.exists() to
# prepend a temporary directory onto the path it tests.
self._os_path_exists = os.path.exists
- def exists(path): # noqa: E301
+ def exists(path): # noqa: E306
# Strip off the leading slash, otherwise we'll end up with path.
return self._os_path_exists(self._make_fake(path))
os.path.exists = exists
diff --git a/src/mailman/utilities/tests/test_templates.py b/src/mailman/utilities/tests/test_templates.py
index a2b364c1a..02c77d0fa 100644
--- a/src/mailman/utilities/tests/test_templates.py
+++ b/src/mailman/utilities/tests/test_templates.py
@@ -77,7 +77,7 @@ class TestSearchOrder(unittest.TestCase):
def test_fully_specified_search_order(self):
search_order = self._stripped_search_order('foo.txt', self.mlist, 'it')
# For convenience.
- def nexteq(path): # noqa: E301
+ def nexteq(path): # noqa: E306
self.assertEqual(next(search_order), path)
# 1: Use the given language argument
nexteq('/v/templates/lists/l.example.com/it/foo.txt')
@@ -107,7 +107,7 @@ class TestSearchOrder(unittest.TestCase):
def test_no_language_argument_search_order(self):
search_order = self._stripped_search_order('foo.txt', self.mlist)
# For convenience.
- def nexteq(path): # noqa: E301
+ def nexteq(path): # noqa: E306
self.assertEqual(next(search_order), path)
# 1: Use mlist.preferred_language
nexteq('/v/templates/lists/l.example.com/de/foo.txt')
@@ -132,7 +132,7 @@ class TestSearchOrder(unittest.TestCase):
def test_no_mailing_list_argument_search_order(self):
search_order = self._stripped_search_order('foo.txt', language='it')
# For convenience.
- def nexteq(path): # noqa: E301
+ def nexteq(path): # noqa: E306
self.assertEqual(next(search_order), path)
# 1: Use the given language argument
nexteq('/v/templates/site/it/foo.txt')
@@ -148,7 +148,7 @@ class TestSearchOrder(unittest.TestCase):
def test_no_optional_arguments_search_order(self):
search_order = self._stripped_search_order('foo.txt')
# For convenience.
- def nexteq(path): # noqa: E301
+ def nexteq(path): # noqa: E306
self.assertEqual(next(search_order), path)
# 1: Use the site's default language
nexteq('/v/templates/site/fr/foo.txt')
@@ -180,7 +180,7 @@ class TestFind(unittest.TestCase):
self.mlist.preferred_language = 'xx'
self.fp = None
# Populate the template directories with a few fake templates.
- def write(text, path): # noqa: E301
+ def write(text, path): # noqa: E306
os.makedirs(os.path.dirname(path))
with open(path, 'w') as fp:
fp.write(text)
diff --git a/src/mailman/utilities/tests/test_uid.py b/src/mailman/utilities/tests/test_uid.py
index fd00ddfff..e3476dfbc 100644
--- a/src/mailman/utilities/tests/test_uid.py
+++ b/src/mailman/utilities/tests/test_uid.py
@@ -54,7 +54,7 @@ class TestUID(unittest.TestCase):
def test_uid_record_try_again(self):
called = False
- def record_second(ignore): # noqa: E301
+ def record_second(ignore): # noqa: E306
nonlocal called
if not called:
called = True