/src/mailman/runners/command.py
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)