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)
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
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."""
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
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
48 log
= logging
.getLogger('mailman.vette')
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', ''))
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', '')
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
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.
88 for part
in typed_subpart_iterator(msg
, 'text', 'plain'):
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
)
95 # There was no text/plain part to be found.
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
:])
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()
114 # Ensure that all the parts are unicodes. Since we only accept
115 # ASCII commands and arguments, ignore anything else.
117 if isinstance(part
, str)
118 else part
.decode('ascii', 'ignore'))
124 @implementer(IEmailResults
)
126 """The email command results."""
128 def __init__(self
, charset
='us-ascii'):
129 self
._output
= StringIO()
130 self
.charset
= charset
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
)
141 value
= self
._output
.getvalue()
142 assert isinstance(value
, str), 'Not a string: %r' % 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
)
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
)
168 # Now craft the response and process the command lines.
169 charset
= msg
.get_param('charset')
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
)
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
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
)
197 print(_('No such command: $command_name'), file=results
)
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
:
205 # All done. Strip blank lines and send the response.
206 lines
= [line
.strip() for line
in finder
.command_lines
if line
]
208 print(_('\n- Unprocessed:'), file=results
)
210 print(line
, file=results
)
211 lines
= [line
.strip() for line
in finder
.ignored_lines
if line
]
213 print(_('\n- Ignored:'), file=results
)
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'),
224 cte
= msg
.get('content-transfer-encoding')
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
230 reply_body
= str(results
)
231 for charset
in (results
.charset
, 'us-ascii', 'latin-1'):
233 reply_body
.encode(charset
)
239 reply
.set_payload(reply_body
, charset
=charset
)