# Copyright (C) 1998-2003 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""-request robot command queue runner."""
# 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
# -owner.
# BAW: get rid of this when we Python 2.2 is a minimum requirement.
from __future__ import nested_scopes
import sys
import re
from types import StringType, UnicodeType
from Mailman import mm_cfg
from Mailman import Utils
from Mailman import Message
from Mailman.Handlers import Replybot
from Mailman.i18n import _
from Mailman.Queue.Runner import Runner
from Mailman.Logging.Syslog import syslog
from Mailman import LockFile
from email.MIMEText import MIMEText
from email.MIMEMessage import MIMEMessage
from email.Iterators import typed_subpart_iterator
NL = '\n'
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 = 1
# Always process the Subject: header first
self.commands.append(msg.get('subject', ''))
# Find the first text/plain part
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'))
if part is None:
# E.g the outer Content-Type: was text/html
return
body = part.get_payload()
# text/plain parts better have string payloads
assert isinstance(body, StringType) or isinstance(body, UnicodeType)
lines = body.splitlines()
# Use no more lines than specified
self.commands.extend(lines[:mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES])
self.ignored.extend(lines[mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES:])
def process(self):
# Now, process each line until we find an error. The first
# non-command line found stops processing.
stop = 0
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 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)
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(_("""\
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))
# Ignore empty lines
unprocessed = [line for line in self.commands[self.lineno:]
if line and line.strip()]
if unprocessed:
resp.append(_('\n- Unprocessed:'))
resp.extend(indent(unprocessed))
if not unprocessed and not self.results:
# The user sent an empty message; return a helpful one.
resp.append(Utils.wrap(_("""\
No commands were found in this message.
To obtain instructions, send a message containing just the word "help".
""")))
if self.ignored:
resp.append(_('\n- Ignored:'))
resp.extend(indent(self.ignored))
resp.append(_('\n- Done.\n\n'))
# Encode any unicode strings into the list charset, so we don't try to
# join unicode strings and invalid ASCII.
charset = Utils.GetCharSet(self.mlist.preferred_language)
encoded_resp = []
for item in resp:
if isinstance(item, UnicodeType):
item = item.encode(charset, 'replace')
encoded_resp.append(item)
results = MIMEText(NL.join(encoded_resp), _charset=charset)
# Safety valve for mail loops with misconfigured email 'bots. We
# don't respond to commands sent with "Precedence: bulk|junk|list"
# unless they explicitly "X-Ack: yes", but not all mail 'bots are
# correctly configured, so we max out the number of responses we'll
# give to an address in a single day.
#
# BAW: We wait until now to make this decision since our sender may
# not be self.msg.get_sender(), but I'm not sure this is right.
recip = self.returnaddr or self.msg.get_sender()
if not self.mlist.autorespondToSender(recip):
return
msg = Message.UserNotification(
recip,
self.mlist.GetBouncesEmail(),
_('The results of your email commands'),
lang=self.mlist.preferred_language)
msg.set_type('multipart/mixed')
msg.attach(results)
orig = MIMEMessage(self.msg)
msg.attach(orig)
msg.send(self.mlist)
class CommandRunner(Runner):
QDIR = mm_cfg.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'):
syslog('vette', 'Precedence: %s message discarded by: %s',
precedence, mlist.GetRequestEmail())
return 0
# Do replybot for commands
mlist.Load()
Replybot.process(mlist, msg, msgdata)
if mlist.autorespond_requests == 1:
syslog('vette', 'replied and discard')
# w/discard
return 0
# Now craft the response
res = Results(mlist, msg, msgdata)
# BAW: Not all the functions of this qrunner require the list to be
# locked. Still, it's more convenient to lock it here and now and
# deal with lock failures in one place.
try:
mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
except LockFile.TimeOutError:
# Oh well, try again later
return 1
# 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.
try:
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(mm_cfg.VERP_CONFIRM_REGEXP, msg.get('to', ''))
if mo:
res.do_command('confirm', (mo.group('cookie'),))
res.send_response()
mlist.Save()
finally:
mlist.Unlock()