diff options
| -rw-r--r-- | mailman/Commands/__init__.py | 20 | ||||
| -rw-r--r-- | mailman/Commands/cmd_echo.py | 26 | ||||
| -rw-r--r-- | mailman/Commands/echo.py | 47 | ||||
| -rw-r--r-- | mailman/interfaces/chain.py | 2 | ||||
| -rw-r--r-- | mailman/interfaces/command.py | 50 | ||||
| -rw-r--r-- | mailman/queue/command.py | 229 | ||||
| -rw-r--r-- | setup.py | 11 |
7 files changed, 236 insertions, 149 deletions
diff --git a/mailman/Commands/__init__.py b/mailman/Commands/__init__.py index e69de29bb..81035e44a 100644 --- a/mailman/Commands/__init__.py +++ b/mailman/Commands/__init__.py @@ -0,0 +1,20 @@ +# Copyright (C) 2008 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +__all__ = [ + 'echo', + ] diff --git a/mailman/Commands/cmd_echo.py b/mailman/Commands/cmd_echo.py deleted file mode 100644 index 4a85b2070..000000000 --- a/mailman/Commands/cmd_echo.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright (C) 2002-2008 by the Free Software Foundation, Inc. -# -# This program 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 2 -# of the License, or (at your option) any later version. -# -# This program 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 this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -""" - echo [args] - Simply echo an acknowledgement. Args are echoed back unchanged. -""" - -SPACE = ' ' - -def process(res, args): - res.results.append('echo %s' % SPACE.join(args)) - return 1 diff --git a/mailman/Commands/echo.py b/mailman/Commands/echo.py new file mode 100644 index 000000000..3aae16344 --- /dev/null +++ b/mailman/Commands/echo.py @@ -0,0 +1,47 @@ +# Copyright (C) 2002-2008 by the Free Software Foundation, Inc. +# +# This program 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 2 of the License, or (at your option) +# any later version. +# +# This program 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 +# this program; if not, write to the Free Software Foundation, Inc., 51 +# Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""The email command 'echo'.""" + +__metaclass__ = type +__all__ = [ + 'Echo', + ] + + +from zope.interfaces import implements + +from mailman.i18n import _ +from mailman.interface import IEmailCommand + + +SPACE = ' ' + + + +class Echo: + """The email 'echo' command.""" + implements(IEmailCommand) + + name = 'echo' + argument_description = '[args]' + description = _( + 'Echo an acknowledgement. Arguments are return unchanged.') + + def process(self, mlist, msg, msgdata, arguments, results): + """See `IEmailCommand`.""" + print >> results, 'echo', SPACE.join(arguments) + return True diff --git a/mailman/interfaces/chain.py b/mailman/interfaces/chain.py index d504d257f..542a19177 100644 --- a/mailman/interfaces/chain.py +++ b/mailman/interfaces/chain.py @@ -15,7 +15,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. -"""Interface describing the basics of chains and links.""" +"""Interfaces describing the basics of chains and links.""" from munepy import Enum from zope.interface import Interface, Attribute diff --git a/mailman/interfaces/command.py b/mailman/interfaces/command.py new file mode 100644 index 000000000..553dcb0e3 --- /dev/null +++ b/mailman/interfaces/command.py @@ -0,0 +1,50 @@ +# Copyright (C) 2008 by the Free Software Foundation, Inc. +# +# This program 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 2 +# of the License, or (at your option) any later version. +# +# This program 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 this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interfaces defining email commands.""" + +from zope.interface import Interface, Attribute + + + +class IEmailResults(Interface): + """The email command results object.""" + + output = Attribute('An output file object for printing results to.') + + + +class IEmailCommand(Interface): + """An email command.""" + + name = Attribute('Command name as seen in a -request email.') + + argument_description = Attribute('Description of command arguments.') + + description = Attribute('Command help.') + + def process(mlist, msg, msgdata, arguments, results): + """Process the email command. + + :param mlist: The mailing list target of the command. + :param msg: The original message object. + :param msgdata: The message metadata. + :param arguments: The command arguments tuple. + :param results: An IEmailResults object for these commands. + :return: True if further processing should be taken of the email + commands in this message. + """ diff --git a/mailman/queue/command.py b/mailman/queue/command.py index c95504401..b06a5387b 100644 --- a/mailman/queue/command.py +++ b/mailman/queue/command.py @@ -16,6 +16,12 @@ """-request robot command queue runner.""" +__metaclass__ = type +__all__ = [ + 'CommandRunner', + 'Results', + ] + # See the delivery diagram in IncomingRunner.py. This module handles all # email destined for mylist-request, -join, and -leave. It no longer handles # bounce messages (i.e. -admin or -bounces), nor does it handle mail to @@ -25,17 +31,20 @@ import re import sys import logging +from StringIO import StringIO from email.Errors import HeaderParseError from email.Header import decode_header, make_header, Header from email.Iterators import typed_subpart_iterator from email.MIMEMessage import MIMEMessage from email.MIMEText import MIMEText +from zope.interface import implements from mailman import Message from mailman import Utils from mailman.app.replybot import autorespond_to_sender from mailman.configuration import config from mailman.i18n import _ +from mailman.interfaces import IEmailResults from mailman.queue import Runner NL = '\n' @@ -44,103 +53,124 @@ log = logging.getLogger('mailman.vette') -class Results: - def __init__(self, mlist, msg, msgdata): - self.mlist = mlist - self.msg = msg - self.msgdata = msgdata - # Only set returnaddr if the response is to go to someone other than - # the address specified in the From: header (e.g. for the password - # command). - self.returnaddr = None - self.commands = [] - self.results = [] - self.ignored = [] - self.lineno = 0 - self.subjcmdretried = 0 - self.respond = True - # Extract the subject header and do RFC 2047 decoding. Note that - # Python 2.1's unicode() builtin doesn't call obj.__unicode__(). - subj = msg.get('subject', '') +class CommandFinder: + """Generate commands from the content of a message.""" + + def __init__(self, msg, results): + self.command_lines = [] + self.ignored_lines = [] + self.processed_lines = [] + # Depending on where the message was destined to, add some implicit + # commands. For example, if this was sent to the -join or -leave + # addresses, it's the same as if 'join' or 'leave' commands were sent + # to the -request address. + if msgdata.get('tojoin'): + self.command_lines.append('join') + elif msgdata.get('toleave'): + self.command_lines.append('leave') + elif msgdata.get('toconfirm'): + mo = re.match(config.VERP_CONFIRM_REGEXP, msg.get('to', '')) + if mo: + self.command_lines.append('confirm ' + mo.group('cookie')) + # Extract the subject header and do RFC 2047 decoding. + subject = msg.get('subject', '') try: - subj = make_header(decode_header(subj)).__unicode__() - # TK: Currently we don't allow 8bit or multibyte in mail command. - subj = subj.encode('us-ascii') - # Always process the Subject: header first - self.commands.append(subj) + subject = unicode(make_header(decode_header(raw_subject))) + # Mail commands must be ASCII. + self.command_lines.append(subject.encode('us-ascii')) except (HeaderParseError, UnicodeError, LookupError): - # We couldn't parse it so ignore the Subject header + # The Subject header was unparseable or not ASCII, so just ignore + # it. pass - # Find the first text/plain part + # Find the first text/plain part of the message. part = None for part in typed_subpart_iterator(msg, 'text', 'plain'): break if part is None or part is not msg: # Either there was no text/plain part or we ignored some # non-text/plain parts. - self.results.append(_('Ignoring non-text/plain MIME parts')) + print >> results, _('Ignoring non-text/plain MIME parts') if part is None: - # E.g the outer Content-Type: was text/html + # There was no text/plain part to be found. return body = part.get_payload(decode=True) - # text/plain parts better have string payloads - assert isinstance(body, basestring) + # text/plain parts better have string payloads. + assert isinstance(body, basestring), 'Non-string decoded payload' lines = body.splitlines() # Use no more lines than specified - self.commands.extend(lines[:config.EMAIL_COMMANDS_MAX_LINES]) - self.ignored.extend(lines[config.EMAIL_COMMANDS_MAX_LINES:]) + self.command_lines.extend(lines[:config.EMAIL_COMMANDS_MAX_LINES]) + self.ignored_lines.extend(lines[config.EMAIL_COMMANDS_MAX_LINES:]) - def process(self): - # Now, process each line until we find an error. The first - # non-command line found stops processing. - stop = False - for line in self.commands: - if line and line.strip(): - args = line.split() - cmd = args.pop(0).lower() - stop = self.do_command(cmd, args) - self.lineno += 1 - if stop: - break + def __iter__(self): + """Return each command line, split into commands and arguments. - def do_command(self, cmd, args=None): - if args is None: - args = () - # Try to import a command handler module for this command - modname = 'mailman.Commands.cmd_' + cmd - try: - __import__(modname) - handler = sys.modules[modname] - # ValueError can be raised if cmd has dots in it. - except (ImportError, ValueError): - # If we're on line zero, it was the Subject: header that didn't - # contain a command. It's possible there's a Re: prefix (or - # localized version thereof) on the Subject: line that's messing - # things up. Pop the prefix off and try again... once. - # - # If that still didn't work it isn't enough to stop processing. - # BAW: should we include a message that the Subject: was ignored? - if not self.subjcmdretried and args: - self.subjcmdretried += 1 - cmd = args.pop(0) - return self.do_command(cmd, args) - return self.lineno <> 0 - return handler.process(self, args) + :return: 2-tuples where the first element is the command and the + second element is a tuple of the arguments. + """ + while self.command_lines: + line = self.command_lines.pop(0) + self.processed_lines.append(line) + parts = line.strip().split() + yield parts[0], tuple(parts[1:]) - def send_response(self): - # Helper - def indent(lines): - return [' ' + line for line in lines] - # Quick exit for some commands which don't need a response - if not self.respond: - return - resp = [Utils.wrap(_("""\ + + +class Results: + """The email command results.""" + + implements(IResults) + + def __init__(self): + self._output = StringIO() + print >> self._output, _("""\ The results of your email command are provided below. -Attached is your original message. -"""))] - if self.results: - resp.append(_('- Results:')) - resp.extend(indent(self.results)) + +""") + print >> self._output, _('- Results:') + + def write(self, text): + self._output.write(text) + + def __str__(self): + return self._output.getvalue() + + + +class CommandRunner(Runner): + QDIR = config.CMDQUEUE_DIR + + def _dispose(self, mlist, msg, msgdata): + message_id = msg.get('message-id', 'n/a') + # The policy here is similar to the Replybot policy. If a message has + # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard + # the command message. + precedence = msg.get('precedence', '').lower() + ack = msg.get('x-ack', '').lower() + if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): + log.info('%s Precedence: %s message discarded by: %s', + message_id, precedence, mlist.request_address) + return False + # Do replybot for commands. + replybot = config.handlers['replybot'] + replybot.process(mlist, msg, msgdata) + if mlist.autorespond_requests == 1: + # Respond and discard. + log.info('%s -request message replied and discard', message_id) + return False + # Now craft the response and process the command lines. + results = Results() + finder = CommandFinder(msg, results) + for command_name, arguments in finder: + command = config.commands.get(command_name) + if command is None: + print >> results, _('No such command: $command_name') + else: + command.process(msg, msgdata, arguments, results) + # All done, send the response. + print >> results, _('\n- Unprocessed:') + # XXX + + # Ignore empty lines unprocessed = [line for line in self.commands[self.lineno:] if line and line.strip()] @@ -187,44 +217,3 @@ To obtain instructions, send a message containing just the word "help". orig = MIMEMessage(self.msg) msg.attach(orig) msg.send(self.mlist) - - - -class CommandRunner(Runner): - QDIR = config.CMDQUEUE_DIR - - def _dispose(self, mlist, msg, msgdata): - # The policy here is similar to the Replybot policy. If a message has - # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard - # it to prevent replybot response storms. - precedence = msg.get('precedence', '').lower() - ack = msg.get('x-ack', '').lower() - if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'): - log.info('Precedence: %s message discarded by: %s', - precedence, mlist.GetRequestEmail()) - return False - # Do replybot for commands - mlist.Load() - replybot = config.handlers['replybot'] - replybot.process(mlist, msg, msgdata) - if mlist.autorespond_requests == 1: - log.info('replied and discard') - # w/discard - return False - # Now craft the response - res = Results(mlist, msg, msgdata) - # This message will have been delivered to one of mylist-request, - # mylist-join, or mylist-leave, and the message metadata will contain - # a key to which one was used. - if msgdata.get('torequest'): - res.process() - elif msgdata.get('tojoin'): - res.do_command('join') - elif msgdata.get('toleave'): - res.do_command('leave') - elif msgdata.get('toconfirm'): - mo = re.match(config.VERP_CONFIRM_REGEXP, msg.get('to', '')) - if mo: - res.do_command('confirm', (mo.group('cookie'),)) - res.send_response() - config.db.commit() @@ -57,9 +57,15 @@ for dirpath, dirnames, filenames in os.walk(start_dir): # XXX The 'bin/' prefix here should be configurable. template = Template('bin/$script = mailman.bin.$script:main') scripts = set( - template.substitute(script=os.path.splitext(script)[0]) + template.substitute(script=script) for script in mailman.bin.__all__ - if not script.startswith('_') + ) + +# Default email commands +template = Template('$command = mailman.command.$command') +commands = set( + template.substitute(command=command) + for command in mailman.Commands.__all__ ) @@ -84,6 +90,7 @@ Any other spelling is incorrect.""", 'console_scripts': list(scripts), # Entry point for plugging in different database backends. 'mailman.archiver' : 'default = mailman.app.archiving:Pipermail', + 'mailman.commands' : list(commands), 'mailman.database' : 'stock = mailman.database:StockDatabase', 'mailman.mta' : 'stock = mailman.MTA:Manual', 'mailman.styles' : 'default = mailman.app.styles:DefaultStyle', |
