# Copyright (C) 1998-2018 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. """-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 re import sys 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.Header import decode_header, make_header, Header from email.Errors import HeaderParseError from email.Iterators import typed_subpart_iterator from email.MIMEText import MIMEText from email.MIMEMessage import MIMEMessage NL = '\n' CONTINUE = 0 STOP = 1 BADCMD = 2 BADSUBJ = 3 try: True, False except NameError: True = 1 False = 0 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', '') try: subj = make_header(decode_header(subj)).__unicode__() # TK: Currently we don't allow 8bit or multibyte in mail command. # MAS: However, an l10n 'Re:' may contain non-ascii so ignore it. subj = subj.encode('us-ascii', 'ignore') # Always process the Subject: header first self.commands.append(subj) except (HeaderParseError, UnicodeError, LookupError): # We couldn't parse it so ignore the Subject header pass # 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(decode=True) if (part.get_content_charset(None)): body = unicode(body, part.get_content_charset(), errors='replace').encode( Utils.GetCharSet(self.msgdata['lang']), errors='replace') # 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. found = BADCMD ret = CONTINUE for line in self.commands: if line and line.strip(): args = line.split() cmd = args.pop(0).lower() ret = self.do_command(cmd, args) if ret == STOP or ret == CONTINUE: found = ret self.lineno += 1 if ret == STOP or ret == BADCMD: break return found 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. # and KeyError if cmd is otherwise good but ends with a dot. # and TypeError if cmd has a null byte. except (ImportError, ValueError, KeyError, TypeError): # 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. # # At least one MUA (163.com web mail) has been observed that # inserts 'Re:' with no following space, so try to account for # that too. # # If that still didn't work it isn't enough to stop processing. # BAW: should we include a message that the Subject: was ignored? # # But first, be sure we're looking at the Subject: and not past # it already. if self.lineno != 0: return BADCMD if self.subjcmdretried < 1: self.subjcmdretried += 1 if re.search('^.*:.+', cmd): cmd = re.sub('.*:', '', cmd).lower() return self.do_command(cmd, args) if self.subjcmdretried < 2 and args: self.subjcmdretried += 1 cmd = args.pop(0).lower() return self.do_command(cmd, args) return BADSUBJ if handler.process(self, args): return STOP else: return CONTINUE 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 and mm_cfg.RESPONSE_INCLUDE_LEVEL >= 2: 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 and mm_cfg.RESPONSE_INCLUDE_LEVEL >= 2: 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.msgdata['lang']) 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, self.msgdata['lang']): return msg = Message.UserNotification( recip, self.mlist.GetOwnerEmail(), _('The results of your email commands'), lang=self.msgdata['lang']) msg.set_type('multipart/mixed') msg.attach(results) if mm_cfg.RESPONSE_INCLUDE_LEVEL == 1: self.msg.set_payload( _('Message body suppressed by Mailman site configuration\n')) if mm_cfg.RESPONSE_INCLUDE_LEVEL == 0: orig = MIMEText(_( 'Original message suppressed by Mailman site configuration\n' ), _charset=charset) else: 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 False # Do replybot for commands mlist.Load() Replybot.process(mlist, msg, msgdata) if mlist.autorespond_requests == 1: syslog('vette', 'replied and discard') # w/discard return False # 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 True # 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: ret = BADCMD if msgdata.get('torequest'): ret = res.process() elif msgdata.get('tojoin'): ret = res.do_command('join') elif msgdata.get('toleave'): ret = res.do_command('leave') elif msgdata.get('toconfirm'): mo = re.match(mm_cfg.VERP_CONFIRM_REGEXP, msg.get('to', '')) if mo: ret = res.do_command('confirm', (mo.group('cookie'),)) if ret == BADCMD and mm_cfg.DISCARD_MESSAGE_WITH_NO_COMMAND: syslog('vette', 'No command, message discarded, msgid: %s', msg.get('message-id', 'n/a')) else: res.send_response() mlist.Save() finally: mlist.Unlock()