PageRenderTime 46ms CodeModel.GetById 22ms app.highlight 20ms RepoModel.GetById 1ms app.codeStats 0ms

/src/mailman/runners/command.py

https://gitlab.com/yamhill/mailman
Python | 234 lines | 184 code | 13 blank | 37 comment | 13 complexity | cbc099f3917623551ec7fe714067890c MD5 | raw file
  1# Copyright (C) 1998-2016 by the Free Software Foundation, Inc.
  2#
  3# This file is part of GNU Mailman.
  4#
  5# GNU Mailman is free software: you can redistribute it and/or modify it under
  6# the terms of the GNU General Public License as published by the Free
  7# Software Foundation, either version 3 of the License, or (at your option)
  8# any later version.
  9#
 10# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
 11# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 12# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
 13# more details.
 14#
 15# You should have received a copy of the GNU General Public License along with
 16# GNU Mailman.  If not, see <http://www.gnu.org/licenses/>.
 17
 18"""-request robot command runner."""
 19
 20# See the delivery diagram in IncomingRunner.py.  This module handles all
 21# email destined for mylist-request, -join, and -leave.  It no longer handles
 22# bounce messages (i.e. -admin or -bounces), nor does it handle mail to
 23# -owner.
 24
 25import re
 26import logging
 27
 28from contextlib import suppress
 29from email.errors import HeaderParseError
 30from email.header import decode_header, make_header
 31from email.iterators import typed_subpart_iterator
 32from io import StringIO
 33from mailman import public
 34from mailman.config import config
 35from mailman.core.i18n import _
 36from mailman.core.runner import Runner
 37from mailman.email.message import UserNotification
 38from mailman.interfaces.command import ContinueProcessing, IEmailResults
 39from mailman.interfaces.languages import ILanguageManager
 40from zope.component import getUtility
 41from zope.interface import implementer
 42
 43
 44NL = '\n'
 45log = logging.getLogger('mailman.vette')
 46
 47
 48class CommandFinder:
 49    """Generate commands from the content of a message."""
 50
 51    def __init__(self, msg, msgdata, results):
 52        self.command_lines = []
 53        self.ignored_lines = []
 54        self.processed_lines = []
 55        # Depending on where the message was destined to, add some implicit
 56        # commands.  For example, if this was sent to the -join or -leave
 57        # addresses, it's the same as if 'join' or 'leave' commands were sent
 58        # to the -request address.
 59        subaddress = msgdata.get('subaddress')
 60        if subaddress == 'join':
 61            self.command_lines.append('join')
 62        elif subaddress == 'leave':
 63            self.command_lines.append('leave')
 64        elif subaddress == 'confirm':
 65            mo = re.match(config.mta.verp_confirm_regexp, msg.get('to', ''))
 66            if mo:
 67                self.command_lines.append('confirm ' + mo.group('cookie'))
 68        # Extract the subject header and do RFC 2047 decoding.
 69        raw_subject = msg.get('subject', '')
 70        try:
 71            subject = str(make_header(decode_header(raw_subject)))
 72            # Mail commands must be ASCII.
 73            self.command_lines.append(subject.encode('us-ascii'))
 74        except (HeaderParseError, UnicodeError, LookupError):
 75            # The Subject header was unparseable or not ASCII.  If the raw
 76            # subject is a unicode object, convert it to ASCII ignoring all
 77            # bogus characters.  Otherwise, there's nothing in the subject
 78            # that we can use.
 79            if isinstance(raw_subject, str):
 80                safe_subject = raw_subject.encode('us-ascii', 'ignore')
 81                self.command_lines.append(safe_subject)
 82        # Find the first text/plain part of the message.
 83        part = None
 84        for part in typed_subpart_iterator(msg, 'text', 'plain'):
 85            break
 86        if part is None or part is not msg:
 87            # Either there was no text/plain part or we ignored some
 88            # non-text/plain parts.
 89            print(_('Ignoring non-text/plain MIME parts'), file=results)
 90        if part is None:
 91            # There was no text/plain part to be found.
 92            return
 93        body = part.get_payload()
 94        # text/plain parts better have string payloads.
 95        assert isinstance(body, (bytes, str)), 'Non-string decoded payload'
 96        lines = body.splitlines()
 97        # Use no more lines than specified
 98        max_lines = int(config.mailman.email_commands_max_lines)
 99        self.command_lines.extend(lines[:max_lines])
100        self.ignored_lines.extend(lines[max_lines:])
101
102    def __iter__(self):
103        """Return each command line, split into space separated arguments."""
104        while self.command_lines:
105            line = self.command_lines.pop(0)
106            self.processed_lines.append(line)
107            parts = line.strip().split()
108            if len(parts) == 0:
109                continue
110            # Ensure that all the parts are unicodes.  Since we only accept
111            # ASCII commands and arguments, ignore anything else.
112            parts = [(part
113                      if isinstance(part, str)
114                      else part.decode('ascii', 'ignore'))
115                     for part in parts]
116            yield parts
117
118
119@public
120@implementer(IEmailResults)
121class Results:
122    """The email command results."""
123
124    def __init__(self, charset='us-ascii'):
125        self._output = StringIO()
126        self.charset = charset
127        print(_("""\
128The results of your email command are provided below.
129"""), file=self._output)
130
131    def write(self, text):
132        if isinstance(text, bytes):
133            text = text.decode(self.charset, 'ignore')
134        self._output.write(text)
135
136    def __str__(self):
137        value = self._output.getvalue()
138        assert isinstance(value, str), 'Not a string: %r' % value
139        return value
140
141
142@public
143class CommandRunner(Runner):
144    """The email command runner."""
145
146    def _dispose(self, mlist, msg, msgdata):
147        message_id = msg.get('message-id', 'n/a')
148        # The policy here is similar to the Replybot policy.  If a message has
149        # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard
150        # the command message.
151        precedence = msg.get('precedence', '').lower()
152        ack = msg.get('x-ack', '').lower()
153        if ack != 'yes' and precedence in ('bulk', 'junk', 'list'):
154            log.info('%s Precedence: %s message discarded by: %s',
155                     message_id, precedence, mlist.request_address)
156            return False
157        # Do replybot for commands.
158        replybot = config.handlers['replybot']
159        replybot.process(mlist, msg, msgdata)
160        if mlist.autorespond_requests == 1:
161            # Respond and discard.
162            log.info('%s -request message replied and discard', message_id)
163            return False
164        # Now craft the response and process the command lines.
165        charset = msg.get_param('charset')
166        if charset is None:
167            charset = 'us-ascii'
168        results = Results(charset)
169        # Include just a few key pieces of information from the original: the
170        # sender, date, and message id.
171        print(_('- Original message details:'), file=results)
172        subject = msg.get('subject', 'n/a')                      # noqa: F841
173        date = msg.get('date', 'n/a')                            # noqa: F841
174        from_ = msg.get('from', 'n/a')                           # noqa: F841
175        print(_('    From: $from_'), file=results)
176        print(_('    Subject: $subject'), file=results)
177        print(_('    Date: $date'), file=results)
178        print(_('    Message-ID: $message_id'), file=results)
179        print(_('\n- Results:'), file=results)
180        finder = CommandFinder(msg, msgdata, results)
181        for parts in finder:
182            command = None
183            # Try to find a command on this line.  There may be a Re: prefix
184            # (possibly internationalized) so try with the first and second
185            # words on the line.
186            if len(parts) > 0:
187                command_name = parts.pop(0)
188                command = config.commands.get(command_name)
189            if command is None and len(parts) > 0:
190                command_name = parts.pop(0)
191                command = config.commands.get(command_name)
192            if command is None:
193                print(_('No such command: $command_name'), file=results)
194            else:
195                status = command.process(
196                    mlist, msg, msgdata, parts, results)
197                assert status in ContinueProcessing, (
198                    'Invalid status: %s' % status)
199                if status == ContinueProcessing.no:
200                    break
201        # All done.  Strip blank lines and send the response.
202        lines = [line.strip() for line in finder.command_lines if line]
203        if len(lines) > 0:
204            print(_('\n- Unprocessed:'), file=results)
205            for line in lines:
206                print(line, file=results)
207        lines = [line.strip() for line in finder.ignored_lines if line]
208        if len(lines) > 0:
209            print(_('\n- Ignored:'), file=results)
210            for line in lines:
211                print(line, file=results)
212        print(_('\n- Done.'), file=results)
213        # Send a reply, but do not attach the original message.  This is a
214        # compromise because the original message is often helpful in tracking
215        # down problems, but it's also a vector for backscatter spam.
216        language = getUtility(ILanguageManager)[msgdata['lang']]
217        reply = UserNotification(msg.sender, mlist.bounces_address,
218                                 _('The results of your email commands'),
219                                 lang=language)
220        cte = msg.get('content-transfer-encoding')
221        if cte is not None:
222            reply['Content-Transfer-Encoding'] = cte
223        # Find a charset for the response body.  Try the original message's
224        # charset first, then ascii, then latin-1 and finally falling back to
225        # utf-8.
226        reply_body = str(results)
227        for charset in (results.charset, 'us-ascii', 'latin-1'):
228            with suppress(UnicodeError):
229                reply_body.encode(charset)
230                break
231        else:
232            charset = 'utf-8'
233        reply.set_payload(reply_body, charset=charset)
234        reply.send(mlist)