From 38119791db219b94b4e313b0d7c810590b5a7258 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 18 Oct 2009 19:13:24 -0400 Subject: Infrastructure for testing the Connection class, and for counting the number of session start and end events in the server. --- src/mailman/mta/connection.py | 93 ++++++++++++++++++++++++++++++++ src/mailman/mta/docs/connection.txt | 16 ++++++ src/mailman/mta/smtp_direct.py | 43 --------------- src/mailman/testing/layers.py | 18 +------ src/mailman/testing/mta.py | 103 ++++++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 59 deletions(-) create mode 100644 src/mailman/mta/connection.py create mode 100644 src/mailman/mta/docs/connection.txt diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py new file mode 100644 index 000000000..5c9ba7474 --- /dev/null +++ b/src/mailman/mta/connection.py @@ -0,0 +1,93 @@ +# Copyright (C) 2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""MTA connections.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Connection', + ] + + +import logging +import smtplib + +from mailman.config import config + + +log = logging.getLogger('mailman.smtp') + + + +class Connection: + """Manage a connection to the SMTP server.""" + def __init__(self, host, port, sessions_per_connection): + """Create a connection manager. + + :param host: The host name of the SMTP server to connect to. + :type host: string + :param port: The port number of the SMTP server to connect to. + :type port: integer + :param sessions_per_connection: The number of SMTP sessions per + connection to the SMTP server. After this number of sessions + has been reached, the connection is closed and a new one is + opened. Set to zero for an unlimited number of sessions per + connection (i.e. your MTA has no limit). + :type sessions_per_connection: integer + """ + self._host = host + self._port = port + self._connections_per_session = sessions_per_connection + self._connection = None + + def _connect(self): + """Open a new connection.""" + self._connection = smtplib.SMTP() + log.debug('Connecting to %s:%s', self._host, self._port) + self._connection.connect(self._host, self._port) + + def sendmail(self, envsender, recips, msgtext): + """Mimic `smtplib.SMTP.sendmail`.""" + if self._connection is None: + self._connect() + try: + results = self._connection.sendmail(envsender, recips, msgtext) + except smtplib.SMTPException: + # For safety, close this connection. The next send attempt will + # automatically re-open it. Pass the exception on up. + self.quit() + raise + # This session has been successfully completed. + self._sessions_per_connection -= 1 + # By testing exactly for equality to 0, we automatically handle the + # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close + # the connection. We won't worry about wraparound . + if self._sessions_per_connection == 0: + self.quit() + return results + + def quit(self): + """Mimic `smtplib.SMTP.quit`.""" + if self._connection is None: + return + try: + self._connection.quit() + except smtplib.SMTPException: + pass + self._connection = None diff --git a/src/mailman/mta/docs/connection.txt b/src/mailman/mta/docs/connection.txt new file mode 100644 index 000000000..d9d1ad1d0 --- /dev/null +++ b/src/mailman/mta/docs/connection.txt @@ -0,0 +1,16 @@ +=============== +MTA connections +=============== + +Outgoing connections to the outgoing mail transport agent (MTA) are mitigated +through a Connection class, which can transparently manage multiple sessions +to a single physical MTA. + + >>> from mailman.mta.connection import Connection + +The number of connections per session is specified when the Connection object +is created, as is the host and port number of the SMTP server. + + >>> connection = Connection( + ... config.mta.smtp_host, int(config.mta.smtp_port), 1) + diff --git a/src/mailman/mta/smtp_direct.py b/src/mailman/mta/smtp_direct.py index b1011c904..419d4ce96 100644 --- a/src/mailman/mta/smtp_direct.py +++ b/src/mailman/mta/smtp_direct.py @@ -59,49 +59,6 @@ COMMA = ',' log = logging.getLogger('mailman.smtp') - -# Manage a connection to the SMTP server -class Connection: - def __init__(self): - self._conn = None - - def _connect(self): - self._conn = smtplib.SMTP() - host = config.mta.smtp_host - port = int(config.mta.smtp_port) - log.debug('Connecting to %s:%s', host, port) - self._conn.connect(host, port) - self._numsessions = int(config.mta.max_sessions_per_connection) - - def sendmail(self, envsender, recips, msgtext): - if self._conn is None: - self._connect() - try: - results = self._conn.sendmail(envsender, recips, msgtext) - except smtplib.SMTPException: - # For safety, close this connection. The next send attempt will - # automatically re-open it. Pass the exception on up. - self.quit() - raise - # This session has been successfully completed. - self._numsessions -= 1 - # By testing exactly for equality to 0, we automatically handle the - # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close - # the connection. We won't worry about wraparound . - if self._numsessions == 0: - self.quit() - return results - - def quit(self): - if self._conn is None: - return - try: - self._conn.quit() - except smtplib.SMTPException: - pass - self._conn = None - - def process(mlist, msg, msgdata): recips = msgdata.get('recips') diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index d4db9ebf2..034e26f83 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -35,7 +35,6 @@ import logging import datetime import tempfile -from lazr.smtptest.controller import QueueController from pkg_resources import resource_string from textwrap import dedent from urllib2 import urlopen, URLError @@ -48,6 +47,7 @@ from mailman.i18n import _ from mailman.interfaces.domain import IDomainManager from mailman.interfaces.messages import IMessageStore from mailman.testing.helpers import TestableMaster +from mailman.testing.mta import SessionCountingController from mailman.utilities.datetime import factory from mailman.utilities.string import expand @@ -210,20 +210,6 @@ class ConfigLayer(MockAndMonkeyLayer): -class ExtendedQueueController(QueueController): - """QueueController with a little extra API.""" - - @property - def messages(self): - """Return all the messages received by the SMTP server.""" - for message in self: - yield message - - def clear(self): - """Clear all the messages from the queue.""" - list(self) - - class SMTPLayer(ConfigLayer): """Layer for starting, stopping, and accessing a test SMTP server.""" @@ -234,7 +220,7 @@ class SMTPLayer(ConfigLayer): assert cls.smtpd is None, 'Layer already set up' host = config.mta.smtp_host port = int(config.mta.smtp_port) - cls.smtpd = ExtendedQueueController(host, port) + cls.smtpd = SessionCountingController(host, port) cls.smtpd.start() @classmethod diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index a10ba3c81..e89cc7cfb 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -25,11 +25,20 @@ __all__ = [ ] +import logging + +from Queue import Queue + +from lazr.smtptest.controller import QueueController +from lazr.smtptest.server import Channel, QueueServer from zope.interface import implements from mailman.interfaces.mta import IMailTransportAgent +log = logging.getLogger('lazr.smtptest') + + class FakeMTA: """Fake MTA for testing purposes.""" @@ -44,3 +53,97 @@ class FakeMTA: def regenerate(self): pass + + + +class SessionCountingChannel(Channel): + """Count the number of SMTP sessions opened and closed.""" + + def smtp_HELO(self, arg): + """See `smtpd.SMTPChannel.smtp_HELO`.""" + # Store this on the server because while the channel has access to the + # server, the server does not have access to the individual channels. + self._server.helo_count += 1 + Channel.smtp_HELO(self, arg) + + def smtp_QUIT(self, arg): + """See `smtpd.SMTPChannel.smtp_QUIT`.""" + # Store this on the server because while the channel has access to the + # server, the server does not have access to the individual channels. + self._server.quit_count += 1 + Channel.smtp_QUIT(self, arg) + + def smtp_STAT(self, arg): + """Cause the server to send statistics to its controller.""" + self._server.send_statistics() + self.push('250 Ok') + + + +class SessionCountingServer(QueueServer): + """Count the number of SMTP sessions opened and closed.""" + + def __init__(self, host, port, queue, oob_queue): + """See `lazr.smtptest.server.QueueServer`.""" + QueueServer.__init__(self, host, port, queue) + # Store these on the server because while the channel has access to + # the server, the server does not have access to the individual + # channels. + self.helo_count = 0 + self.quit_count = 0 + # The out-of-band queue is where the server sends statistics to the + # controller upon request. + self._oob_queue = oob_queue + + def handle_accept(self): + """See `lazr.smtp.server.Server`.""" + connection, address = self.accept() + log.info('[SessionCountingServer] accepted: %s', address) + SessionCountingChannel(self, connection, address) + + def reset(self): + """See `lazr.smtp.server.Server`.""" + QueueServer.reset(self) + self.helo_count = 0 + self.quit_count = 0 + + def send_statistics(self): + """Send the current connection statistics to the controller.""" + self._oob_queue.put((self.helo_count, self.quit_count)) + + + +class SessionCountingController(QueueController): + """Count the number of SMTP sessions opened and closed.""" + + def __init__(self, host, port): + """See `lazr.smtptest.controller.QueueController`.""" + self.oob_queue = Queue() + QueueController.__init__(self, host, port) + + def _make_server(self, host, port): + """See `lazr.smtptest.controller.QueueController`.""" + self.server = SessionCountingServer( + host, port, self.queue, self.oob_queue) + + def get_statistics(self): + """Retrieve connection statistics from the server. + + :return: a 2-tuple of the format (HELO count, QUIT count) + :rtype 2-tuple of integers + """ + smtpd = self._connect() + smtpd.docmd('STAT') + # An Empty exception will occur if the data isn't available in 10 + # seconds. Let that propagate. + return self.queue.get(block=True, timeout=10) + + @property + def messages(self): + """Return all the messages received by the SMTP server.""" + for message in self: + yield message + + def clear(self): + """Clear all the messages from the queue.""" + list(self) -- cgit v1.3.1 From 2980db25a729060abbe541c80272b4d8cc956ce3 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 18 Oct 2009 19:25:23 -0400 Subject: Show that the session count is zero at first. --- src/mailman/mta/docs/connection.txt | 4 ++++ src/mailman/testing/mta.py | 45 +++++++++++++++---------------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/mailman/mta/docs/connection.txt b/src/mailman/mta/docs/connection.txt index d9d1ad1d0..d00df0579 100644 --- a/src/mailman/mta/docs/connection.txt +++ b/src/mailman/mta/docs/connection.txt @@ -14,3 +14,7 @@ is created, as is the host and port number of the SMTP server. >>> connection = Connection( ... config.mta.smtp_host, int(config.mta.smtp_port), 1) +At the start, there have been no connections to the server. + + >>> smtpd.get_session_count() + 0 diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index e89cc7cfb..28e5ec77c 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -59,20 +59,6 @@ class FakeMTA: class SessionCountingChannel(Channel): """Count the number of SMTP sessions opened and closed.""" - def smtp_HELO(self, arg): - """See `smtpd.SMTPChannel.smtp_HELO`.""" - # Store this on the server because while the channel has access to the - # server, the server does not have access to the individual channels. - self._server.helo_count += 1 - Channel.smtp_HELO(self, arg) - - def smtp_QUIT(self, arg): - """See `smtpd.SMTPChannel.smtp_QUIT`.""" - # Store this on the server because while the channel has access to the - # server, the server does not have access to the individual channels. - self._server.quit_count += 1 - Channel.smtp_QUIT(self, arg) - def smtp_STAT(self, arg): """Cause the server to send statistics to its controller.""" self._server.send_statistics() @@ -86,11 +72,7 @@ class SessionCountingServer(QueueServer): def __init__(self, host, port, queue, oob_queue): """See `lazr.smtptest.server.QueueServer`.""" QueueServer.__init__(self, host, port, queue) - # Store these on the server because while the channel has access to - # the server, the server does not have access to the individual - # channels. - self.helo_count = 0 - self.quit_count = 0 + self.session_count = 0 # The out-of-band queue is where the server sends statistics to the # controller upon request. self._oob_queue = oob_queue @@ -98,18 +80,20 @@ class SessionCountingServer(QueueServer): def handle_accept(self): """See `lazr.smtp.server.Server`.""" connection, address = self.accept() + self.session_count += 1 log.info('[SessionCountingServer] accepted: %s', address) SessionCountingChannel(self, connection, address) def reset(self): """See `lazr.smtp.server.Server`.""" QueueServer.reset(self) - self.helo_count = 0 - self.quit_count = 0 + self.session_count = 0 def send_statistics(self): """Send the current connection statistics to the controller.""" - self._oob_queue.put((self.helo_count, self.quit_count)) + # Do not count the connection caused by the STAT connect. + self.session_count -= 1 + self._oob_queue.put(self.session_count) @@ -126,17 +110,24 @@ class SessionCountingController(QueueController): self.server = SessionCountingServer( host, port, self.queue, self.oob_queue) - def get_statistics(self): - """Retrieve connection statistics from the server. + def start(self): + """See `lazr.smtptest.controller.QueueController`.""" + QueueController.start(self) + # Reset the connection statistics, since the base class's start() + # method causes a connection to occur. + self.reset() + + def get_session_count(self): + """Retrieve the number of sessions. - :return: a 2-tuple of the format (HELO count, QUIT count) - :rtype 2-tuple of integers + :return: The number of sessions that have been opened. + :rtype: integer """ smtpd = self._connect() smtpd.docmd('STAT') # An Empty exception will occur if the data isn't available in 10 # seconds. Let that propagate. - return self.queue.get(block=True, timeout=10) + return self.oob_queue.get(block=True, timeout=10) @property def messages(self): -- cgit v1.3.1 From 9f6eaef1d836a4234773c0f64dbb09b901ccf21f Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 18 Oct 2009 19:32:07 -0400 Subject: Show that session counts are kept and can be reset. --- src/mailman/mta/connection.py | 2 +- src/mailman/mta/docs/connection.txt | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py index 5c9ba7474..922c492e9 100644 --- a/src/mailman/mta/connection.py +++ b/src/mailman/mta/connection.py @@ -53,7 +53,7 @@ class Connection: """ self._host = host self._port = port - self._connections_per_session = sessions_per_connection + self._sessions_per_connection = sessions_per_connection self._connection = None def _connect(self): diff --git a/src/mailman/mta/docs/connection.txt b/src/mailman/mta/docs/connection.txt index d00df0579..d57e301d6 100644 --- a/src/mailman/mta/docs/connection.txt +++ b/src/mailman/mta/docs/connection.txt @@ -13,8 +13,33 @@ is created, as is the host and port number of the SMTP server. >>> connection = Connection( ... config.mta.smtp_host, int(config.mta.smtp_port), 1) - + At the start, there have been no connections to the server. >>> smtpd.get_session_count() 0 + +By sending a message to the server, a session is started. + + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + + >>> smtpd.get_session_count() + 1 + +We can reset the session count back to zero. + + >>> from smtplib import SMTP + >>> def reset(): + ... smtpd = SMTP() + ... smtpd.connect(config.mta.smtp_host, int(config.mta.smtp_port)) + ... smtpd.docmd('RSET') + + >>> reset() + >>> smtpd.get_session_count() + 0 -- cgit v1.3.1 From 34e27b816aa120e81a8e970ac1d8847384bd3edf Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 18 Oct 2009 20:05:15 -0400 Subject: Show that a max_sessions_per_connection == 0 means there's an unlimited number of sessions per connection (or at least 10 ). --- src/mailman/mta/connection.py | 6 +- src/mailman/mta/docs/connection.txt | 130 +++++++++++++++++++++++++++++++++--- src/mailman/testing/layers.py | 4 +- src/mailman/testing/mta.py | 34 +++++----- 4 files changed, 144 insertions(+), 30 deletions(-) diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py index 922c492e9..105d25afb 100644 --- a/src/mailman/mta/connection.py +++ b/src/mailman/mta/connection.py @@ -54,6 +54,7 @@ class Connection: self._host = host self._port = port self._sessions_per_connection = sessions_per_connection + self._session_count = None self._connection = None def _connect(self): @@ -61,6 +62,7 @@ class Connection: self._connection = smtplib.SMTP() log.debug('Connecting to %s:%s', self._host, self._port) self._connection.connect(self._host, self._port) + self._session_count = self._sessions_per_connection def sendmail(self, envsender, recips, msgtext): """Mimic `smtplib.SMTP.sendmail`.""" @@ -74,11 +76,11 @@ class Connection: self.quit() raise # This session has been successfully completed. - self._sessions_per_connection -= 1 + self._session_count -= 1 # By testing exactly for equality to 0, we automatically handle the # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close # the connection. We won't worry about wraparound . - if self._sessions_per_connection == 0: + if self._session_count == 0: self.quit() return results diff --git a/src/mailman/mta/docs/connection.txt b/src/mailman/mta/docs/connection.txt index d57e301d6..8924826c8 100644 --- a/src/mailman/mta/docs/connection.txt +++ b/src/mailman/mta/docs/connection.txt @@ -4,22 +4,23 @@ MTA connections Outgoing connections to the outgoing mail transport agent (MTA) are mitigated through a Connection class, which can transparently manage multiple sessions -to a single physical MTA. +in a single connection. >>> from mailman.mta.connection import Connection -The number of connections per session is specified when the Connection object -is created, as is the host and port number of the SMTP server. +The number of sessions per connections is specified when the Connection object +is created, as is the host and port number of the SMTP server. Zero means +there's an unlimited number of sessions per connection. >>> connection = Connection( - ... config.mta.smtp_host, int(config.mta.smtp_port), 1) + ... config.mta.smtp_host, int(config.mta.smtp_port), 0) At the start, there have been no connections to the server. - >>> smtpd.get_session_count() + >>> smtpd.get_connection_count() 0 -By sending a message to the server, a session is started. +By sending a message to the server, a connection is opened. >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ ... From: anne@example.com @@ -29,10 +30,10 @@ By sending a message to the server, a session is started. ... """) {} - >>> smtpd.get_session_count() + >>> smtpd.get_connection_count() 1 -We can reset the session count back to zero. +We can reset the connection count back to zero. >>> from smtplib import SMTP >>> def reset(): @@ -41,5 +42,116 @@ We can reset the session count back to zero. ... smtpd.docmd('RSET') >>> reset() - >>> smtpd.get_session_count() + >>> smtpd.get_connection_count() 0 + + >>> connection.quit() + + +Sessions per connection +======================= + +Let's say we specify a maximum number of sessions per connection of 2. When +the third message is sent, the connection is torn down and a new one is +created. + +The connection count starts at zero. + + >>> connection = Connection( + ... config.mta.smtp_host, int(config.mta.smtp_port), 2) + + >>> smtpd.get_connection_count() + 0 + +We send two messages through the Connection object. Only one connection is +opened. + + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + + >>> smtpd.get_connection_count() + 1 + + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + + >>> smtpd.get_connection_count() + 1 + +The third message causes a third session, which exceeds the maximum. So the +current connection is closed and a new one opened. + + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + + >>> smtpd.get_connection_count() + 2 + +A fourth message does not cause a new connection to be made. + + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + + >>> smtpd.get_connection_count() + 2 + +But a fifth one does. + + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + + >>> smtpd.get_connection_count() + 3 + + +No maximum +========== + +A value of zero means that there is an unlimited number of sessions per +connection. + + >>> connection = Connection( + ... config.mta.smtp_host, int(config.mta.smtp_port), 0) + >>> reset() + +Even after ten messages are sent, there's still been only one connection to +the server. + + >>> connection.debug = True + >>> for i in range(10): + ... # Ignore the results. + ... results = connection.sendmail( + ... 'anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + + >>> smtpd.get_connection_count() + 1 diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index 034e26f83..70b0ae9b1 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -47,7 +47,7 @@ from mailman.i18n import _ from mailman.interfaces.domain import IDomainManager from mailman.interfaces.messages import IMessageStore from mailman.testing.helpers import TestableMaster -from mailman.testing.mta import SessionCountingController +from mailman.testing.mta import ConnectionCountingController from mailman.utilities.datetime import factory from mailman.utilities.string import expand @@ -220,7 +220,7 @@ class SMTPLayer(ConfigLayer): assert cls.smtpd is None, 'Layer already set up' host = config.mta.smtp_host port = int(config.mta.smtp_port) - cls.smtpd = SessionCountingController(host, port) + cls.smtpd = ConnectionCountingController(host, port) cls.smtpd.start() @classmethod diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index 28e5ec77c..62070cc5d 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -56,8 +56,8 @@ class FakeMTA: -class SessionCountingChannel(Channel): - """Count the number of SMTP sessions opened and closed.""" +class StatisticsChannel(Channel): + """A channel that can answers to the fake STAT command.""" def smtp_STAT(self, arg): """Cause the server to send statistics to its controller.""" @@ -66,13 +66,13 @@ class SessionCountingChannel(Channel): -class SessionCountingServer(QueueServer): - """Count the number of SMTP sessions opened and closed.""" +class ConnectionCountingServer(QueueServer): + """Count the number of SMTP connections opened.""" def __init__(self, host, port, queue, oob_queue): """See `lazr.smtptest.server.QueueServer`.""" QueueServer.__init__(self, host, port, queue) - self.session_count = 0 + self._connection_count = 0 # The out-of-band queue is where the server sends statistics to the # controller upon request. self._oob_queue = oob_queue @@ -80,25 +80,25 @@ class SessionCountingServer(QueueServer): def handle_accept(self): """See `lazr.smtp.server.Server`.""" connection, address = self.accept() - self.session_count += 1 - log.info('[SessionCountingServer] accepted: %s', address) - SessionCountingChannel(self, connection, address) + self._connection_count += 1 + log.info('[ConnectionCountingServer] accepted: %s', address) + StatisticsChannel(self, connection, address) def reset(self): """See `lazr.smtp.server.Server`.""" QueueServer.reset(self) - self.session_count = 0 + 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.session_count -= 1 - self._oob_queue.put(self.session_count) + self._connection_count -= 1 + self._oob_queue.put(self._connection_count) -class SessionCountingController(QueueController): - """Count the number of SMTP sessions opened and closed.""" +class ConnectionCountingController(QueueController): + """Count the number of SMTP connections opened.""" def __init__(self, host, port): """See `lazr.smtptest.controller.QueueController`.""" @@ -107,7 +107,7 @@ class SessionCountingController(QueueController): def _make_server(self, host, port): """See `lazr.smtptest.controller.QueueController`.""" - self.server = SessionCountingServer( + self.server = ConnectionCountingServer( host, port, self.queue, self.oob_queue) def start(self): @@ -117,10 +117,10 @@ class SessionCountingController(QueueController): # method causes a connection to occur. self.reset() - def get_session_count(self): - """Retrieve the number of sessions. + def get_connection_count(self): + """Retrieve the number of connections. - :return: The number of sessions that have been opened. + :return: The number of connections to the server that have been made. :rtype: integer """ smtpd = self._connect() -- cgit v1.3.1 From 1bd649921b181c1f61083a04df0a9224d7164d0d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 18 Oct 2009 21:48:52 -0400 Subject: IMailTransportAgent -> IMailTransportAgentAliases Add IMailTransportAgentDelivery and begin to flesh out the doctest. --- src/mailman/interfaces/mta.py | 28 +++++++++++++++++++++++++--- src/mailman/mta/bulk.py | 40 ++++++++++++++++++++++++++++++++++++++++ src/mailman/mta/docs/bulk.txt | 24 ++++++++++++++++++++++++ src/mailman/mta/null.py | 10 +++++----- src/mailman/mta/postfix.py | 8 ++++---- src/mailman/testing/mta.py | 4 ++-- 6 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 src/mailman/mta/bulk.py create mode 100644 src/mailman/mta/docs/bulk.txt diff --git a/src/mailman/interfaces/mta.py b/src/mailman/interfaces/mta.py index a8c794750..3dd1793e5 100644 --- a/src/mailman/interfaces/mta.py +++ b/src/mailman/interfaces/mta.py @@ -21,7 +21,8 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'IMailTransportAgent', + 'IMailTransportAgentAliases', + 'IMailTransportAgentDelivery', ] @@ -29,8 +30,8 @@ from zope.interface import Interface -class IMailTransportAgent(Interface): - """Interface to the MTA.""" +class IMailTransportAgentAliases(Interface): + """Interface to the MTA aliases generator.""" def create(mlist): """Tell the MTA that the mailing list was created.""" @@ -40,3 +41,24 @@ class IMailTransportAgent(Interface): def regenerate(): """Regenerate the full aliases file.""" + + + +class IMailTransportAgentDelivery(Interface): + """Interface to the MTA delivery strategies.""" + + def deliver(mlist, msg, msgdata): + """Deliver a message to a mailing list's recipients. + + Ordinarily the mailing list is consulted for delivery specifics, + however the message metadata dictionary can contain additional + directions to control delivery. Specifics are left to the + implementation. + + :param mlist: The mailing list being delivered to. + :type mlist: `IMailingList` + :param msg: The original message being delivered. + :type msg: `Message` + :param msgdata: Additional message metadata for this delivery. + :type msgdata: dictionary + """ diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py new file mode 100644 index 000000000..12f94cc23 --- /dev/null +++ b/src/mailman/mta/bulk.py @@ -0,0 +1,40 @@ +# Copyright (C) 2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Module stuff.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'BulkDelivery', + ] + + +from zope.interface import implements + +from mailman.interfaces.mta import IMailTransportAgentDelivery + + + +class BulkDelivery: + """Deliver messages to the MTA in as few sessions as possible.""" + + implements(IMailTransportAgentDelivery) + + def deliver(self, mlist, msg, msgdata): + """See `IMailTransportAgentDelivery`.""" diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt new file mode 100644 index 000000000..4f564173d --- /dev/null +++ b/src/mailman/mta/docs/bulk.txt @@ -0,0 +1,24 @@ +====================== +Standard bulk delivery +====================== + +Mailman has several built in strategies for completing the actual delivery of +messages to the immediate upstream mail transport agent, which completes the +actual final delivery to recipients. + +Bulk delivery attempts to deliver as few copies of the identical message as +possible to as many recipients as possible. By grouping recipients this way, +bandwidth between Mailman and the MTA, and consequently between the MTA and +remote mail servers, can be greatly reduced. The downside is the messages +cannot be personalized. See `verp.txt`_ for an alternative strategy. + + >>> from mailman.mta.bulk import BulkDelivery + +Delivery strategies must implement the proper interface. + + >>> bulk = BulkDelivery() + + >>> from mailman.interfaces.mta import IMailTransportAgentDelivery + >>> from zope.interface.verify import verifyObject + >>> verifyObject(IMailTransportAgentDelivery, bulk) + True diff --git a/src/mailman/mta/null.py b/src/mailman/mta/null.py index 07cb7a869..5670d7c7f 100644 --- a/src/mailman/mta/null.py +++ b/src/mailman/mta/null.py @@ -29,23 +29,23 @@ __all__ = [ from zope.interface import implements -from mailman.interfaces.mta import IMailTransportAgent +from mailman.interfaces.mta import IMailTransportAgentAliases class NullMTA: """Null MTA that just satisfies the interface.""" - implements(IMailTransportAgent) + implements(IMailTransportAgentAliases) def create(self, mlist): - """See `IMailTransportAgent`.""" + """See `IMailTransportAgentAliases`.""" pass def delete(self, mlist): - """See `IMailTransportAgent`.""" + """See `IMailTransportAgentAliases`.""" pass def regenerate(self): - """See `IMailTransportAgent`.""" + """See `IMailTransportAgentAliases`.""" pass diff --git a/src/mailman/mta/postfix.py b/src/mailman/mta/postfix.py index d68c3d19b..4ddf444af 100644 --- a/src/mailman/mta/postfix.py +++ b/src/mailman/mta/postfix.py @@ -40,7 +40,7 @@ from zope.interface import implements from mailman import Utils from mailman.config import config from mailman.interfaces.listmanager import IListManager -from mailman.interfaces.mta import IMailTransportAgent +from mailman.interfaces.mta import IMailTransportAgentAliases from mailman.i18n import _ log = logging.getLogger('mailman.error') @@ -56,10 +56,10 @@ SUBDESTINATIONS = ( class LMTP: """Connect Mailman to Postfix via LMTP.""" - implements(IMailTransportAgent) + implements(IMailTransportAgentAliases) def create(self, mlist): - """See `IMailTransportAgent`.""" + """See `IMailTransportAgentAliases`.""" # Acquire a lock file to prevent other processes from racing us here. with Lock(LOCKFILE): # We can ignore the mlist argument because for LMTP delivery, we @@ -69,7 +69,7 @@ class LMTP: delete = create def regenerate(self): - """See `IMailTransportAgent`.""" + """See `IMailTransportAgentAliases`.""" # Acquire a lock file to prevent other processes from racing us here. with Lock(LOCKFILE): self._do_write_file() diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index 62070cc5d..6e0ee45b9 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -33,7 +33,7 @@ from lazr.smtptest.controller import QueueController from lazr.smtptest.server import Channel, QueueServer from zope.interface import implements -from mailman.interfaces.mta import IMailTransportAgent +from mailman.interfaces.mta import IMailTransportAgentAliases log = logging.getLogger('lazr.smtptest') @@ -43,7 +43,7 @@ log = logging.getLogger('lazr.smtptest') class FakeMTA: """Fake MTA for testing purposes.""" - implements(IMailTransportAgent) + implements(IMailTransportAgentAliases) def create(self, mlist): pass -- cgit v1.3.1 From 8b2b3712c3b9df2e7cc63e63799d830b12e12e00 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Mon, 19 Oct 2009 07:56:20 -0400 Subject: The bulk delivery chunking algorithm. --- src/mailman/interfaces/mta.py | 8 ++- src/mailman/mta/bulk.py | 67 ++++++++++++++++++++++++ src/mailman/mta/docs/bulk.txt | 118 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 189 insertions(+), 4 deletions(-) diff --git a/src/mailman/interfaces/mta.py b/src/mailman/interfaces/mta.py index 3dd1793e5..f8dcb64b3 100644 --- a/src/mailman/interfaces/mta.py +++ b/src/mailman/interfaces/mta.py @@ -53,7 +53,13 @@ class IMailTransportAgentDelivery(Interface): Ordinarily the mailing list is consulted for delivery specifics, however the message metadata dictionary can contain additional directions to control delivery. Specifics are left to the - implementation. + implementation, but there are a few common keys: + + * envelope_sender - the email address of the RFC 2821 envelope sender; + * decorated - a flag indicating whether the message has been decorated + with headers and footers yet; + * recipients - the set of all recipients who should receive this + message, as a set of email addresses; :param mlist: The mailing list being delivered to. :type mlist: `IMailingList` diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index 12f94cc23..5c6d8257f 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -25,16 +25,83 @@ __all__ = [ ] +from itertools import chain + from zope.interface import implements from mailman.interfaces.mta import IMailTransportAgentDelivery +# A mapping of top-level domains to bucket numbers. The zeroth bucket is +# reserved for everything else. At one time, these were the most common +# domains. +CHUNKMAP = dict( + com=1, + net=2, + org=2, + edu=3, + us=3, + ca=3, + ) + + class BulkDelivery: """Deliver messages to the MTA in as few sessions as possible.""" implements(IMailTransportAgentDelivery) + def __init__(self, max_recipients): + """Create a bulk deliverer. + + :param max_recipients: The maximum number of recipients per delivery + chunk. Zero or less means to group all recipients into one + chunk. + :type max_recipients: integer + """ + self._max_recipients = max_recipients + def deliver(self, mlist, msg, msgdata): """See `IMailTransportAgentDelivery`.""" + + def chunkify(self, recipients): + """Split a set of recipients into chunks. + + The `max_recipients` argument given to the constructor specifies the + maximum number of recipients in each chunk. + + :param recipients: The set of recipient email addresses + :type recipients: sequence of email address strings + :return: A list of chunks, where each chunk is a set containing no + more than `max_recipients` number of addresses. The chunk can + contain fewer, and no packing is guaranteed. + :rtype: list of sets of strings + """ + if self._max_recipients <= 0: + yield set(recipients) + return + # This algorithm was originally suggested by Chuq Von Rospach. Start + # by splitting the recipient addresses into top-level domain buckets, + # using the "most common" domains. Everything else ends up in the + # zeroth bucket. + by_bucket = {} + for address in recipients: + localpart, at, domain = address.partition('@') + domain_parts = domain.split('.') + bucket_number = CHUNKMAP.get(domain_parts[-1], 0) + by_bucket.setdefault(bucket_number, set()).add(address) + # Fill chunks by sorting the tld values by length. + chunk = set() + for tld_chunk in sorted(by_bucket.values(), key=len, reverse=True): + while tld_chunk: + chunk.add(tld_chunk.pop()) + if len(chunk) == self._max_recipients: + yield chunk + chunk = set() + # Every tld bucket starts a new chunk, but only if non-empty + if len(chunk) > 0: + yield chunk + chunk = set() + # Be sure to include the last chunk, but only if it's non-empty. + if len(chunk) > 0: + yield chunk diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index 4f564173d..0943b276f 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -14,11 +14,123 @@ cannot be personalized. See `verp.txt`_ for an alternative strategy. >>> from mailman.mta.bulk import BulkDelivery -Delivery strategies must implement the proper interface. +The standard bulk deliverer takes as an argument the maximum number of +recipients per session. The default is to deliver the message in one chunk, +containing all recipients. + + >>> bulk = BulkDelivery(0) - >>> bulk = BulkDelivery() +Delivery strategies must implement the proper interface. >>> from mailman.interfaces.mta import IMailTransportAgentDelivery >>> from zope.interface.verify import verifyObject - >>> verifyObject(IMailTransportAgentDelivery, bulk) + >>> verifyObject(IMailTransportAgentDelivery, bulk) + True + + +Chunking recipients +=================== + +The set of final recipients is contained in the 'recipients' key in the +message metadata. When `max_recipients` is specified as zero, then the bulk +deliverer puts all recipients into one big chunk. + + >>> from string import ascii_letters + >>> recipients = set(letter + 'person@example.com' + ... for letter in ascii_letters) + + >>> chunks = list(bulk.chunkify(recipients)) + >>> len(chunks) + 1 + >>> len(chunks[0]) + 52 + +Let say the maximum number of recipients allowed is 4, then no chunk will have +more than 4 recipients, though they can have fewer (but still not zero). + + >>> bulk = BulkDelivery(4) + >>> chunks = list(bulk.chunkify(recipients)) + >>> len(chunks) + 13 + >>> all(0 < len(chunk) <= 4 for chunk in chunks) True + +The chunking algorithm sorts recipients by top level domain by length. + + >>> recipients = set([ + ... 'anne@example.com', + ... 'bart@example.org', + ... 'cate@example.net', + ... 'dave@example.com', + ... 'elle@example.org', + ... 'fred@example.net', + ... 'gwen@example.com', + ... 'herb@example.us', + ... 'ione@example.net', + ... 'john@example.com', + ... 'kate@example.com', + ... 'liam@example.ca', + ... 'mary@example.us', + ... 'neil@example.net', + ... 'ocho@example.org', + ... 'paco@example.xx', + ... 'quaq@example.zz', + ... ]) + + >>> bulk = BulkDelivery(4) + >>> chunks = list(bulk.chunkify(recipients)) + >>> len(chunks) + 6 + +We can't make any guarantees about sorting within each chunk, but we can tell +a few things. For example, the first two chunks will be composed of .net (4) +and .org (3) domains (for a total of 7). + + >>> len(chunks[0]) + 4 + >>> len(chunks[1]) + 3 + + >>> for address in sorted(chunks[0].union(chunks[1])): + ... print address + bart@example.org + cate@example.net + elle@example.org + fred@example.net + ione@example.net + neil@example.net + ocho@example.org + +We also know that the next two chunks will contain .com (5) addresses. + + >>> len(chunks[2]) + 4 + >>> len(chunks[3]) + 1 + + >>> for address in sorted(chunks[2].union(chunks[3])): + ... print address + anne@example.com + dave@example.com + gwen@example.com + john@example.com + kate@example.com + +The next chunk will contain the .us (2) and .ca (1) domains. + + >>> len(chunks[4]) + 3 + >>> for address in sorted(chunks[4]): + ... print address + herb@example.us + liam@example.ca + mary@example.us + +The final chunk will contain the outliers, .xx (1) and .zz (2). + + >>> len(chunks[5]) + 2 + >>> for address in sorted(chunks[5]): + ... print address + paco@example.xx + quaq@example.zz -- cgit v1.3.1 From 434eea096b03cc713a626e8d051e5632dd0b8aff Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 23 Oct 2009 22:43:29 -0400 Subject: recips -> recipients --- src/mailman/pipeline/calculate_recipients.py | 38 ++++++++++++++-------------- src/mailman/pipeline/docs/calc-recips.txt | 10 ++++---- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/mailman/pipeline/calculate_recipients.py b/src/mailman/pipeline/calculate_recipients.py index 0850db929..ccbc069d1 100644 --- a/src/mailman/pipeline/calculate_recipients.py +++ b/src/mailman/pipeline/calculate_recipients.py @@ -19,7 +19,7 @@ This module calculates the non-digest recipients for the message based on the list's membership and configuration options. It places the list of recipients -on the `recips' attribute of the message. This attribute is used by the +on the `recipients' attribute of the message. This attribute is used by the SendmailDeliver and BulkDeliver modules. """ @@ -52,7 +52,7 @@ class CalculateRecipients: def process(self, mlist, msg, msgdata): # Short circuit if we've already calculated the recipients list, # regardless of whether the list is empty or not. - if 'recips' in msgdata: + if 'recipients' in msgdata: return # Should the original sender should be included in the recipients list? include_sender = True @@ -72,10 +72,10 @@ class CalculateRecipients: if mlist.Authenticate((config.AuthListModerator, config.AuthListAdmin), password): - recips = mlist.getMemberCPAddresses( + recipients = mlist.getMemberCPAddresses( mlist.getRegularMemberKeys() + mlist.getDigestMemberKeys()) - msgdata['recips'] = recips + msgdata['recipients'] = recipients return else: # Bad Urgent: password, so reject it instead of passing it on. @@ -88,30 +88,30 @@ delivery. The original message as received by Mailman is attached. """) raise errors.RejectMessage(Utils.wrap(text)) # Calculate the regular recipients of the message - recips = set(member.address.address - for member in mlist.regular_members.members - if member.delivery_status == DeliveryStatus.enabled) + recipients = set(member.address.address + for member in mlist.regular_members.members + if member.delivery_status == DeliveryStatus.enabled) # Remove the sender if they don't want to receive their own posts - if not include_sender and member.address.address in recips: - recips.remove(member.address.address) + if not include_sender and member.address.address in recipients: + recipients.remove(member.address.address) # Handle topic classifications - do_topic_filters(mlist, msg, msgdata, recips) + do_topic_filters(mlist, msg, msgdata, recipients) # Bookkeeping - msgdata['recips'] = recips + msgdata['recipients'] = recipients -def do_topic_filters(mlist, msg, msgdata, recips): +def do_topic_filters(mlist, msg, msgdata, recipients): if not mlist.topics_enabled: # MAS: if topics are currently disabled for the list, send to all # regardless of ReceiveNonmatchingTopics return hits = msgdata.get('topichits') - zaprecips = [] + zap_recipients = [] if hits: # The message hit some topics, so only deliver this message to those # who are interested in one of the hit topics. - for user in recips: + for user in recipients: utopics = mlist.getMemberTopics(user) if not utopics: # This user is not interested in any topics, so they get all @@ -125,13 +125,13 @@ def do_topic_filters(mlist, msg, msgdata, recips): else: # The user was interested in topics, but not any of the ones # this message matched, so zap him. - zaprecips.append(user) + zap_recipients.append(user) else: # The semantics for a message that did not hit any of the pre-canned # topics is to troll through the membership list, looking for users # who selected at least one topic of interest, but turned on # ReceiveNonmatchingTopics. - for user in recips: + for user in recipients: if not mlist.getMemberTopics(user): # The user did not select any topics of interest, so he gets # this message by default. @@ -140,8 +140,8 @@ def do_topic_filters(mlist, msg, msgdata, recips): user, config.ReceiveNonmatchingTopics): # The user has interest in some topics, but elects not to # receive message that match no topics, so zap him. - zaprecips.append(user) + zap_recipients.append(user) # Otherwise, the user wants non-matching messages. # Prune out the non-receiving users - for user in zaprecips: - recips.remove(user) + for user in zap_recipients: + recipients.remove(user) diff --git a/src/mailman/pipeline/docs/calc-recips.txt b/src/mailman/pipeline/docs/calc-recips.txt index ef8c0e2b7..0821aa1a9 100644 --- a/src/mailman/pipeline/docs/calc-recips.txt +++ b/src/mailman/pipeline/docs/calc-recips.txt @@ -53,12 +53,12 @@ but not all of the recipients. ... ... Something of great import. ... """) - >>> recips = set(('qperson@example.com', 'zperson@example.com')) - >>> msgdata = dict(recips=recips) + >>> recipients = set(('qperson@example.com', 'zperson@example.com')) + >>> msgdata = dict(recipients=recipients) >>> handler = config.handlers['calculate-recipients'] >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'qperson@example.com', u'zperson@example.com'] @@ -70,7 +70,7 @@ soon as they are posted. In other words, these folks are not digest members. >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] Members can elect not to receive a list copy of their own postings. @@ -83,7 +83,7 @@ Members can elect not to receive a list copy of their own postings. ... """) >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'aperson@example.com', u'bperson@example.com'] Members can also elect not to receive a list copy of any message on which they -- cgit v1.3.1 From 7f5818a6c2356211c41c9fcd7dd8fa3b5c4ac6f6 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Fri, 23 Oct 2009 23:21:29 -0400 Subject: Default max_recipients to None, meaning no max. Hook it up to the Connection class. --- src/mailman/mta/bulk.py | 27 ++++++++++++----- src/mailman/mta/docs/bulk.txt | 70 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index 5c6d8257f..2b400e8b5 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -29,7 +29,9 @@ from itertools import chain from zope.interface import implements +from mailman.config import config from mailman.interfaces.mta import IMailTransportAgentDelivery +from mailman.mta.connection import Connection # A mapping of top-level domains to bucket numbers. The zeroth bucket is @@ -51,18 +53,20 @@ class BulkDelivery: implements(IMailTransportAgentDelivery) - def __init__(self, max_recipients): + def __init__(self, max_recipients=None): """Create a bulk deliverer. :param max_recipients: The maximum number of recipients per delivery - chunk. Zero or less means to group all recipients into one - chunk. + chunk. None, zero or less means to group all recipients into one + big chunk. :type max_recipients: integer """ - self._max_recipients = max_recipients - - def deliver(self, mlist, msg, msgdata): - """See `IMailTransportAgentDelivery`.""" + self._max_recipients = (max_recipients + if max_recipients is not None + else 0) + self._connection = Connection( + config.mta.smtp_host, int(config.mta.smtp_port), + self._max_recipients) def chunkify(self, recipients): """Split a set of recipients into chunks. @@ -105,3 +109,12 @@ class BulkDelivery: # Be sure to include the last chunk, but only if it's non-empty. if len(chunk) > 0: yield chunk + + def deliver(self, mlist, msg, msgdata): + """See `IMailTransportAgentDelivery`.""" + recipients = msgdata.get('recipients') + if recipients is None: + return + for recipients in self.chunkify(msgdata['recipients']): + self._connection.sendmail( + 'foo@example.com', recipients, msg.as_string()) diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index 0943b276f..42c9e386a 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -18,7 +18,7 @@ The standard bulk deliverer takes as an argument the maximum number of recipients per session. The default is to deliver the message in one chunk, containing all recipients. - >>> bulk = BulkDelivery(0) + >>> bulk = BulkDelivery() Delivery strategies must implement the proper interface. @@ -134,3 +134,71 @@ The final chunk will contain the outliers, .xx (1) and .zz (2). ... print address paco@example.xx quaq@example.zz + + +Bulk delivery +============= + +The set of recipients for bulk delivery comes from the message metadata. If +there are no calculated recipients, nothing gets sent. + + >>> smtpd.clear() + >>> mlist = create_list('test@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: test one + ... Message-ID: + ... + ... This is a test. + ... """) + + >>> bulk = BulkDelivery() + >>> bulk.deliver(mlist, msg, {}) + >>> len(list(smtpd.messages)) + 0 + +With bulk delivery and no maximum number of recipients, there will be just one +message sent, with all the recipients packed into the envelope recipients +(i.e. RCTP TO). + + >>> recipients = set('person_{0:02d}'.format(i) for i in range(100)) + >>> msgdata = dict(recipients=recipients) + >>> bulk.deliver(mlist, msg, msgdata) + >>> messages = list(smtpd.messages) + >>> len(messages) + 1 + >>> print messages[0].as_string() + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: ... + X-RcptTo: person_... + person_... + ... + + This is a test. + +The X-RcptTo header contains the set of recipients, in random order. + + >>> len(messages[0]['x-rcptto'].split(',')) + 100 + +When the maximum number of recipients is set to 20, 5 messages will be sent, +each with 20 addresses in the RCPT TO header. + + >>> bulk = BulkDelivery(20) + >>> bulk.deliver(mlist, msg, msgdata) + >>> messages = list(smtpd.messages) + >>> len(messages) + 5 + >>> for message in messages: + ... x_rcptto = message['x-rcptto'] + ... print 'Number of recipients:', len(x_rcptto.split(',')) + Number of recipients: 20 + Number of recipients: 20 + Number of recipients: 20 + Number of recipients: 20 + Number of recipients: 20 -- cgit v1.3.1 From 19e6548e3df4719455ab1ed2a242acbc3e38d9e9 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sat, 24 Oct 2009 16:49:33 -0400 Subject: Bulk delivery's Sender and Errors-To headers. --- src/mailman/mta/bulk.py | 19 +++++++ src/mailman/mta/docs/bulk.txt | 118 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index 2b400e8b5..ff00f3d20 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -115,6 +115,25 @@ class BulkDelivery: recipients = msgdata.get('recipients') if recipients is None: return + # Blow away any existing Sender and Errors-To headers and substitute + # our own. Our interpretation of RFC 5322 $3.6.2 is that Mailman is + # the "agent responsible for actual transmission of the message" + # because what we send to list members is different than what the + # original author sent. RFC 2076 says Errors-To is "non-standard, + # discouraged" but we include it for historical purposes. + del msg['sender'] + del msg['errors-to'] + # The message metadata can override the calculation of the sender, but + # otherwise it falls to the list's -bounces robot. If this message is + # not intended for any specific mailing list, the site owner's address + # is used. + sender = msgdata.get('sender') + if sender is None: + sender = (config.mailman.site_owner + if mlist is None + else mlist.bounces_address) + msg['Sender'] = sender + msg['Errors-To'] = sender for recipients in self.chunkify(msgdata['recipients']): self._connection.sendmail( 'foo@example.com', recipients, msg.as_string()) diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index 42c9e386a..a612e8ac9 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -173,8 +173,7 @@ message sent, with all the recipients packed into the envelope recipients To: test@example.com Subject: test one Message-ID: - X-Peer: ... - X-MailFrom: ... + ... X-RcptTo: person_... person_... ... @@ -202,3 +201,118 @@ each with 20 addresses in the RCPT TO header. Number of recipients: 20 Number of recipients: 20 Number of recipients: 20 + + +Delivery headers +================ + +The message delivered to bulk recipients has its Sender and Errors-To headers +set to some Mailman-responsible agent that can handle such errors, for example +the -bounces robot for the list or the site owner's address. The relevant +RFCs are 5321, 5322, and 2076. + +It is arguably incorrect for Mailman to touch the Sender header, if the +interpretation is such that the original author's agent is considered the +"agent responsible for actual transmission of the message" (RFC 5322 $3.6.2). +On the other hand, a reasonable argument can be made that Mailman is the +responsible agent since the message being received by list members is +different than the message sent by the original author. We make the latter +interpretation, and thus Mailman removes all existing Sender headers and +inserts its own. + +RFC 2076 states that Errors-To is "non-standard, discouraged". We treat it +the same as Sender for historical de-facto purposes. + +The bulk delivery module calculates the sending agent address first from the +message metadata... + + >>> bulk = BulkDelivery() + >>> recipients = set(['aperson@example.com']) + >>> msgdata = dict(recipients=recipients, + ... sender='asender@example.org') + >>> bulk.deliver(mlist, msg, msgdata) + >>> message = list(smtpd.messages)[0] + >>> print message.as_string() + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + Sender: asender@example.org + Errors-To: asender@example.org + X-Peer: ... + X-MailFrom: foo@example.com + X-RcptTo: aperson@example.com + + This is a test. + +...followed by the mailing list's bounces robot address... + + >>> del msgdata['sender'] + >>> bulk.deliver(mlist, msg, msgdata) + >>> message = list(smtpd.messages)[0] + >>> print message.as_string() + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + Sender: test-bounces@example.com + Errors-To: test-bounces@example.com + X-Peer: ... + X-MailFrom: foo@example.com + X-RcptTo: aperson@example.com + + This is a test. + +...and finally the site owner, if there is no mailing list target for this +message. + + >>> config.push('site-owner', """\ + ... [mailman] + ... site_owner: site-owner@example.com + ... """) + + >>> bulk.deliver(None, msg, msgdata) + >>> message = list(smtpd.messages)[0] + >>> print message.as_string() + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + Sender: site-owner@example.com + Errors-To: site-owner@example.com + X-Peer: ... + X-MailFrom: foo@example.com + X-RcptTo: aperson@example.com + + This is a test. + +Any existing Sender or Errors-To headers in the original message are always +deleted first. + + >>> msg = message_from_string("""\ + ... From: bperson@example.org + ... To: test@example.com + ... Subject: test two + ... Message-ID: + ... Sender: robot@example.org + ... Errors-To: robot@example.org + ... + ... This is a test. + ... """) + + >>> bulk.deliver(mlist, msg, msgdata) + >>> message = list(smtpd.messages)[0] + >>> print message.as_string() + From: bperson@example.org + To: test@example.com + Subject: test two + Message-ID: + Sender: test-bounces@example.com + Errors-To: test-bounces@example.com + X-Peer: ... + X-MailFrom: foo@example.com + X-RcptTo: aperson@example.com + + This is a test. + + >>> config.pop('site-owner') -- cgit v1.3.1 From cac646019303ffe85cfac4c00eca7d44f634a03d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sat, 31 Oct 2009 14:12:19 -0400 Subject: IMailTransportAgentDelivery.deliver() returns a dictionary just like SMTP.sendmail(). Handle SMTPRecipientsRefused just like smtp_direct.py. Hack the test mail server to be able to generate failures. SMTP responses must be bytes (don't forget we're using unicode literals). --- src/mailman/interfaces/mta.py | 2 ++ src/mailman/mta/bulk.py | 15 ++++++++-- src/mailman/mta/docs/bulk.txt | 65 ++++++++++++++++++++++++++++++++++++++++--- src/mailman/testing/mta.py | 44 +++++++++++++++++++++++++---- 4 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/mailman/interfaces/mta.py b/src/mailman/interfaces/mta.py index f8dcb64b3..da0943e25 100644 --- a/src/mailman/interfaces/mta.py +++ b/src/mailman/interfaces/mta.py @@ -67,4 +67,6 @@ class IMailTransportAgentDelivery(Interface): :type msg: `Message` :param msgdata: Additional message metadata for this delivery. :type msgdata: dictionary + :return: delivery failures as defined by `smtplib.SMTP.sendmail` + :rtype: dictionary """ diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index ff00f3d20..21bbd1713 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -25,6 +25,9 @@ __all__ = [ ] +import logging +import smtplib + from itertools import chain from zope.interface import implements @@ -34,6 +37,8 @@ from mailman.interfaces.mta import IMailTransportAgentDelivery from mailman.mta.connection import Connection +log = logging.getLogger('mailman.smtp') + # A mapping of top-level domains to bucket numbers. The zeroth bucket is # reserved for everything else. At one time, these were the most common # domains. @@ -134,6 +139,12 @@ class BulkDelivery: else mlist.bounces_address) msg['Sender'] = sender msg['Errors-To'] = sender + message_id = msg['message-id'] for recipients in self.chunkify(msgdata['recipients']): - self._connection.sendmail( - 'foo@example.com', recipients, msg.as_string()) + try: + refused = self._connection.sendmail( + sender, recipients, msg.as_string()) + except smtplib.SMTPRecipientsRefused as error: + log.error('%s recipients refused: %s', message_id, error) + refused = error.recipients + return refused diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index a612e8ac9..fabc265b6 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -165,6 +165,8 @@ message sent, with all the recipients packed into the envelope recipients >>> recipients = set('person_{0:02d}'.format(i) for i in range(100)) >>> msgdata = dict(recipients=recipients) >>> bulk.deliver(mlist, msg, msgdata) + {} + >>> messages = list(smtpd.messages) >>> len(messages) 1 @@ -190,6 +192,8 @@ each with 20 addresses in the RCPT TO header. >>> bulk = BulkDelivery(20) >>> bulk.deliver(mlist, msg, msgdata) + {} + >>> messages = list(smtpd.messages) >>> len(messages) 5 @@ -231,6 +235,8 @@ message metadata... >>> msgdata = dict(recipients=recipients, ... sender='asender@example.org') >>> bulk.deliver(mlist, msg, msgdata) + {} + >>> message = list(smtpd.messages)[0] >>> print message.as_string() From: aperson@example.org @@ -240,7 +246,7 @@ message metadata... Sender: asender@example.org Errors-To: asender@example.org X-Peer: ... - X-MailFrom: foo@example.com + X-MailFrom: asender@example.org X-RcptTo: aperson@example.com This is a test. @@ -249,6 +255,8 @@ message metadata... >>> del msgdata['sender'] >>> bulk.deliver(mlist, msg, msgdata) + {} + >>> message = list(smtpd.messages)[0] >>> print message.as_string() From: aperson@example.org @@ -258,7 +266,7 @@ message metadata... Sender: test-bounces@example.com Errors-To: test-bounces@example.com X-Peer: ... - X-MailFrom: foo@example.com + X-MailFrom: test-bounces@example.com X-RcptTo: aperson@example.com This is a test. @@ -272,6 +280,8 @@ message. ... """) >>> bulk.deliver(None, msg, msgdata) + {} + >>> message = list(smtpd.messages)[0] >>> print message.as_string() From: aperson@example.org @@ -281,7 +291,7 @@ message. Sender: site-owner@example.com Errors-To: site-owner@example.com X-Peer: ... - X-MailFrom: foo@example.com + X-MailFrom: site-owner@example.com X-RcptTo: aperson@example.com This is a test. @@ -301,6 +311,8 @@ deleted first. ... """) >>> bulk.deliver(mlist, msg, msgdata) + {} + >>> message = list(smtpd.messages)[0] >>> print message.as_string() From: bperson@example.org @@ -310,9 +322,54 @@ deleted first. Sender: test-bounces@example.com Errors-To: test-bounces@example.com X-Peer: ... - X-MailFrom: foo@example.com + X-MailFrom: test-bounces@example.com X-RcptTo: aperson@example.com This is a test. >>> config.pop('site-owner') + + +Delivery failures +================= + +Mailman does not do final delivery. Instead, it sends mail through a site +local mail server which manages queuing and final delivery. However, even +this local mail server can produce delivery failures visible to Mailman in +certain situations. + +For example, something could be seriously wrong with the mail server and it +could refuse delivery to all recipients. + + # Tell the mail server to fail on the next 3 RCPT TO commands, one for + # each recipient in the following message. + >>> smtpd.err_queue.put('rcpt') + >>> smtpd.err_queue.put('rcpt') + >>> smtpd.err_queue.put('rcpt') + + >>> recipients = set([ + ... 'aperson@example.org', + ... 'bperson@example.org', + ... 'cperson@example.org', + ... ]) + >>> msgdata = dict(recipients=recipients) + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: test three + ... Message-ID: + ... + ... This is a test. + ... """) + + >>> failures = bulk.deliver(mlist, msg, msgdata) + >>> for address in sorted(failures): + ... print address, failures[address][0], failures[address][1] + aperson@example.org 500 Error: SMTPRecipientsRefused + bperson@example.org 500 Error: SMTPRecipientsRefused + cperson@example.org 500 Error: SMTPRecipientsRefused + + >>> messages = list(smtpd.messages) + >>> len(messages) + 0 diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index 6e0ee45b9..81852a24b 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -27,7 +27,7 @@ __all__ = [ import logging -from Queue import Queue +from Queue import Empty, Queue from lazr.smtptest.controller import QueueController from lazr.smtptest.server import Channel, QueueServer @@ -62,20 +62,51 @@ class StatisticsChannel(Channel): def smtp_STAT(self, arg): """Cause the server to send statistics to its controller.""" self._server.send_statistics() - self.push('250 Ok') + self.push(b'250 Ok') + + def smtp_RCPT(self, arg): + """For testing, sometimes cause a non-25x response.""" + if self._server.next_error == 'rcpt': + # The test suite wants this to fail. The message corresponds to + # the exception we expect smtplib.SMTP to raise. + self.push(b'500 Error: SMTPRecipientsRefused') + else: + # Everything's cool. + Channel.smtp_RCPT(self, arg) class ConnectionCountingServer(QueueServer): """Count the number of SMTP connections opened.""" - def __init__(self, host, port, queue, oob_queue): - """See `lazr.smtptest.server.QueueServer`.""" + def __init__(self, host, port, queue, oob_queue, err_queue): + """See `lazr.smtptest.server.QueueServer`. + + :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 # 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 + + @property + def next_error(self): + """Return the next error, or None if nothing's on the stack. + + :return: The next SMTP command that should error. + :rtype: string (lower cased) or None + """ + try: + return self._err_queue.get_nowait() + except Empty: + return None def handle_accept(self): """See `lazr.smtp.server.Server`.""" @@ -103,12 +134,13 @@ class ConnectionCountingController(QueueController): def __init__(self, host, port): """See `lazr.smtptest.controller.QueueController`.""" self.oob_queue = Queue() + self.err_queue = Queue() QueueController.__init__(self, host, port) def _make_server(self, host, port): """See `lazr.smtptest.controller.QueueController`.""" self.server = ConnectionCountingServer( - host, port, self.queue, self.oob_queue) + host, port, self.queue, self.oob_queue, self.err_queue) def start(self): """See `lazr.smtptest.controller.QueueController`.""" @@ -124,7 +156,7 @@ class ConnectionCountingController(QueueController): :rtype: integer """ smtpd = self._connect() - smtpd.docmd('STAT') + smtpd.docmd(b'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) -- cgit v1.3.1 From 6ec26d074d923fa83b65b96c4904459d777781f9 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sat, 31 Oct 2009 15:14:37 -0400 Subject: Handle SMTPResponseExceptions like smtp_direct.py, but without the distinction between temporary and permanent failures. That will happen at a higher level. --- src/mailman/mta/bulk.py | 6 +++++ src/mailman/mta/docs/bulk.txt | 34 +++++++++++++++++++++++----- src/mailman/testing/mta.py | 52 ++++++++++++++++++++++++++++++++----------- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index 21bbd1713..be740c9cd 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -147,4 +147,10 @@ class BulkDelivery: except smtplib.SMTPRecipientsRefused as error: log.error('%s recipients refused: %s', message_id, error) refused = error.recipients + except smtplib.SMTPResponseException as error: + log.error('%s response exception: %s', message_id, error) + refused = dict( + # recipient -> (code, error) + (recipient, (error.smtp_code, error.smtp_error)) + for recipient in recipients) return refused diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index fabc265b6..bcce7e4c7 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -338,14 +338,14 @@ local mail server which manages queuing and final delivery. However, even this local mail server can produce delivery failures visible to Mailman in certain situations. -For example, something could be seriously wrong with the mail server and it -could refuse delivery to all recipients. +For example, there could be a problem delivering to any of the specified +recipients. # Tell the mail server to fail on the next 3 RCPT TO commands, one for # each recipient in the following message. - >>> smtpd.err_queue.put('rcpt') - >>> smtpd.err_queue.put('rcpt') - >>> smtpd.err_queue.put('rcpt') + >>> smtpd.err_queue.put(('rcpt', 500)) + >>> smtpd.err_queue.put(('rcpt', 500)) + >>> smtpd.err_queue.put(('rcpt', 500)) >>> recipients = set([ ... 'aperson@example.org', @@ -373,3 +373,27 @@ could refuse delivery to all recipients. >>> messages = list(smtpd.messages) >>> len(messages) 0 + +Or there could be some other problem causing an SMTP response failure. + + # Tell the mail server to register a temporary failure on the next MAIL + # FROM command. + >>> smtpd.err_queue.put(('mail', 450)) + + >>> failures = bulk.deliver(mlist, msg, msgdata) + >>> for address in sorted(failures): + ... print address, failures[address][0], failures[address][1] + aperson@example.org 450 Error: SMTPResponseException + bperson@example.org 450 Error: SMTPResponseException + cperson@example.org 450 Error: SMTPResponseException + + # Tell the mail server to register a permanent failure on the next MAIL + # FROM command. + >>> smtpd.err_queue.put(('mail', 500)) + + >>> failures = bulk.deliver(mlist, msg, msgdata) + >>> for address in sorted(failures): + ... print address, failures[address][0], failures[address][1] + aperson@example.org 500 Error: SMTPResponseException + bperson@example.org 500 Error: SMTPResponseException + cperson@example.org 500 Error: SMTPResponseException diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index 81852a24b..d16b9f955 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -66,13 +66,25 @@ class StatisticsChannel(Channel): def smtp_RCPT(self, arg): """For testing, sometimes cause a non-25x response.""" - if self._server.next_error == 'rcpt': + 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(b'500 Error: SMTPRecipientsRefused') - else: + self.push(b'%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_RCPT(self, arg) + 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(b'%d Error: SMTPResponseException' % code) @@ -95,18 +107,32 @@ class ConnectionCountingServer(QueueServer): # controller upon request. self._oob_queue = oob_queue self._err_queue = err_queue + self._last_error = None - @property - def next_error(self): - """Return the next error, or None if nothing's on the stack. + def next_error(self, command): + """Return the next error for the SMTP command, if there is one. - :return: The next SMTP command that should error. - :rtype: string (lower cased) or None + :param command: The SMTP command for which an error might be + expected. If the next error matches the given command, the + expected error code is returned. + :type command: string, lower-cased + :return: An SMTP error code + :rtype: integer """ - try: - return self._err_queue.get_nowait() - except Empty: - return None + # If the last error we pulled from the queue didn't match, then we're + # caching it, and it might match this expected error. If there is no + # last error in the cache, get one from the queue now. + if self._last_error is None: + try: + self._last_error = self._err_queue.get_nowait() + except Empty: + # No error is expected + return None + if self._last_error[0] == command: + code = self._last_error[1] + self._last_error = None + return code + return None def handle_accept(self): """See `lazr.smtp.server.Server`.""" -- cgit v1.3.1 From 4f2ca4cb900ab891ebbbec56bd30b737304a3da7 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sat, 31 Oct 2009 15:23:14 -0400 Subject: Copy slightly modified code from smtp_direct.py for handling low level error. Sadly, punting on tests for this for now. --- src/mailman/mta/bulk.py | 11 +++++++++++ src/mailman/mta/docs/bulk.txt | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index be740c9cd..13a6fb369 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -153,4 +153,15 @@ class BulkDelivery: # recipient -> (code, error) (recipient, (error.smtp_code, error.smtp_error)) for recipient in recipients) + except (socket.error, IOError, smtplib.SMTPException) as error: + # MTA not responding, or other socket problems, or any other + # kind of SMTPException. In that case, nothing got delivered, + # so treat this as a temporary failure. We use error code 444 + # for this (temporary, unspecified failure, cf RFC 5321). + log.error('%s low level smtp error: %s', message_id, error) + error = str(error) + refused = dict( + # recipient -> (code, error) + (recipient, (444, error)) + for recipient in recipients) return refused diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index bcce7e4c7..f1533ffe8 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -129,7 +129,7 @@ The next chunk will contain the .us (2) and .ca (1) domains. The final chunk will contain the outliers, .xx (1) and .zz (2). >>> len(chunks[5]) - 2 + 2 >>> for address in sorted(chunks[5]): ... print address paco@example.xx @@ -397,3 +397,5 @@ Or there could be some other problem causing an SMTP response failure. aperson@example.org 500 Error: SMTPResponseException bperson@example.org 500 Error: SMTPResponseException cperson@example.org 500 Error: SMTPResponseException + +XXX Untested: socket.error, IOError, smtplib.SMTPException. -- cgit v1.3.1 From c05caff08e5229e7f16ca02aaa4eed78a74a4999 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 1 Nov 2009 12:20:10 -0500 Subject: Refactor BulkDeliver so that the guts can be used in VERPDeliver. Rename the substitution variables in verp_format. --- src/mailman/config/schema.cfg | 19 +++---- src/mailman/mta/base.py | 127 +++++++++++++++++++++++++++++++++++++++++ src/mailman/mta/bulk.py | 78 ++++--------------------- src/mailman/mta/docs/bulk.txt | 4 ++ src/mailman/mta/docs/verp.txt | 129 ++++++++++++++++++++++++++++++++++++++++++ src/mailman/mta/verp.py | 84 +++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 76 deletions(-) create mode 100644 src/mailman/mta/base.py create mode 100644 src/mailman/mta/docs/verp.txt create mode 100644 src/mailman/mta/verp.py diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 1520c4726..b0e7d52d9 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -306,11 +306,10 @@ delivery_retry_period: 5d # # http://cr.yp.to/proto/verp.txt # -# This involves encoding the address of the recipient as we (Mailman) know it -# into the envelope sender address (i.e. the SMTP `MAIL FROM:' address). -# Thus, no matter what kind of forwarding the recipient has in place, should -# it eventually bounce, we will receive an unambiguous notice of the bouncing -# address. +# This involves encoding the address of the recipient as Mailman knows it into +# the envelope sender address (i.e. RFC 5321 MAIL FROM). Thus, no matter what +# kind of forwarding the recipient has in place, should it eventually bounce, +# we will receive an unambiguous notice of the bouncing address. # # However, we're technically only "VERP-like" because we're doing the envelope # sender encoding in Mailman, not in the MTA. We do require cooperation from @@ -318,11 +317,11 @@ delivery_retry_period: 5d # semantics. # # The first variable describes how to encode VERP envelopes. It must contain -# these three string interpolations: +# these three string interpolations, which get filled in by Mailman: # -# $bounces -- the list-bounces mailbox will be set here -# $mailbox -- the recipient's mailbox will be set here -# $host -- the recipient's host name will be set here +# $bounces -- the list's -bounces robot address will be set here +# $local -- the recipient address's local mailbox part will be set here +# $domain -- the recipient address's domain name will be set here # # This example uses the default below. # @@ -334,7 +333,7 @@ delivery_retry_period: 5d # Note that your MTA /must/ be configured to deliver such an addressed message # to mylist-bounces! verp_delimiter: + -verp_format: ${bounces}+${mailbox}=${host} +verp_format: ${bounces}+${local}=${domain} # For nicer confirmation emails, use a VERP-like format which encodes the # confirmation cookie in the reply address. This lets us put a more user diff --git a/src/mailman/mta/base.py b/src/mailman/mta/base.py new file mode 100644 index 000000000..5942386a6 --- /dev/null +++ b/src/mailman/mta/base.py @@ -0,0 +1,127 @@ +# Copyright (C) 2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Base delivery class.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'BaseDelivery', + ] + + +import logging +import smtplib + +from zope.interface import implements + +from mailman.config import config +from mailman.interfaces.mta import IMailTransportAgentDelivery +from mailman.mta.connection import Connection + + +log = logging.getLogger('mailman.smtp') + + + +class BaseDelivery: + """Base delivery class.""" + + implements(IMailTransportAgentDelivery) + + def __init__(self, max_recipients=None): + """Create a basic deliverer. + + :param max_recipients: The maximum number of recipients per delivery + chunk. None, zero or less means to group all recipients into one + big chunk. + :type max_recipients: integer + """ + self._max_recipients = (max_recipients + if max_recipients is not None + else 0) + self._connection = Connection( + config.mta.smtp_host, int(config.mta.smtp_port), + self._max_recipients) + + def _deliver_to_recipients(self, mlist, msg, msgdata, + sender, recipients): + """Low-level delivery to a set of recipients. + + :param mlist: The mailing list being delivered to. + :type mlist: `IMailingList` + :param msg: The original message being delivered. + :type msg: `Message` + :param msgdata: Additional message metadata for this delivery. + :type msgdata: dictionary + :param sender: The envelope sender. + :type sender: string + :param recipients: The recipients of this message. + :type recipients: sequence + :return: delivery failures as defined by `smtplib.SMTP.sendmail` + :rtype: dictionary + """ + message_id = msg['message-id'] + try: + refused = self._connection.sendmail( + sender, recipients, msg.as_string()) + except smtplib.SMTPRecipientsRefused as error: + log.error('%s recipients refused: %s', message_id, error) + refused = error.recipients + except smtplib.SMTPResponseException as error: + log.error('%s response exception: %s', message_id, error) + refused = dict( + # recipient -> (code, error) + (recipient, (error.smtp_code, error.smtp_error)) + for recipient in recipients) + except (socket.error, IOError, smtplib.SMTPException) as error: + # MTA not responding, or other socket problems, or any other + # kind of SMTPException. In that case, nothing got delivered, + # so treat this as a temporary failure. We use error code 444 + # for this (temporary, unspecified failure, cf RFC 5321). + log.error('%s low level smtp error: %s', message_id, error) + error = str(error) + refused = dict( + # recipient -> (code, error) + (recipient, (444, error)) + for recipient in recipients) + return refused + + def _get_sender(self, mlist, msg, msgdata): + """Return the envelope sender to use. + + The message metadata can override the calculation of the sender, but + otherwise it falls to the list's -bounces robot. If this message is + not intended for any specific mailing list, the site owner's address + is used. + + :param mlist: The mailing list being delivered to. + :type mlist: `IMailingList` + :param msg: The original message being delivered. + :type msg: `Message` + :param msgdata: Additional message metadata for this delivery. + :type msgdata: dictionary + :return: The envelope sender. + :rtype: string + """ + sender = msgdata.get('sender') + if sender is None: + return (config.mailman.site_owner + if mlist is None + else mlist.bounces_address) + return sender diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index 13a6fb369..e516594d4 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see . -"""Module stuff.""" +"""Bulk message delivery.""" from __future__ import absolute_import, unicode_literals @@ -25,19 +25,8 @@ __all__ = [ ] -import logging -import smtplib +from mailman.mta.base import BaseDelivery -from itertools import chain - -from zope.interface import implements - -from mailman.config import config -from mailman.interfaces.mta import IMailTransportAgentDelivery -from mailman.mta.connection import Connection - - -log = logging.getLogger('mailman.smtp') # A mapping of top-level domains to bucket numbers. The zeroth bucket is # reserved for everything else. At one time, these were the most common @@ -53,25 +42,8 @@ CHUNKMAP = dict( -class BulkDelivery: - """Deliver messages to the MTA in as few sessions as possible.""" - - implements(IMailTransportAgentDelivery) - - def __init__(self, max_recipients=None): - """Create a bulk deliverer. - - :param max_recipients: The maximum number of recipients per delivery - chunk. None, zero or less means to group all recipients into one - big chunk. - :type max_recipients: integer - """ - self._max_recipients = (max_recipients - if max_recipients is not None - else 0) - self._connection = Connection( - config.mta.smtp_host, int(config.mta.smtp_port), - self._max_recipients) +class BulkDelivery(BaseDelivery): + """Deliver messages to the MSA in as few sessions as possible.""" def chunkify(self, recipients): """Split a set of recipients into chunks. @@ -118,7 +90,8 @@ class BulkDelivery: def deliver(self, mlist, msg, msgdata): """See `IMailTransportAgentDelivery`.""" recipients = msgdata.get('recipients') - if recipients is None: + if not recipients: + # Could be None, could be an empty sequence. return # Blow away any existing Sender and Errors-To headers and substitute # our own. Our interpretation of RFC 5322 $3.6.2 is that Mailman is @@ -126,42 +99,15 @@ class BulkDelivery: # because what we send to list members is different than what the # original author sent. RFC 2076 says Errors-To is "non-standard, # discouraged" but we include it for historical purposes. + sender = self._get_sender(mlist, msg, msgdata) del msg['sender'] del msg['errors-to'] - # The message metadata can override the calculation of the sender, but - # otherwise it falls to the list's -bounces robot. If this message is - # not intended for any specific mailing list, the site owner's address - # is used. - sender = msgdata.get('sender') - if sender is None: - sender = (config.mailman.site_owner - if mlist is None - else mlist.bounces_address) msg['Sender'] = sender msg['Errors-To'] = sender - message_id = msg['message-id'] + refused = {} for recipients in self.chunkify(msgdata['recipients']): - try: - refused = self._connection.sendmail( - sender, recipients, msg.as_string()) - except smtplib.SMTPRecipientsRefused as error: - log.error('%s recipients refused: %s', message_id, error) - refused = error.recipients - except smtplib.SMTPResponseException as error: - log.error('%s response exception: %s', message_id, error) - refused = dict( - # recipient -> (code, error) - (recipient, (error.smtp_code, error.smtp_error)) - for recipient in recipients) - except (socket.error, IOError, smtplib.SMTPException) as error: - # MTA not responding, or other socket problems, or any other - # kind of SMTPException. In that case, nothing got delivered, - # so treat this as a temporary failure. We use error code 444 - # for this (temporary, unspecified failure, cf RFC 5321). - log.error('%s low level smtp error: %s', message_id, error) - error = str(error) - refused = dict( - # recipient -> (code, error) - (recipient, (444, error)) - for recipient in recipients) + chunk_refused = self._deliver_to_recipients( + mlist, msg, msgdata, sender, recipients) + refused.update(chunk_refused) return refused + diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index f1533ffe8..e40bdce16 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -158,6 +158,10 @@ there are no calculated recipients, nothing gets sent. >>> len(list(smtpd.messages)) 0 + >>> bulk.deliver(mlist, msg, dict(recipients=set())) + >>> len(list(smtpd.messages)) + 0 + With bulk delivery and no maximum number of recipients, there will be just one message sent, with all the recipients packed into the envelope recipients (i.e. RCTP TO). diff --git a/src/mailman/mta/docs/verp.txt b/src/mailman/mta/docs/verp.txt new file mode 100644 index 000000000..ac8d70e29 --- /dev/null +++ b/src/mailman/mta/docs/verp.txt @@ -0,0 +1,129 @@ +====================== +Standard VERP delivery +====================== + +'VERP' delivery is an alternative to bulk_ delivery, where an individual +message is crafted uniquely for each recipient. Our use of the term `VERP`_ +is actually incorrect, since the term is narrowly defined by the technique of +setting the return path so that bounces can be unambiguously decoded. + +.. _bulk: bulk.txt +.. _VERP: http://en.wikipedia.org/wiki/Variable_envelope_return_path + +The cost of enabling VERP is that Mailman must send to the upstream MTA, one +message per recipient. Under bulk delivery, an exact copy of one message can +be sent to many recipients, greatly reducing the bandwidth for delivery. + +In Mailman, enabling VERP delivery for bounce detection brings with it a side +benefit: the message which must be crafted uniquely for each recipient, can be +further personalized to include all kinds of information unique to that +recipient. In the simplest case, the message can contain footer information, +e.g. pointing the user to their account URL or including a user-specific +unsubscription link. In theory, VERP delivery means we can do sophisticated +`mail merge`_ operations. + +.. _`mail merge`: http://en.wikipedia.org/wiki/Mail_merge + +Mailman's use of the term VERP really means "message personalization". + + >>> from mailman.mta.verp import VERPDelivery + >>> verp = VERPDelivery() + +Delivery strategies must implement the proper interface. + + >>> from mailman.interfaces.mta import IMailTransportAgentDelivery + >>> from zope.interface.verify import verifyObject + >>> verifyObject(IMailTransportAgentDelivery, verp) + True + + +No recipients +============= + +The message metadata specifies the set of recipients to send this message to. +If there are no recipients, there's nothing to do. + + >>> smtpd.clear() + >>> mlist = create_list('test@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: test one + ... Message-ID: + ... + ... This is a test. + ... """) + + >>> verp.deliver(mlist, msg, {}) + >>> len(list(smtpd.messages)) + 0 + + >>> verp.deliver(mlist, msg, dict(recipients=set())) + >>> len(list(smtpd.messages)) + 0 + + +Individual copy +=============== + +Each recipient of the message gets an individual, personalized copy of the +message, with their email address encoded into the envelope sender. This is +so the return path will point back to Mailman but allow for decoding of the +intended recipient's delivery address. + + >>> recipients = set([ + ... 'aperson@example.com', + ... 'bperson@example.com', + ... 'cperson@example.com', + ... ]) + + >>> verp.deliver(mlist, msg, dict(recipients=recipients)) + >>> messages = list(smtpd.messages) + >>> len(messages) + 3 + + >>> from operator import itemgetter + >>> for message in sorted(messages, key=itemgetter('x-rcptto')): + ... print message.as_string() + ... print '----------' + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+aperson=example.com@example.com + X-RcptTo: aperson@example.com + + This is a test. + ---------- + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+bperson=example.com@example.com + X-RcptTo: bperson@example.com + + This is a test. + ---------- + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+cperson=example.com@example.com + X-RcptTo: cperson@example.com + + This is a test. + ---------- + +The deliverer made a copy of the original message, so it wasn't changed. + + >>> print msg.as_string() + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + + This is a test. + diff --git a/src/mailman/mta/verp.py b/src/mailman/mta/verp.py new file mode 100644 index 000000000..be44afd10 --- /dev/null +++ b/src/mailman/mta/verp.py @@ -0,0 +1,84 @@ +# Copyright (C) 2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""VERP (i.e. personalized) message delivery.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'VERPDelivery', + ] + + +import copy +import logging + +from mailman.config import config +from mailman.email.utils import split_email +from mailman.mta.base import BaseDelivery +from mailman.utilities.string import expand + + +DOT = '.' +log = logging.getLogger('mailman.smtp') + + + +class VERPDelivery(BaseDelivery): + """Deliver a unique message to the MSA for each recipient.""" + + def deliver(self, mlist, msg, msgdata): + """See `IMailTransportAgentDelivery`. + + Craft a unique message for every recipient. Encode the recipient's + delivery address in the return envelope so there can be no ambiguity + in bounce processing. + """ + recipients = msgdata.get('recipients') + if not recipients: + # Could be None, could be an empty sequence. + return + sender = self._get_sender(mlist, msg, msgdata) + sender_mailbox, sender_domain = split_email(sender) + for recipient in recipients: + # Make a copy of the original messages and operator on it, since + # we're going to munge it repeatedly for each recipient. + message_copy = copy.deepcopy(msg) + # Encode the recipient's address for VERP. + recipient_mailbox, recipient_domain = split_email(recipient) + if recipient_domain is None: + # The recipient address is not fully-qualified. We can't + # deliver it to this person, nor can we craft a valid verp + # header. I don't think there's much we can do except ignore + # this recipient. + log.info('Skipping VERP delivery to unqual recip: %s', recip) + continue + verp_sender = '{0}@{1}'.format( + expand(config.mta.verp_format, dict( + bounces=sender_mailbox, + local=recipient_mailbox, + domain=DOT.join(recipient_domain))), + DOT.join(sender_domain)) + # We can flag the mail as a duplicate for each member, if they've + # already received this message, as calculated by Message-ID. See + # AvoidDuplicates.py for details. + del message_copy['x-mailman-copy'] + if recipient in msgdata.get('add-dup-header', {}): + message_copy['X-Mailman-Copy'] = 'yes' + self._deliver_to_recipients(mlist, msg, msgdata, + verp_sender, [recipient]) -- cgit v1.3.1 From 0f03599f841388c066232cdbfd417d69e57b312d Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 1 Nov 2009 15:23:56 -0500 Subject: Add full personalization delivery mechanism. --- src/mailman/mta/docs/personalized.txt | 137 ++++++++++++++++++++++++++++++++++ src/mailman/mta/docs/verp.txt | 9 +-- src/mailman/mta/personalized.py | 59 +++++++++++++++ src/mailman/mta/verp.py | 9 ++- 4 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 src/mailman/mta/docs/personalized.txt create mode 100644 src/mailman/mta/personalized.py diff --git a/src/mailman/mta/docs/personalized.txt b/src/mailman/mta/docs/personalized.txt new file mode 100644 index 000000000..c1c590479 --- /dev/null +++ b/src/mailman/mta/docs/personalized.txt @@ -0,0 +1,137 @@ +=========================== +Fully personalized delivery +=========================== + +Fully personalized mail delivery is an enhancement over VERP_ delivery where +the To field of the message is replaced with the recipient's address. A +typical email message is sent to the mailing list's posting address and copied +to the list membership that way. Some people like the more personal address. + +.. _VERP: verp.txt + +Personalized delivery still does VERP. + + >>> from mailman.mta.personalized import PersonalizedDelivery + >>> personalized = PersonalizedDelivery() + +Delivery strategies must implement the proper interface. + + >>> from mailman.interfaces.mta import IMailTransportAgentDelivery + >>> from zope.interface.verify import verifyObject + >>> verifyObject(IMailTransportAgentDelivery, personalized) + True + + +To header +========= + +The To header is replaced with the recipient's address and name. + + >>> mlist = create_list('test@example.com') + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: test one + ... Message-ID: + ... + ... This is a test. + ... """) + + >>> recipients = set([ + ... 'aperson@example.com', + ... 'bperson@example.com', + ... 'cperson@example.com', + ... ]) + + >>> personalized.deliver(mlist, msg, dict(recipients=recipients)) + {} + >>> messages = list(smtpd.messages) + >>> len(messages) + 3 + + >>> from operator import itemgetter + >>> for message in sorted(messages, key=itemgetter('to')): + ... print message.as_string() + ... print '----------' + From: aperson@example.org + To: aperson@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+aperson=example.com@example.com + X-RcptTo: aperson@example.com + + This is a test. + ---------- + From: aperson@example.org + To: bperson@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+bperson=example.com@example.com + X-RcptTo: bperson@example.com + + This is a test. + ---------- + From: aperson@example.org + To: cperson@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+cperson=example.com@example.com + X-RcptTo: cperson@example.com + + This is a test. + ---------- + +If the recipient is a user registered with Mailman, and the user has an +associated real name, then this name also shows up in the To header. + + >>> from zope.component import getUtility + >>> from mailman.interfaces.usermanager import IUserManager + >>> user_manager = getUtility(IUserManager) + + >>> bill = user_manager.create_user('bperson@example.com', 'Bill Person') + >>> cate = user_manager.create_user('cperson@example.com', 'Cate Person') + >>> transaction.commit() + + >>> personalized.deliver(mlist, msg, dict(recipients=recipients)) + {} + >>> messages = list(smtpd.messages) + >>> len(messages) + 3 + + >>> from operator import itemgetter + >>> for message in sorted(messages, key=itemgetter('x-rcptto')): + ... print message.as_string() + ... print '----------' + From: aperson@example.org + To: aperson@example.com + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+aperson=example.com@example.com + X-RcptTo: aperson@example.com + + This is a test. + ---------- + From: aperson@example.org + To: Bill Person + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+bperson=example.com@example.com + X-RcptTo: bperson@example.com + + This is a test. + ---------- + From: aperson@example.org + To: Cate Person + Subject: test one + Message-ID: + X-Peer: ... + X-MailFrom: test-bounces+cperson=example.com@example.com + X-RcptTo: cperson@example.com + + This is a test. + ---------- diff --git a/src/mailman/mta/docs/verp.txt b/src/mailman/mta/docs/verp.txt index ac8d70e29..11157d363 100644 --- a/src/mailman/mta/docs/verp.txt +++ b/src/mailman/mta/docs/verp.txt @@ -2,13 +2,11 @@ Standard VERP delivery ====================== -'VERP' delivery is an alternative to bulk_ delivery, where an individual -message is crafted uniquely for each recipient. Our use of the term `VERP`_ -is actually incorrect, since the term is narrowly defined by the technique of -setting the return path so that bounces can be unambiguously decoded. +Variable Envelope Return Path (VERP_) delivery is an alternative to bulk_ +delivery, where an individual message is crafted uniquely for each recipient. -.. _bulk: bulk.txt .. _VERP: http://en.wikipedia.org/wiki/Variable_envelope_return_path +.. _bulk: bulk.txt The cost of enabling VERP is that Mailman must send to the upstream MTA, one message per recipient. Under bulk delivery, an exact copy of one message can @@ -78,6 +76,7 @@ intended recipient's delivery address. ... ]) >>> verp.deliver(mlist, msg, dict(recipients=recipients)) + {} >>> messages = list(smtpd.messages) >>> len(messages) 3 diff --git a/src/mailman/mta/personalized.py b/src/mailman/mta/personalized.py new file mode 100644 index 000000000..99d939096 --- /dev/null +++ b/src/mailman/mta/personalized.py @@ -0,0 +1,59 @@ +# Copyright (C) 2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Personalized delivery.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'PersonalizedDelivery', + ] + + +from email.header import Header +from email.utils import formataddr +from zope.component import getUtility + +from mailman.interfaces.usermanager import IUserManager +from mailman.mta.verp import VERPDelivery + + + +class PersonalizedDelivery(VERPDelivery): + """Personalize the message's To header.""" + + def _deliver_to_recipients(self, mlist, msg, msgdata, + sender, recipients): + """See `BaseDelivery`.""" + # This module only works with VERP delivery. + assert len(recipients) == 1, 'Single recipient is required' + # Try to find the real name for the recipient email address, if the + # address is associated with a user registered with Mailman. + recipient = recipients[0] + user_manager = getUtility(IUserManager) + user = user_manager.get_user(recipient) + if user is None: + msg.replace_header('To', recipient) + else: + # Convert the unicode name to an email-safe representation. + # Create a Header instance for the name so that it's properly + # encoded for email transport. + name = Header(user.real_name).encode() + msg.replace_header('To', formataddr((name, recipient))) + return super(PersonalizedDelivery, self)._deliver_to_recipients( + mlist, msg, msgdata, sender, recipients) diff --git a/src/mailman/mta/verp.py b/src/mailman/mta/verp.py index be44afd10..a8cc9d937 100644 --- a/src/mailman/mta/verp.py +++ b/src/mailman/mta/verp.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see . -"""VERP (i.e. personalized) message delivery.""" +"""VERP delivery.""" from __future__ import absolute_import, unicode_literals @@ -55,6 +55,7 @@ class VERPDelivery(BaseDelivery): return sender = self._get_sender(mlist, msg, msgdata) sender_mailbox, sender_domain = split_email(sender) + refused = {} for recipient in recipients: # Make a copy of the original messages and operator on it, since # we're going to munge it repeatedly for each recipient. @@ -80,5 +81,7 @@ class VERPDelivery(BaseDelivery): del message_copy['x-mailman-copy'] if recipient in msgdata.get('add-dup-header', {}): message_copy['X-Mailman-Copy'] = 'yes' - self._deliver_to_recipients(mlist, msg, msgdata, - verp_sender, [recipient]) + recipient_refused = self._deliver_to_recipients( + mlist, msg, msgdata, verp_sender, [recipient]) + refused.update(recipient_refused) + return refused -- cgit v1.3.1 From b2a308fe129c59bd8b4732907e55133807c2d8d9 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 1 Nov 2009 18:21:20 -0500 Subject: More refactoring, this time into a framework based IndividualDelivery module. Refactoring may not yet be complete. --- src/mailman/mta/base.py | 67 +++++++++++++++++++++++----- src/mailman/mta/bulk.py | 13 ++++++ src/mailman/mta/personalized.py | 44 +++++++++++++----- src/mailman/mta/verp.py | 99 +++++++++++++++++++++++------------------ 4 files changed, 157 insertions(+), 66 deletions(-) diff --git a/src/mailman/mta/base.py b/src/mailman/mta/base.py index 5942386a6..48a3cd8f4 100644 --- a/src/mailman/mta/base.py +++ b/src/mailman/mta/base.py @@ -22,9 +22,11 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'BaseDelivery', + 'IndividualDelivery', ] +import copy import logging import smtplib @@ -44,20 +46,11 @@ class BaseDelivery: implements(IMailTransportAgentDelivery) - def __init__(self, max_recipients=None): - """Create a basic deliverer. - - :param max_recipients: The maximum number of recipients per delivery - chunk. None, zero or less means to group all recipients into one - big chunk. - :type max_recipients: integer - """ - self._max_recipients = (max_recipients - if max_recipients is not None - else 0) + def __init__(self): + """Create a basic deliverer.""" self._connection = Connection( config.mta.smtp_host, int(config.mta.smtp_port), - self._max_recipients) + int(config.mta.max_sessions_per_connection)) def _deliver_to_recipients(self, mlist, msg, msgdata, sender, recipients): @@ -125,3 +118,53 @@ class BaseDelivery: if mlist is None else mlist.bounces_address) return sender + + + +class IndividualDelivery(BaseDelivery): + """Deliver a unique individual message to each recipient. + + This is a framework delivery mechanism. By using mixins, registration, + and subclassing you can customize this delivery class to do any + combination of VERP, full personalization, individualized header/footer + decoration and even full mail merging. + + The core concept here is that for each recipient, the deliver() method + iterates over the list of registered callbacks, each of which have a + chance to modify the message before final delivery. + """ + + def __init__(self): + """See `BaseDelivery`.""" + # + super(IndividualDelivery, self).__init__() + self.callbacks = [] + + def deliver(self, mlist, msg, msgdata): + """See `IMailTransportAgentDelivery`. + + Craft a unique message for every recipient. Encode the recipient's + delivery address in the return envelope so there can be no ambiguity + in bounce processing. + """ + recipients = msgdata.get('recipients') + if not recipients: + # Could be None, could be an empty sequence. + return + refused = {} + for recipient in recipients: + # Make a copy of the original messages and operator on it, since + # we're going to munge it repeatedly for each recipient. + message_copy = copy.deepcopy(msg) + msgdata_copy = msgdata.copy() + # Squirrel the current recipient away in the message metadata. + # That way the subclass's _get_sender() override can encode the + # recipient address in the sender, e.g. for VERP. + msgdata_copy['recipient'] = recipient + sender = self._get_sender(mlist, message_copy, msgdata_copy) + for callback in self.callbacks: + callback(mlist, message_copy, msgdata_copy) + status = self._deliver_to_recipients( + mlist, message_copy, msgdata_copy, sender, [recipient]) + refused.update(status) + return refused diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index e516594d4..3247331d3 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -45,6 +45,19 @@ CHUNKMAP = dict( class BulkDelivery(BaseDelivery): """Deliver messages to the MSA in as few sessions as possible.""" + def __init__(self, max_recipients=None): + """See `BaseDelivery`. + + :param max_recipients: The maximum number of recipients per delivery + chunk. None, zero or less means to group all recipients into one + big chunk. + :type max_recipients: integer + """ + super(BulkDelivery, self).__init__() + self._max_recipients = (max_recipients + if max_recipients is not None + else 0) + def chunkify(self, recipients): """Split a set of recipients into chunks. diff --git a/src/mailman/mta/personalized.py b/src/mailman/mta/personalized.py index 99d939096..3c8eba25a 100644 --- a/src/mailman/mta/personalized.py +++ b/src/mailman/mta/personalized.py @@ -22,6 +22,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'PersonalizedDelivery', + 'PersonalizedMixin', ] @@ -34,17 +35,26 @@ from mailman.mta.verp import VERPDelivery -class PersonalizedDelivery(VERPDelivery): - """Personalize the message's To header.""" +class PersonalizedMixin: + """Personalize the message's To header. - def _deliver_to_recipients(self, mlist, msg, msgdata, - sender, recipients): - """See `BaseDelivery`.""" - # This module only works with VERP delivery. - assert len(recipients) == 1, 'Single recipient is required' - # Try to find the real name for the recipient email address, if the - # address is associated with a user registered with Mailman. - recipient = recipients[0] + This is a mixin class, providing the basic functionality for header + personalization. The methods it provides are intended to be called from a + concrete base class. + """ + + def personalize_to(self, msg, recipient): + """Modify the To header to contain the recipient. + + The To header contents is replaced with the recipient's address, and + if the recipient is a user registered with Mailman, the recipient's + real name too. + + :param msg: The message to modify. + :type msg: `email.message.Message` + :param recipient: The recipient's email address. + :type recipient: string + """ user_manager = getUtility(IUserManager) user = user_manager.get_user(recipient) if user is None: @@ -55,5 +65,19 @@ class PersonalizedDelivery(VERPDelivery): # encoded for email transport. name = Header(user.real_name).encode() msg.replace_header('To', formataddr((name, recipient))) + + +class PersonalizedDelivery(VERPDelivery, PersonalizedMixin): + """Personalize the message's To header.""" + + def _deliver_to_recipients(self, mlist, msg, msgdata, + sender, recipients): + """See `BaseDelivery`.""" + # This module only works with VERP delivery. + assert len(recipients) == 1, 'Single recipient is required' + # Try to find the real name for the recipient email address, if the + # address is associated with a user registered with Mailman. + recipient = recipients[0] + self.personalize_to(msg, recipient) return super(PersonalizedDelivery, self)._deliver_to_recipients( mlist, msg, msgdata, sender, recipients) diff --git a/src/mailman/mta/verp.py b/src/mailman/mta/verp.py index a8cc9d937..a46c42cff 100644 --- a/src/mailman/mta/verp.py +++ b/src/mailman/mta/verp.py @@ -22,15 +22,15 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'VERPDelivery', + 'VERPMixin', ] -import copy import logging from mailman.config import config from mailman.email.utils import split_email -from mailman.mta.base import BaseDelivery +from mailman.mta.base import IndividualDelivery from mailman.utilities.string import expand @@ -39,49 +39,60 @@ log = logging.getLogger('mailman.smtp') -class VERPDelivery(BaseDelivery): - """Deliver a unique message to the MSA for each recipient.""" +class VERPMixin: + """Mixin for VERP functionality. - def deliver(self, mlist, msg, msgdata): - """See `IMailTransportAgentDelivery`. + This works by overriding the base class's _get_sender() method to return + the VERP'd envelope sender. It expects the individual recipient's address + to be squirreled away in the message metadata. + """ + def _get_sender(self, mlist, msg, msgdata): + """Return the recipient's address VERP encoded in the sender. - Craft a unique message for every recipient. Encode the recipient's - delivery address in the return envelope so there can be no ambiguity - in bounce processing. + :param mlist: The mailing list being delivered to. + :type mlist: `IMailingList` + :param msg: The original message being delivered. + :type msg: `Message` + :param msgdata: Additional message metadata for this delivery. + :type msgdata: dictionary """ - recipients = msgdata.get('recipients') - if not recipients: - # Could be None, could be an empty sequence. - return - sender = self._get_sender(mlist, msg, msgdata) + recipient = msgdata['recipient'] + sender = super(VERPMixin, self)._get_sender(mlist, msg, msgdata) sender_mailbox, sender_domain = split_email(sender) - refused = {} - for recipient in recipients: - # Make a copy of the original messages and operator on it, since - # we're going to munge it repeatedly for each recipient. - message_copy = copy.deepcopy(msg) - # Encode the recipient's address for VERP. - recipient_mailbox, recipient_domain = split_email(recipient) - if recipient_domain is None: - # The recipient address is not fully-qualified. We can't - # deliver it to this person, nor can we craft a valid verp - # header. I don't think there's much we can do except ignore - # this recipient. - log.info('Skipping VERP delivery to unqual recip: %s', recip) - continue - verp_sender = '{0}@{1}'.format( - expand(config.mta.verp_format, dict( - bounces=sender_mailbox, - local=recipient_mailbox, - domain=DOT.join(recipient_domain))), - DOT.join(sender_domain)) - # We can flag the mail as a duplicate for each member, if they've - # already received this message, as calculated by Message-ID. See - # AvoidDuplicates.py for details. - del message_copy['x-mailman-copy'] - if recipient in msgdata.get('add-dup-header', {}): - message_copy['X-Mailman-Copy'] = 'yes' - recipient_refused = self._deliver_to_recipients( - mlist, msg, msgdata, verp_sender, [recipient]) - refused.update(recipient_refused) - return refused + # Encode the recipient's address for VERP. + recipient_mailbox, recipient_domain = split_email(recipient) + if recipient_domain is None: + # The recipient address is not fully-qualified. We can't + # deliver it to this person, nor can we craft a valid verp + # header. I don't think there's much we can do except ignore + # this recipient. + log.info('Skipping VERP delivery to unqual recip: %s', recip) + return sender + return '{0}@{1}'.format( + expand(config.mta.verp_format, dict( + bounces=sender_mailbox, + local=recipient_mailbox, + domain=DOT.join(recipient_domain))), + DOT.join(sender_domain)) + + + +class VERPDelivery(VERPMixin, IndividualDelivery): + """Deliver a unique message to the MSA for each recipient.""" + + def __init__(self): + """See `IndividualDelivery`.""" + super(VERPDelivery, self).__init__() + self.callbacks.append(self.avoid_duplicates) + + def avoid_duplicates(self, mlist, msg, msgdata): + """Flag the message for duplicate avoidance. + + We can flag the mail as a duplicate for each member, if they've + already received this message, as calculated by Message-ID. See + `AvoidDuplicates.py`_ for details. + """ + recipient = msgdata['recipient'] + del msg['x-mailman-copy'] + if recipient in msgdata.get('add-dup-header', {}): + msg['X-Mailman-Copy'] = 'yes' -- cgit v1.3.1 From d49e812a80fadbd04c377c1954fb99b3d99f95ca Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 1 Nov 2009 18:24:16 -0500 Subject: More refactoring. --- src/mailman/mta/personalized.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/mailman/mta/personalized.py b/src/mailman/mta/personalized.py index 3c8eba25a..6fb73fdba 100644 --- a/src/mailman/mta/personalized.py +++ b/src/mailman/mta/personalized.py @@ -43,18 +43,14 @@ class PersonalizedMixin: concrete base class. """ - def personalize_to(self, msg, recipient): + def personalize_to(self, mlist, msg, msgdata): """Modify the To header to contain the recipient. The To header contents is replaced with the recipient's address, and if the recipient is a user registered with Mailman, the recipient's real name too. - - :param msg: The message to modify. - :type msg: `email.message.Message` - :param recipient: The recipient's email address. - :type recipient: string """ + recipient = msgdata['recipient'] user_manager = getUtility(IUserManager) user = user_manager.get_user(recipient) if user is None: @@ -67,17 +63,10 @@ class PersonalizedMixin: msg.replace_header('To', formataddr((name, recipient))) -class PersonalizedDelivery(VERPDelivery, PersonalizedMixin): +class PersonalizedDelivery(PersonalizedMixin, VERPDelivery): """Personalize the message's To header.""" - def _deliver_to_recipients(self, mlist, msg, msgdata, - sender, recipients): - """See `BaseDelivery`.""" - # This module only works with VERP delivery. - assert len(recipients) == 1, 'Single recipient is required' - # Try to find the real name for the recipient email address, if the - # address is associated with a user registered with Mailman. - recipient = recipients[0] - self.personalize_to(msg, recipient) - return super(PersonalizedDelivery, self)._deliver_to_recipients( - mlist, msg, msgdata, sender, recipients) + def __init__(self): + """See `IndividualDelivery`.""" + super(VERPDelivery, self).__init__() + self.callbacks.append(self.personalize_to) -- cgit v1.3.1 From c9d9f209a26f64bb54ec6e31b9c2d5a8d030b8e0 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Sun, 1 Nov 2009 22:52:14 -0500 Subject: Add decorations. s/recips/recipients/ --- src/mailman/inject.py | 2 +- src/mailman/mta/decorating.py | 50 ++++++++ src/mailman/mta/docs/decorating.txt | 151 +++++++++++++++++++++++++ src/mailman/mta/personalized.py | 3 +- src/mailman/pipeline/avoid_duplicates.py | 4 +- src/mailman/pipeline/decorate.py | 7 +- src/mailman/pipeline/docs/avoid-duplicates.txt | 10 +- src/mailman/pipeline/docs/decorate.txt | 2 +- src/mailman/pipeline/docs/file-recips.txt | 10 +- src/mailman/pipeline/docs/replybot.txt | 4 +- src/mailman/pipeline/file_recipients.py | 6 +- src/mailman/pipeline/owner_recipients.py | 2 +- src/mailman/queue/bounce.py | 2 +- src/mailman/queue/digest.py | 4 +- src/mailman/queue/docs/command.txt | 2 +- src/mailman/queue/docs/digester.txt | 16 +-- src/mailman/queue/docs/incoming.txt | 2 +- src/mailman/queue/outgoing.py | 2 +- 18 files changed, 239 insertions(+), 40 deletions(-) create mode 100644 src/mailman/mta/decorating.py create mode 100644 src/mailman/mta/docs/decorating.txt diff --git a/src/mailman/inject.py b/src/mailman/inject.py index eec096066..d22ae7128 100644 --- a/src/mailman/inject.py +++ b/src/mailman/inject.py @@ -67,7 +67,7 @@ def inject_message(mlist, msg, recips=None, switchboard=None, **kws): ) msgdata.update(kws) if recips is not None: - msgdata['recips'] = recips + msgdata['recipients'] = recips config.switchboards[switchboard].enqueue(msg, **msgdata) diff --git a/src/mailman/mta/decorating.py b/src/mailman/mta/decorating.py new file mode 100644 index 000000000..487fa8a1c --- /dev/null +++ b/src/mailman/mta/decorating.py @@ -0,0 +1,50 @@ +# Copyright (C) 2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see . + +"""Individualized delivery with header/footer decorations.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'DecoratingDelivery', + 'DecoratingMixin', + ] + + +from mailman.config import config +from mailman.mta.verp import VERPDelivery + + + +class DecoratingMixin: + """Decorate a message with recipient-specific headers and footers.""" + + def decorate(self, mlist, msg, msgdata): + """Add recipient-specific headers and footers.""" + decorator = config.handlers['decorate'] + decorator.process(mlist, msg, msgdata) + + + +class DecoratingDelivery(DecoratingMixin, VERPDelivery): + """Add recipient-specific headers and footers.""" + + def __init__(self): + """See `IndividualDelivery`.""" + super(DecoratingDelivery, self).__init__() + self.callbacks.append(self.decorate) diff --git a/src/mailman/mta/docs/decorating.txt b/src/mailman/mta/docs/decorating.txt new file mode 100644 index 000000000..1047a9fb6 --- /dev/null +++ b/src/mailman/mta/docs/decorating.txt @@ -0,0 +1,151 @@ +======================= +Personalized decoration +======================= + +Personalized messages can be decorated by headers and footers containing +information specific to the recipient. + + >>> from mailman.mta.decorating import DecoratingDelivery + >>> decorating = DecoratingDelivery() + +Delivery strategies must implement the proper interface. + + >>> from mailman.interfaces.mta import IMailTransportAgentDelivery + >>> from zope.interface.verify import verifyObject + >>> verifyObject(IMailTransportAgentDelivery, decorating) + True + + +Decorations +=========== + +Decorations are added when the mailing list had a header and/or footer +defined, and the decoration handler is told to do personalized decorations. + + >>> mlist = create_list('test@example.com') + >>> mlist.msg_header = """\ + ... Delivery address: $user_address + ... Subscribed address: $user_delivered_to + ... """ + + >>> mlist.msg_footer = """\ + ... User name: $user_name + ... Password: $user_password + ... Language: $user_language + ... Options: $user_optionsurl + ... """ + + >>> transaction.commit() + + >>> msg = message_from_string("""\ + ... From: aperson@example.org + ... To: test@example.com + ... Subject: test one + ... Message-ID: + ... + ... This is a test. + ... """) + + >>> recipients = set([ + ... 'aperson@example.com', + ... 'bperson@example.com', + ... 'cperson@example.com', + ... ]) + + >>> msgdata = dict( + ... recipients=recipients, + ... personalize=True, + ... ) + +More information is included when the recipient is a member of the mailing +list. + + >>> from zope.component import getUtility + >>> from mailman.interfaces.member import MemberRole + >>> from mailman.interfaces.usermanager import IUserManager + >>> user_manager = getUtility(IUserManager) + + >>> anne = user_manager.create_user('aperson@example.com', 'Anne Person') + >>> anne.password = 'AAA' + >>> list(anne.addresses)[0].subscribe(mlist, MemberRole.member) + ... + + >>> bart = user_manager.create_user('bperson@example.com', 'Bart Person') + >>> bart.password = 'BBB' + >>> list(bart.addresses)[0].subscribe(mlist, MemberRole.member) + ... + + >>> cris = user_manager.create_user('cperson@example.com', 'Cris Person') + >>> cris.password = 'CCC' + >>> list(cris.addresses)[0].subscribe(mlist, MemberRole.member) + ... + +The decorations happen when the message is delivered. + + >>> decorating.deliver(mlist, msg, msgdata) + {} + >>> messages = list(smtpd.messages) + >>> len(messages) + 3 + + >>> from operator import itemgetter + >>> for message in sorted(messages, key=itemgetter('x-rcptto')): + ... print message.as_string() + ... print '----------' + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + X-Peer: ... + X-MailFrom: test-bounces+aperson=example.com@example.com + X-RcptTo: aperson@example.com + + Delivery address: aperson@example.com + Subscribed address: aperson@example.com + This is a test. + User name: Anne Person + Password: AAA + Language: English (USA) + Options: http://example.com/aperson@example.com + ---------- + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + X-Peer: ... + X-MailFrom: test-bounces+bperson=example.com@example.com + X-RcptTo: bperson@example.com + + Delivery address: bperson@example.com + Subscribed address: bperson@example.com + This is a test. + User name: Bart Person + Password: BBB + Language: English (USA) + Options: http://example.com/bperson@example.com + ---------- + From: aperson@example.org + To: test@example.com + Subject: test one + Message-ID: + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + X-Peer: ... + X-MailFrom: test-bounces+cperson=example.com@example.com + X-RcptTo: cperson@example.com + + Delivery address: cperson@example.com + Subscribed address: cperson@example.com + This is a test. + User name: Cris Person + Password: CCC + Language: English (USA) + Options: http://example.com/cperson@example.com + ---------- diff --git a/src/mailman/mta/personalized.py b/src/mailman/mta/personalized.py index 6fb73fdba..4d0243fc7 100644 --- a/src/mailman/mta/personalized.py +++ b/src/mailman/mta/personalized.py @@ -63,10 +63,11 @@ class PersonalizedMixin: msg.replace_header('To', formataddr((name, recipient))) + class PersonalizedDelivery(PersonalizedMixin, VERPDelivery): """Personalize the message's To header.""" def __init__(self): """See `IndividualDelivery`.""" - super(VERPDelivery, self).__init__() + super(PersonalizedDelivery, self).__init__() self.callbacks.append(self.personalize_to) diff --git a/src/mailman/pipeline/avoid_duplicates.py b/src/mailman/pipeline/avoid_duplicates.py index 0458e117c..840d5f8b2 100644 --- a/src/mailman/pipeline/avoid_duplicates.py +++ b/src/mailman/pipeline/avoid_duplicates.py @@ -52,7 +52,7 @@ class AvoidDuplicates: def process(self, mlist, msg, msgdata): """See `IHandler`.""" - recips = msgdata.get('recips') + recips = msgdata.get('recipients') # Short circuit if not recips: return @@ -109,7 +109,7 @@ class AvoidDuplicates: # having received this message. newrecips.add(r) # Set the new list of recipients. XXX recips should always be a set. - msgdata['recips'] = list(newrecips) + msgdata['recipients'] = list(newrecips) # RFC 2822 specifies zero or one CC header if cc_addresses: del msg['cc'] diff --git a/src/mailman/pipeline/decorate.py b/src/mailman/pipeline/decorate.py index c6b613fda..f9e41e177 100644 --- a/src/mailman/pipeline/decorate.py +++ b/src/mailman/pipeline/decorate.py @@ -52,10 +52,7 @@ def process(mlist, msg, msgdata): if msgdata.get('personalize'): # Calculate the extra personalization dictionary. Note that the # length of the recips list better be exactly 1. - recips = msgdata.get('recips', []) - assert len(recips) == 1, ( - 'The number of intended recipients must be exactly 1') - recipient = recips[0].lower() + recipient = msgdata['recipient'] user = getUtility(IUserManager).get_user(recipient) member = mlist.members.get_member(recipient) d['user_address'] = recipient @@ -63,7 +60,7 @@ def process(mlist, msg, msgdata): d['user_delivered_to'] = member.address.original_address # BAW: Hmm, should we allow this? d['user_password'] = user.password - d['user_language'] = member.preferred_language + d['user_language'] = member.preferred_language.description d['user_name'] = (user.real_name if user.real_name else member.address.original_address) d['user_optionsurl'] = member.options_url diff --git a/src/mailman/pipeline/docs/avoid-duplicates.txt b/src/mailman/pipeline/docs/avoid-duplicates.txt index 9b44d0ebe..b6d0133fd 100644 --- a/src/mailman/pipeline/docs/avoid-duplicates.txt +++ b/src/mailman/pipeline/docs/avoid-duplicates.txt @@ -69,7 +69,7 @@ will get a list copy. ... """) >>> msgdata = recips.copy() >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'aperson@example.com', u'bperson@example.com'] >>> print msg.as_string() From: Claire Person @@ -87,7 +87,7 @@ If they're mentioned on the CC line, they won't get a list copy. ... """) >>> msgdata = recips.copy() >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'bperson@example.com'] >>> print msg.as_string() From: Claire Person @@ -107,7 +107,7 @@ But if they're mentioned on the CC line and have receive_list_copy set to True ... """) >>> msgdata = recips.copy() >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'aperson@example.com', u'bperson@example.com'] >>> print msg.as_string() From: Claire Person @@ -145,7 +145,7 @@ Other headers checked for recipients include the To... ... """) >>> msgdata = recips.copy() >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'bperson@example.com'] >>> print msg.as_string() From: Claire Person @@ -164,7 +164,7 @@ Other headers checked for recipients include the To... ... """) >>> msgdata = recips.copy() >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'bperson@example.com'] >>> print msg.as_string() From: Claire Person diff --git a/src/mailman/pipeline/docs/decorate.txt b/src/mailman/pipeline/docs/decorate.txt index 42afe9a80..78b500409 100644 --- a/src/mailman/pipeline/docs/decorate.txt +++ b/src/mailman/pipeline/docs/decorate.txt @@ -312,7 +312,7 @@ provided in the message data, otherwise an exception occurs. And the number of intended recipients must be exactly 1. - >>> process(mlist, None, dict(personalize=True, recips=[1, 2, 3])) + >>> process(mlist, None, dict(personalize=True, recipients=[1, 2, 3])) Traceback (most recent call last): ... AssertionError: The number of intended recipients must be exactly 1 diff --git a/src/mailman/pipeline/docs/file-recips.txt b/src/mailman/pipeline/docs/file-recips.txt index 3401e7492..b84b2181d 100644 --- a/src/mailman/pipeline/docs/file-recips.txt +++ b/src/mailman/pipeline/docs/file-recips.txt @@ -21,7 +21,7 @@ returns. ... ... A message. ... """) - >>> msgdata = {'recips': 7} + >>> msgdata = {'recipients': 7} >>> handler = config.handlers['file-recipients'] >>> handler.process(mlist, msg, msgdata) @@ -31,7 +31,7 @@ returns. A message. >>> msgdata - {u'recips': 7} + {u'recipients': 7} Missing file @@ -50,7 +50,7 @@ empty. No such file or directory: u'.../_xtest@example.com/members.txt' >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [] @@ -73,7 +73,7 @@ addresses are returned as the set of recipients. >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) ['bperson@example.com', 'cperson@example.com', 'dperson@example.com', 'eperson@example.com', 'fperson@example.com', 'gperson@example.com'] @@ -97,6 +97,6 @@ in the recipients list. ... """) >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) ['bperson@example.com', 'dperson@example.com', 'eperson@example.com', 'fperson@example.com', 'gperson@example.com'] diff --git a/src/mailman/pipeline/docs/replybot.txt b/src/mailman/pipeline/docs/replybot.txt index 36bc6198f..f02b90254 100644 --- a/src/mailman/pipeline/docs/replybot.txt +++ b/src/mailman/pipeline/docs/replybot.txt @@ -49,7 +49,7 @@ response. _parsemsg : False listname : _xtest@example.com nodecorate : True - recips : [u'aperson@example.com'] + recipients : [u'aperson@example.com'] reduced_list_headers: True version : 3 @@ -137,7 +137,7 @@ header is ignored. _parsemsg : False listname : _xtest@example.com nodecorate : True - recips : [u'asystem@example.com'] + recipients : [u'asystem@example.com'] reduced_list_headers: True version : 3 diff --git a/src/mailman/pipeline/file_recipients.py b/src/mailman/pipeline/file_recipients.py index fd2db596a..c3d995a9c 100644 --- a/src/mailman/pipeline/file_recipients.py +++ b/src/mailman/pipeline/file_recipients.py @@ -45,7 +45,7 @@ class FileRecipients: def process(self, mlist, msg, msgdata): """See `IHandler`.""" - if 'recips' in msgdata: + if 'recipients' in msgdata: return filename = os.path.join(mlist.data_path, 'members.txt') try: @@ -54,11 +54,11 @@ class FileRecipients: except IOError, e: if e.errno <> errno.ENOENT: raise - msgdata['recips'] = set() + msgdata['recipients'] = set() return # If the sender is a member of the list, remove them from the file # recipients. member = mlist.members.get_member(msg.sender) if member is not None: addrs.discard(member.address.address) - msgdata['recips'] = addrs + msgdata['recipients'] = addrs diff --git a/src/mailman/pipeline/owner_recipients.py b/src/mailman/pipeline/owner_recipients.py index ceb6ae0a1..ca6c17bd9 100644 --- a/src/mailman/pipeline/owner_recipients.py +++ b/src/mailman/pipeline/owner_recipients.py @@ -28,7 +28,7 @@ __all__ = [ def process(mlist, msg, msgdata): # The recipients are the owner and the moderator - msgdata['recips'] = mlist.owner + mlist.moderator + msgdata['recipients'] = mlist.owner + mlist.moderator # Don't decorate these messages with the header/footers msgdata['nodecorate'] = True msgdata['personalize'] = False diff --git a/src/mailman/queue/bounce.py b/src/mailman/queue/bounce.py index c966729bb..3d2ef6140 100644 --- a/src/mailman/queue/bounce.py +++ b/src/mailman/queue/bounce.py @@ -181,7 +181,7 @@ class BounceRunner(Runner, BounceMixin): # get stuck in a bounce loop. config.switchboards['out'].enqueue( msg, msgdata, - recips=[config.mailman.site_owner], + recipients=[config.mailman.site_owner], envsender=config.mailman.noreply_address, ) # List isn't doing bounce processing? diff --git a/src/mailman/queue/digest.py b/src/mailman/queue/digest.py index 30705b8be..3dbfc7917 100644 --- a/src/mailman/queue/digest.py +++ b/src/mailman/queue/digest.py @@ -360,10 +360,10 @@ class DigestRunner(Runner): # Send the digests to the virgin queue for final delivery. queue = config.switchboards['virgin'] queue.enqueue(mime, - recips=mime_recipients, + recipients=mime_recipients, listname=mlist.fqdn_listname, isdigest=True) queue.enqueue(rfc1153, - recips=rfc1153_recipients, + recipients=rfc1153_recipients, listname=mlist.fqdn_listname, isdigest=True) diff --git a/src/mailman/queue/docs/command.txt b/src/mailman/queue/docs/command.txt index 73be64de0..ae5b2612d 100644 --- a/src/mailman/queue/docs/command.txt +++ b/src/mailman/queue/docs/command.txt @@ -60,7 +60,7 @@ And now the response is in the virgin queue. >>> sorted(item.msgdata.items()) [..., ('listname', u'test@example.com'), ..., - ('recips', [u'aperson@example.com']), + ('recipients', [u'aperson@example.com']), ...] diff --git a/src/mailman/queue/docs/digester.txt b/src/mailman/queue/docs/digester.txt index c61f0fe3d..55f93cf40 100644 --- a/src/mailman/queue/docs/digester.txt +++ b/src/mailman/queue/docs/digester.txt @@ -522,12 +522,12 @@ and the other is the RFC 1153 digest. Only wperson and xperson get the MIME digests. - >>> sorted(mime.msgdata['recips']) + >>> sorted(mime.msgdata['recipients']) [u'wperson@example.com', u'xperson@example.com'] Only yperson and zperson get the RFC 1153 digests. - >>> sorted(rfc1153.msgdata['recips']) + >>> sorted(rfc1153.msgdata['recipients']) [u'yperson@example.com', u'zperson@example.com'] Now uperson decides that they would like to start receiving digests too. @@ -541,10 +541,10 @@ Now uperson decides that they would like to start receiving digests too. 2 >>> mime, rfc1153 = mime_rfc1153(messages) - >>> sorted(mime.msgdata['recips']) + >>> sorted(mime.msgdata['recipients']) [u'uperson@example.com', u'wperson@example.com', u'xperson@example.com'] - >>> sorted(rfc1153.msgdata['recips']) + >>> sorted(rfc1153.msgdata['recipients']) [u'yperson@example.com', u'zperson@example.com'] At this point, both uperson and wperson decide that they'd rather receive @@ -566,10 +566,10 @@ as much and does not want to receive one last digest. 2 >>> mime, rfc1153 = mime_rfc1153(messages) - >>> sorted(mime.msgdata['recips']) + >>> sorted(mime.msgdata['recipients']) [u'uperson@example.com', u'xperson@example.com'] - >>> sorted(rfc1153.msgdata['recips']) + >>> sorted(rfc1153.msgdata['recipients']) [u'yperson@example.com', u'zperson@example.com'] Since uperson has received their last digest, they will not get any more of @@ -583,8 +583,8 @@ them. 2 >>> mime, rfc1153 = mime_rfc1153(messages) - >>> sorted(mime.msgdata['recips']) + >>> sorted(mime.msgdata['recipients']) [u'xperson@example.com'] - >>> sorted(rfc1153.msgdata['recips']) + >>> sorted(rfc1153.msgdata['recipients']) [u'yperson@example.com', u'zperson@example.com'] diff --git a/src/mailman/queue/docs/incoming.txt b/src/mailman/queue/docs/incoming.txt index 8635f2872..74326820f 100644 --- a/src/mailman/queue/docs/incoming.txt +++ b/src/mailman/queue/docs/incoming.txt @@ -195,7 +195,7 @@ tests above. >>> dump_msgdata(item.msgdata) _parsemsg : False ... - recips : [u'aperson@example.com'] + recipients : [u'aperson@example.com'] ... >>> fp.seek(file_pos) diff --git a/src/mailman/queue/outgoing.py b/src/mailman/queue/outgoing.py index e64e8c57b..7776b1b54 100644 --- a/src/mailman/queue/outgoing.py +++ b/src/mailman/queue/outgoing.py @@ -118,7 +118,7 @@ class OutgoingRunner(Runner, BounceMixin): config.mta.delivery_retry_period) msgdata['last_recip_count'] = len(recips) msgdata['deliver_until'] = deliver_until - msgdata['recips'] = recips + msgdata['recipients'] = recips self._retryq.enqueue(msg, msgdata) # We've successfully completed handling of this message return False -- cgit v1.3.1 From 1e8d8bfdb64968763a6a4fbd74ad912eb4c6c0b6 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Mon, 2 Nov 2009 20:07:53 -0500 Subject: Fix all tests except outgoing.txt and common.txt --- src/mailman/archiving/mailarchive.py | 2 +- src/mailman/docs/pipelines.txt | 6 +++--- src/mailman/docs/registration.txt | 2 +- src/mailman/docs/requests.txt | 26 +++++++++++++------------- src/mailman/email/message.py | 4 ++-- src/mailman/mta/docs/bulk.txt | 1 - src/mailman/mta/docs/verp.txt | 1 - src/mailman/pipeline/docs/acknowledge.txt | 4 ++-- src/mailman/pipeline/docs/avoid-duplicates.txt | 5 +++-- src/mailman/pipeline/docs/decorate.txt | 21 --------------------- src/mailman/testing/layers.py | 2 +- 11 files changed, 26 insertions(+), 48 deletions(-) diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py index a5eb27db0..b3d7f4506 100644 --- a/src/mailman/archiving/mailarchive.py +++ b/src/mailman/archiving/mailarchive.py @@ -84,4 +84,4 @@ class MailArchive: config.switchboards['out'].enqueue( msg, listname=mlist.fqdn_listname, - recips=[config.archiver.mail_archive.recipient]) + recipients=[config.archiver.mail_archive.recipient]) diff --git a/src/mailman/docs/pipelines.txt b/src/mailman/docs/pipelines.txt index 5ad6ba243..51dfd7a61 100644 --- a/src/mailman/docs/pipelines.txt +++ b/src/mailman/docs/pipelines.txt @@ -66,7 +66,7 @@ However there are currently no recipients for this message. >>> dump_msgdata(msgdata) original_sender : aperson@example.com origsubj : My first post - recips : set([]) + recipients : set([]) stripped_subject: My first post And the message is now sitting in various other processing queues. @@ -103,7 +103,7 @@ And the message is now sitting in various other processing queues. _parsemsg : False original_sender : aperson@example.com origsubj : My first post - recips : set([]) + recipients : set([]) stripped_subject: My first post version : 3 @@ -148,7 +148,7 @@ This is the message that will actually get delivered to end recipients. listname : xtest@example.com original_sender : aperson@example.com origsubj : My first post - recips : set([]) + recipients : set([]) stripped_subject: My first post version : 3 diff --git a/src/mailman/docs/registration.txt b/src/mailman/docs/registration.txt index 09cc790ea..6b1ff0c18 100644 --- a/src/mailman/docs/registration.txt +++ b/src/mailman/docs/registration.txt @@ -158,7 +158,7 @@ message is sent to the user in order to verify the registered address. >>> dump_msgdata(qdata) _parsemsg : False nodecorate : True - recips : [u'aperson@example.com'] + recipients : [u'aperson@example.com'] reduced_list_headers: True version : 3 diff --git a/src/mailman/docs/requests.txt b/src/mailman/docs/requests.txt index 0113e2274..5dcfa922a 100644 --- a/src/mailman/docs/requests.txt +++ b/src/mailman/docs/requests.txt @@ -299,7 +299,7 @@ The message can be rejected, meaning it is bounced back to the sender. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'aperson@example.org'] + recipients : [u'aperson@example.org'] reduced_list_headers: True version : 3 @@ -404,7 +404,7 @@ moderators. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'zperson@example.com'] + recipients : [u'zperson@example.com'] reduced_list_headers: True version : 3 @@ -474,7 +474,7 @@ queue when the message is held. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'alist-owner@example.com'] + recipients : [u'alist-owner@example.com'] reduced_list_headers: True tomoderators : True version : 3 @@ -529,7 +529,7 @@ subscriber. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'cperson@example.org'] + recipients : [u'cperson@example.org'] reduced_list_headers: True version : 3 @@ -573,7 +573,7 @@ subscription and the fact that they may need to approve it. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'alist-owner@example.com'] + recipients : [u'alist-owner@example.com'] reduced_list_headers: True tomoderators : True version : 3 @@ -590,7 +590,7 @@ at the recipient list. >>> qmsg_1, qdata_1 = dequeue(expected_count=2) >>> qmsg_2, qdata_2 = dequeue() - >>> if 'fperson@example.org' in qdata_1['recips']: + >>> if 'fperson@example.org' in qdata_1['recipients']: ... # The first message is the welcome message ... welcome_qmsg = qmsg_1 ... welcome_qdata = qdata_1 @@ -646,7 +646,7 @@ The welcome message is sent to the person who just subscribed. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'fperson@example.org'] + recipients : [u'fperson@example.org'] reduced_list_headers: True verp : False version : 3 @@ -672,7 +672,7 @@ The admin message is sent to the moderators. envsender : changeme@example.com listname : alist@example.com nodecorate : True - recips : [] + recipients : [] reduced_list_headers: True version : 3 @@ -752,7 +752,7 @@ unsubscription holds can send the list's moderators an immediate notification. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'alist-owner@example.com'] + recipients : [u'alist-owner@example.com'] reduced_list_headers: True tomoderators : True version : 3 @@ -810,7 +810,7 @@ and the person remains a member of the mailing list. _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'hperson@example.com'] + recipients : [u'hperson@example.com'] reduced_list_headers: True version : 3 @@ -834,7 +834,7 @@ change. >>> qmsg_1, qdata_1 = dequeue(expected_count=2) >>> qmsg_2, qdata_2 = dequeue() - >>> if 'gperson@example.com' in qdata_1['recips']: + >>> if 'gperson@example.com' in qdata_1['recipients']: ... # The first message is the goodbye message ... goodbye_qmsg = qmsg_1 ... goodbye_qdata = qdata_1 @@ -865,7 +865,7 @@ The goodbye message... _parsemsg : False listname : alist@example.com nodecorate : True - recips : [u'gperson@example.com'] + recipients : [u'gperson@example.com'] reduced_list_headers: True verp : False version : 3 @@ -890,6 +890,6 @@ The goodbye message... envsender : changeme@example.com listname : alist@example.com nodecorate : True - recips : [] + recipients : [] reduced_list_headers: True version : 3 diff --git a/src/mailman/email/message.py b/src/mailman/email/message.py index 984c58c76..458f4070c 100644 --- a/src/mailman/email/message.py +++ b/src/mailman/email/message.py @@ -204,7 +204,7 @@ class UserNotification(Message): virginq = config.switchboards['virgin'] # The message metadata better have a 'recip' attribute. enqueue_kws = dict( - recips=self.recips, + recipients=self.recips, nodecorate=True, reduced_list_headers=True, ) @@ -242,7 +242,7 @@ class OwnerNotification(UserNotification): # The message metadata better have a `recip' attribute virginq.enqueue(self, listname=mlist.fqdn_listname, - recips=self.recips, + recipients=self.recips, nodecorate=True, reduced_list_headers=True, envsender=self._sender, diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index e40bdce16..99f58e7a7 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -142,7 +142,6 @@ Bulk delivery The set of recipients for bulk delivery comes from the message metadata. If there are no calculated recipients, nothing gets sent. - >>> smtpd.clear() >>> mlist = create_list('test@example.com') >>> msg = message_from_string("""\ ... From: aperson@example.org diff --git a/src/mailman/mta/docs/verp.txt b/src/mailman/mta/docs/verp.txt index 11157d363..c7c14e714 100644 --- a/src/mailman/mta/docs/verp.txt +++ b/src/mailman/mta/docs/verp.txt @@ -41,7 +41,6 @@ No recipients The message metadata specifies the set of recipients to send this message to. If there are no recipients, there's nothing to do. - >>> smtpd.clear() >>> mlist = create_list('test@example.com') >>> msg = message_from_string("""\ ... From: aperson@example.org diff --git a/src/mailman/pipeline/docs/acknowledge.txt b/src/mailman/pipeline/docs/acknowledge.txt index 3b8316cab..c304bd8bf 100644 --- a/src/mailman/pipeline/docs/acknowledge.txt +++ b/src/mailman/pipeline/docs/acknowledge.txt @@ -110,7 +110,7 @@ The receipt will include the original message's subject in the response body, >>> virginq.files [] >>> sorted(qdata.items()) - [..., ('recips', [u'aperson@example.com']), ...] + [..., ('recipients', [u'aperson@example.com']), ...] >>> print qmsg.as_string() ... MIME-Version: 1.0 @@ -144,7 +144,7 @@ If there is no subject, then the receipt will use a generic message. >>> virginq.files [] >>> sorted(qdata.items()) - [..., ('recips', [u'aperson@example.com']), ...] + [..., ('recipients', [u'aperson@example.com']), ...] >>> print qmsg.as_string() MIME-Version: 1.0 ... diff --git a/src/mailman/pipeline/docs/avoid-duplicates.txt b/src/mailman/pipeline/docs/avoid-duplicates.txt index b6d0133fd..1493c4d04 100644 --- a/src/mailman/pipeline/docs/avoid-duplicates.txt +++ b/src/mailman/pipeline/docs/avoid-duplicates.txt @@ -24,7 +24,8 @@ Create some members we're going to use. >>> member_b = address_b.subscribe(mlist, MemberRole.member) >>> # This is the message metadata dictionary as it would be produced by >>> # the CalcRecips handler. - >>> recips = dict(recips=['aperson@example.com', 'bperson@example.com']) + >>> recips = dict( + ... recipients=['aperson@example.com', 'bperson@example.com']) Short circuiting @@ -126,7 +127,7 @@ Other headers checked for recipients include the To... ... """) >>> msgdata = recips.copy() >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recips']) + >>> sorted(msgdata['recipients']) [u'bperson@example.com'] >>> print msg.as_string() From: Claire Person diff --git a/src/mailman/pipeline/docs/decorate.txt b/src/mailman/pipeline/docs/decorate.txt index 78b500409..246e67096 100644 --- a/src/mailman/pipeline/docs/decorate.txt +++ b/src/mailman/pipeline/docs/decorate.txt @@ -295,24 +295,3 @@ that the header and footer can be added as attachments. footer --BOUNDARY-- - - -Personalization -=============== - -A mailing list can be 'personalized', meaning that each message is unique for -each recipient. When the list is personalized, additional interpolation -variables are available, however the list of intended recipients must be -provided in the message data, otherwise an exception occurs. - - >>> process(mlist, None, dict(personalize=True)) - Traceback (most recent call last): - ... - AssertionError: The number of intended recipients must be exactly 1 - -And the number of intended recipients must be exactly 1. - - >>> process(mlist, None, dict(personalize=True, recipients=[1, 2, 3])) - Traceback (most recent call last): - ... - AssertionError: The number of intended recipients must be exactly 1 diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index 70b0ae9b1..4bc98161e 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -235,7 +235,7 @@ class SMTPLayer(ConfigLayer): @classmethod def testTearDown(cls): - pass + cls.smtpd.clear() -- cgit v1.3.1