diff options
| author | Barry Warsaw | 2009-11-02 21:59:27 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2009-11-02 21:59:27 -0500 |
| commit | f5f8eb69849264e2647c192963b6aeec97dd43ed (patch) | |
| tree | ba95236f769894b593dc9585cdfc2362d0aeb806 | |
| parent | 9bd005cfcca26b9f02b96bba5076cd9e58421e98 (diff) | |
| parent | 1e8d8bfdb64968763a6a4fbd74ad912eb4c6c0b6 (diff) | |
| download | mailman-f5f8eb69849264e2647c192963b6aeec97dd43ed.tar.gz mailman-f5f8eb69849264e2647c192963b6aeec97dd43ed.tar.zst mailman-f5f8eb69849264e2647c192963b6aeec97dd43ed.zip | |
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) |
