summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBarry Warsaw2009-11-02 21:59:27 -0500
committerBarry Warsaw2009-11-02 21:59:27 -0500
commitf5f8eb69849264e2647c192963b6aeec97dd43ed (patch)
treeba95236f769894b593dc9585cdfc2362d0aeb806
parent9bd005cfcca26b9f02b96bba5076cd9e58421e98 (diff)
parent1e8d8bfdb64968763a6a4fbd74ad912eb4c6c0b6 (diff)
downloadmailman-f5f8eb69849264e2647c192963b6aeec97dd43ed.tar.gz
mailman-f5f8eb69849264e2647c192963b6aeec97dd43ed.tar.zst
mailman-f5f8eb69849264e2647c192963b6aeec97dd43ed.zip
-rw-r--r--src/mailman/archiving/mailarchive.py2
-rw-r--r--src/mailman/config/schema.cfg19
-rw-r--r--src/mailman/docs/pipelines.txt6
-rw-r--r--src/mailman/docs/registration.txt2
-rw-r--r--src/mailman/docs/requests.txt26
-rw-r--r--src/mailman/email/message.py4
-rw-r--r--src/mailman/inject.py2
-rw-r--r--src/mailman/interfaces/mta.py36
-rw-r--r--src/mailman/mta/base.py170
-rw-r--r--src/mailman/mta/bulk.py126
-rw-r--r--src/mailman/mta/connection.py95
-rw-r--r--src/mailman/mta/decorating.py50
-rw-r--r--src/mailman/mta/docs/bulk.txt404
-rw-r--r--src/mailman/mta/docs/connection.txt157
-rw-r--r--src/mailman/mta/docs/decorating.txt151
-rw-r--r--src/mailman/mta/docs/personalized.txt137
-rw-r--r--src/mailman/mta/docs/verp.txt127
-rw-r--r--src/mailman/mta/null.py10
-rw-r--r--src/mailman/mta/personalized.py73
-rw-r--r--src/mailman/mta/postfix.py8
-rw-r--r--src/mailman/mta/smtp_direct.py43
-rw-r--r--src/mailman/mta/verp.py98
-rw-r--r--src/mailman/pipeline/avoid_duplicates.py4
-rw-r--r--src/mailman/pipeline/calculate_recipients.py38
-rw-r--r--src/mailman/pipeline/decorate.py7
-rw-r--r--src/mailman/pipeline/docs/acknowledge.txt4
-rw-r--r--src/mailman/pipeline/docs/avoid-duplicates.txt15
-rw-r--r--src/mailman/pipeline/docs/calc-recips.txt10
-rw-r--r--src/mailman/pipeline/docs/decorate.txt21
-rw-r--r--src/mailman/pipeline/docs/file-recips.txt10
-rw-r--r--src/mailman/pipeline/docs/replybot.txt4
-rw-r--r--src/mailman/pipeline/file_recipients.py6
-rw-r--r--src/mailman/pipeline/owner_recipients.py2
-rw-r--r--src/mailman/queue/bounce.py2
-rw-r--r--src/mailman/queue/digest.py4
-rw-r--r--src/mailman/queue/docs/command.txt2
-rw-r--r--src/mailman/queue/docs/digester.txt16
-rw-r--r--src/mailman/queue/docs/incoming.txt2
-rw-r--r--src/mailman/queue/outgoing.py2
-rw-r--r--src/mailman/testing/layers.py20
-rw-r--r--src/mailman/testing/mta.py156
41 files changed, 1880 insertions, 191 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/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/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/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/interfaces/mta.py b/src/mailman/interfaces/mta.py
index a8c794750..da0943e25 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,32 @@ 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, 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`
+ :param msg: The original message being delivered.
+ :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/base.py b/src/mailman/mta/base.py
new file mode 100644
index 000000000..48a3cd8f4
--- /dev/null
+++ b/src/mailman/mta/base.py
@@ -0,0 +1,170 @@
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Base delivery class."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'BaseDelivery',
+ 'IndividualDelivery',
+ ]
+
+
+import copy
+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):
+ """Create a basic deliverer."""
+ self._connection = Connection(
+ config.mta.smtp_host, int(config.mta.smtp_port),
+ int(config.mta.max_sessions_per_connection))
+
+ 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
+
+
+
+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
new file mode 100644
index 000000000..3247331d3
--- /dev/null
+++ b/src/mailman/mta/bulk.py
@@ -0,0 +1,126 @@
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Bulk message delivery."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'BulkDelivery',
+ ]
+
+
+from mailman.mta.base import BaseDelivery
+
+
+# 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(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.
+
+ 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
+
+ def deliver(self, mlist, msg, msgdata):
+ """See `IMailTransportAgentDelivery`."""
+ recipients = msgdata.get('recipients')
+ 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
+ # 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.
+ sender = self._get_sender(mlist, msg, msgdata)
+ del msg['sender']
+ del msg['errors-to']
+ msg['Sender'] = sender
+ msg['Errors-To'] = sender
+ refused = {}
+ for recipients in self.chunkify(msgdata['recipients']):
+ chunk_refused = self._deliver_to_recipients(
+ mlist, msg, msgdata, sender, recipients)
+ refused.update(chunk_refused)
+ return refused
+
diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py
new file mode 100644
index 000000000..105d25afb
--- /dev/null
+++ b/src/mailman/mta/connection.py
@@ -0,0 +1,95 @@
+# 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 <http://www.gnu.org/licenses/>.
+
+"""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._sessions_per_connection = sessions_per_connection
+ self._session_count = None
+ 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)
+ self._session_count = self._sessions_per_connection
+
+ 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._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 <wink>.
+ if self._session_count == 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/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 <http://www.gnu.org/licenses/>.
+
+"""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/bulk.txt b/src/mailman/mta/docs/bulk.txt
new file mode 100644
index 000000000..99f58e7a7
--- /dev/null
+++ b/src/mailman/mta/docs/bulk.txt
@@ -0,0 +1,404 @@
+======================
+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
+
+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()
+
+Delivery strategies must implement the proper interface.
+
+ >>> from mailman.interfaces.mta import IMailTransportAgentDelivery
+ >>> from zope.interface.verify import verifyObject
+ >>> 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
+
+
+Bulk delivery
+=============
+
+The set of recipients for bulk delivery comes from the message metadata. If
+there are no calculated recipients, nothing gets sent.
+
+ >>> mlist = create_list('test@example.com')
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.org
+ ... To: test@example.com
+ ... Subject: test one
+ ... Message-ID: <aardvark>
+ ...
+ ... This is a test.
+ ... """)
+
+ >>> bulk = BulkDelivery()
+ >>> bulk.deliver(mlist, msg, {})
+ >>> 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).
+
+ >>> 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: <aardvark>
+ ...
+ X-RcptTo: person_...
+ person_...
+ ...
+ <BLANKLINE>
+ 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
+
+
+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: <aardvark>
+ Sender: asender@example.org
+ Errors-To: asender@example.org
+ X-Peer: ...
+ X-MailFrom: asender@example.org
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ 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: <aardvark>
+ Sender: test-bounces@example.com
+ Errors-To: test-bounces@example.com
+ X-Peer: ...
+ X-MailFrom: test-bounces@example.com
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ 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: <aardvark>
+ Sender: site-owner@example.com
+ Errors-To: site-owner@example.com
+ X-Peer: ...
+ X-MailFrom: site-owner@example.com
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ 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: <badger>
+ ... 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: <badger>
+ Sender: test-bounces@example.com
+ Errors-To: test-bounces@example.com
+ X-Peer: ...
+ X-MailFrom: test-bounces@example.com
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ 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, 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', 500))
+ >>> smtpd.err_queue.put(('rcpt', 500))
+ >>> smtpd.err_queue.put(('rcpt', 500))
+
+ >>> 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: <camel>
+ ...
+ ... 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
+
+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
+
+XXX Untested: socket.error, IOError, smtplib.SMTPException.
diff --git a/src/mailman/mta/docs/connection.txt b/src/mailman/mta/docs/connection.txt
new file mode 100644
index 000000000..8924826c8
--- /dev/null
+++ b/src/mailman/mta/docs/connection.txt
@@ -0,0 +1,157 @@
+===============
+MTA connections
+===============
+
+Outgoing connections to the outgoing mail transport agent (MTA) are mitigated
+through a Connection class, which can transparently manage multiple sessions
+in a single connection.
+
+ >>> from mailman.mta.connection import Connection
+
+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), 0)
+
+At the start, there have been no connections to the server.
+
+ >>> smtpd.get_connection_count()
+ 0
+
+By sending a message to the server, a 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
+
+We can reset the connection 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_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/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: <aardvark>
+ ...
+ ... 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)
+ <Member: Anne Person <aperson@example.com> ...
+
+ >>> bart = user_manager.create_user('bperson@example.com', 'Bart Person')
+ >>> bart.password = 'BBB'
+ >>> list(bart.addresses)[0].subscribe(mlist, MemberRole.member)
+ <Member: Bart Person <bperson@example.com> ...
+
+ >>> cris = user_manager.create_user('cperson@example.com', 'Cris Person')
+ >>> cris.password = 'CCC'
+ >>> list(cris.addresses)[0].subscribe(mlist, MemberRole.member)
+ <Member: Cris Person <cperson@example.com> ...
+
+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: <aardvark>
+ 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
+ <BLANKLINE>
+ 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: <aardvark>
+ 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
+ <BLANKLINE>
+ 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: <aardvark>
+ 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
+ <BLANKLINE>
+ 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/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: <aardvark>
+ ...
+ ... 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: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+aperson=example.com@example.com
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
+ From: aperson@example.org
+ To: bperson@example.com
+ Subject: test one
+ Message-ID: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+bperson=example.com@example.com
+ X-RcptTo: bperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
+ From: aperson@example.org
+ To: cperson@example.com
+ Subject: test one
+ Message-ID: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+cperson=example.com@example.com
+ X-RcptTo: cperson@example.com
+ <BLANKLINE>
+ 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: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+aperson=example.com@example.com
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
+ From: aperson@example.org
+ To: Bill Person <bperson@example.com>
+ Subject: test one
+ Message-ID: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+bperson=example.com@example.com
+ X-RcptTo: bperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
+ From: aperson@example.org
+ To: Cate Person <cperson@example.com>
+ Subject: test one
+ Message-ID: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+cperson=example.com@example.com
+ X-RcptTo: cperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
diff --git a/src/mailman/mta/docs/verp.txt b/src/mailman/mta/docs/verp.txt
new file mode 100644
index 000000000..c7c14e714
--- /dev/null
+++ b/src/mailman/mta/docs/verp.txt
@@ -0,0 +1,127 @@
+======================
+Standard VERP delivery
+======================
+
+Variable Envelope Return Path (VERP_) delivery is an alternative to bulk_
+delivery, where an individual message is crafted uniquely for each recipient.
+
+.. _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
+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.
+
+ >>> mlist = create_list('test@example.com')
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.org
+ ... To: test@example.com
+ ... Subject: test one
+ ... Message-ID: <aardvark>
+ ...
+ ... 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: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+aperson=example.com@example.com
+ X-RcptTo: aperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
+ From: aperson@example.org
+ To: test@example.com
+ Subject: test one
+ Message-ID: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+bperson=example.com@example.com
+ X-RcptTo: bperson@example.com
+ <BLANKLINE>
+ This is a test.
+ ----------
+ From: aperson@example.org
+ To: test@example.com
+ Subject: test one
+ Message-ID: <aardvark>
+ X-Peer: ...
+ X-MailFrom: test-bounces+cperson=example.com@example.com
+ X-RcptTo: cperson@example.com
+ <BLANKLINE>
+ 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: <aardvark>
+ <BLANKLINE>
+ This is a test.
+ <BLANKLINE>
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/personalized.py b/src/mailman/mta/personalized.py
new file mode 100644
index 000000000..4d0243fc7
--- /dev/null
+++ b/src/mailman/mta/personalized.py
@@ -0,0 +1,73 @@
+# 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 <http://www.gnu.org/licenses/>.
+
+"""Personalized delivery."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'PersonalizedDelivery',
+ 'PersonalizedMixin',
+ ]
+
+
+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 PersonalizedMixin:
+ """Personalize the message's To header.
+
+ 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, 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.
+ """
+ recipient = msgdata['recipient']
+ 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)))
+
+
+
+class PersonalizedDelivery(PersonalizedMixin, VERPDelivery):
+ """Personalize the message's To header."""
+
+ def __init__(self):
+ """See `IndividualDelivery`."""
+ super(PersonalizedDelivery, self).__init__()
+ self.callbacks.append(self.personalize_to)
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/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
@@ -60,49 +60,6 @@ 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 <wink>.
- 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')
if not recips:
diff --git a/src/mailman/mta/verp.py b/src/mailman/mta/verp.py
new file mode 100644
index 000000000..a46c42cff
--- /dev/null
+++ b/src/mailman/mta/verp.py
@@ -0,0 +1,98 @@
+# 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 <http://www.gnu.org/licenses/>.
+
+"""VERP delivery."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'VERPDelivery',
+ 'VERPMixin',
+ ]
+
+
+import logging
+
+from mailman.config import config
+from mailman.email.utils import split_email
+from mailman.mta.base import IndividualDelivery
+from mailman.utilities.string import expand
+
+
+DOT = '.'
+log = logging.getLogger('mailman.smtp')
+
+
+
+class VERPMixin:
+ """Mixin for VERP functionality.
+
+ 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.
+
+ :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
+ """
+ recipient = msgdata['recipient']
+ sender = super(VERPMixin, self)._get_sender(mlist, msg, msgdata)
+ sender_mailbox, sender_domain = split_email(sender)
+ # 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'
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/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/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/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 9b44d0ebe..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
@@ -69,7 +70,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 <cperson@example.com>
@@ -87,7 +88,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 <cperson@example.com>
@@ -107,7 +108,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 <cperson@example.com>
@@ -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 <cperson@example.com>
@@ -145,7 +146,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 <cperson@example.com>
@@ -164,7 +165,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 <cperson@example.com>
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
diff --git a/src/mailman/pipeline/docs/decorate.txt b/src/mailman/pipeline/docs/decorate.txt
index 42afe9a80..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.
<BLANKLINE>
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, recips=[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.
<BLANKLINE>
>>> 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.
<BLANKLINE>
>>> 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
diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py
index d4db9ebf2..4bc98161e 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 ConnectionCountingController
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 = ConnectionCountingController(host, port)
cls.smtpd.start()
@classmethod
@@ -249,7 +235,7 @@ class SMTPLayer(ConfigLayer):
@classmethod
def testTearDown(cls):
- pass
+ cls.smtpd.clear()
diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py
index a10ba3c81..d16b9f955 100644
--- a/src/mailman/testing/mta.py
+++ b/src/mailman/testing/mta.py
@@ -25,16 +25,25 @@ __all__ = [
]
+import logging
+
+from Queue import Empty, 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
+from mailman.interfaces.mta import IMailTransportAgentAliases
+
+
+log = logging.getLogger('lazr.smtptest')
class FakeMTA:
"""Fake MTA for testing purposes."""
- implements(IMailTransportAgent)
+ implements(IMailTransportAgentAliases)
def create(self, mlist):
pass
@@ -44,3 +53,146 @@ class FakeMTA:
def regenerate(self):
pass
+
+
+
+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."""
+ self._server.send_statistics()
+ self.push(b'250 Ok')
+
+ def smtp_RCPT(self, arg):
+ """For testing, sometimes cause a non-25x response."""
+ code = self._server.next_error('rcpt')
+ if code is None:
+ # Everything's cool.
+ Channel.smtp_RCPT(self, arg)
+ else:
+ # The test suite wants this to fail. The message corresponds to
+ # the exception we expect smtplib.SMTP to raise.
+ self.push(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_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)
+
+
+
+class ConnectionCountingServer(QueueServer):
+ """Count the number of SMTP connections opened."""
+
+ 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
+ self._last_error = None
+
+ def next_error(self, command):
+ """Return the next error for the SMTP command, if there is one.
+
+ :param command: The SMTP command for which an error might be
+ 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
+ """
+ # 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`."""
+ connection, address = self.accept()
+ 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._connection_count = 0
+
+ def send_statistics(self):
+ """Send the current connection statistics to the controller."""
+ # Do not count the connection caused by the STAT connect.
+ self._connection_count -= 1
+ self._oob_queue.put(self._connection_count)
+
+
+
+class ConnectionCountingController(QueueController):
+ """Count the number of SMTP connections opened."""
+
+ 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, self.err_queue)
+
+ 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_connection_count(self):
+ """Retrieve the number of connections.
+
+ :return: The number of connections to the server that have been made.
+ :rtype: integer
+ """
+ smtpd = self._connect()
+ smtpd.docmd(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)
+
+ @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)