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