1 # Copyright (C) 1998-2007 by the Free Software Foundation, Inc.
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; either version 2
6 # of the License, or (at your option) any later version.
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # GNU General Public License for more details.
13 # You should have received a copy of the GNU General Public License
14 # along with this program; if not, write to the Free Software
15 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 """-request robot command queue runner."""
19 # See the delivery diagram in IncomingRunner.py. This module handles all
20 # email destined for mylist-request, -join, and -leave. It no longer handles
21 # bounce messages (i.e. -admin or -bounces), nor does it handle mail to
28 from email
.Errors
import HeaderParseError
29 from email
.Header
import decode_header
, make_header
, Header
30 from email
.Iterators
import typed_subpart_iterator
31 from email
.MIMEMessage
import MIMEMessage
32 from email
.MIMEText
import MIMEText
34 from Mailman
import Message
35 from Mailman
import Utils
36 from Mailman
.Handlers
import Replybot
37 from Mailman
.app
.replybot
import autorespond_to_sender
38 from Mailman
.configuration
import config
39 from Mailman
.i18n
import _
40 from Mailman
.queue
import Runner
44 log
= logging
.getLogger('mailman.vette')
49 def __init__(self
, mlist
, msg
, msgdata
):
52 self
.msgdata
= msgdata
53 # Only set returnaddr if the response is to go to someone other than
54 # the address specified in the From: header (e.g. for the password
56 self
.returnaddr
= None
61 self
.subjcmdretried
= 0
63 # Extract the subject header and do RFC 2047 decoding. Note that
64 # Python 2.1's unicode() builtin doesn't call obj.__unicode__().
65 subj
= msg
.get('subject', '')
67 subj
= make_header(decode_header(subj
)).__unicode
__()
68 # TK: Currently we don't allow 8bit or multibyte in mail command.
69 subj
= subj
.encode('us-ascii')
70 # Always process the Subject: header first
71 self
.commands
.append(subj
)
72 except (HeaderParseError
, UnicodeError, LookupError):
73 # We couldn't parse it so ignore the Subject header
75 # Find the first text/plain part
77 for part
in typed_subpart_iterator(msg
, 'text', 'plain'):
79 if part
is None or part
is not msg
:
80 # Either there was no text/plain part or we ignored some
81 # non-text/plain parts.
82 self
.results
.append(_('Ignoring non-text/plain MIME parts'))
84 # E.g the outer Content-Type: was text/html
86 body
= part
.get_payload(decode
=True)
87 # text/plain parts better have string payloads
88 assert isinstance(body
, basestring
)
89 lines
= body
.splitlines()
90 # Use no more lines than specified
91 self
.commands
.extend(lines
[:config
.EMAIL_COMMANDS_MAX_LINES
])
92 self
.ignored
.extend(lines
[config
.EMAIL_COMMANDS_MAX_LINES
:])
95 # Now, process each line until we find an error. The first
96 # non-command line found stops processing.
98 for line
in self
.commands
:
99 if line
and line
.strip():
101 cmd
= args
.pop(0).lower()
102 stop
= self
.do_command(cmd
, args
)
107 def do_command(self
, cmd
, args
=None):
110 # Try to import a command handler module for this command
111 modname
= 'Mailman.Commands.cmd_' + cmd
114 handler
= sys
.modules
[modname
]
115 # ValueError can be raised if cmd has dots in it.
116 except (ImportError, ValueError):
117 # If we're on line zero, it was the Subject: header that didn't
118 # contain a command. It's possible there's a Re: prefix (or
119 # localized version thereof) on the Subject: line that's messing
120 # things up. Pop the prefix off and try again... once.
122 # If that still didn't work it isn't enough to stop processing.
123 # BAW: should we include a message that the Subject: was ignored?
124 if not self
.subjcmdretried
and args
:
125 self
.subjcmdretried
+= 1
127 return self
.do_command(cmd
, args
)
128 return self
.lineno
<> 0
129 return handler
.process(self
, args
)
131 def send_response(self
):
134 return [' ' + line
for line
in lines
]
135 # Quick exit for some commands which don't need a response
138 resp
= [Utils
.wrap(_("""\
139 The results of your email command are provided below.
140 Attached is your original message.
143 resp
.append(_('- Results:'))
144 resp
.extend(indent(self
.results
))
146 unprocessed
= [line
for line
in self
.commands
[self
.lineno
:]
147 if line
and line
.strip()]
149 resp
.append(_('\n- Unprocessed:'))
150 resp
.extend(indent(unprocessed
))
151 if not unprocessed
and not self
.results
:
152 # The user sent an empty message; return a helpful one.
153 resp
.append(Utils
.wrap(_("""\
154 No commands were found in this message.
155 To obtain instructions, send a message containing just the word "help".
158 resp
.append(_('\n- Ignored:'))
159 resp
.extend(indent(self
.ignored
))
160 resp
.append(_('\n- Done.\n\n'))
161 # Encode any unicode strings into the list charset, so we don't try to
162 # join unicode strings and invalid ASCII.
163 charset
= Utils
.GetCharSet(self
.msgdata
['lang'])
166 if isinstance(item
, unicode):
167 item
= item
.encode(charset
, 'replace')
168 encoded_resp
.append(item
)
169 results
= MIMEText(NL
.join(encoded_resp
), _charset
=charset
)
170 # Safety valve for mail loops with misconfigured email 'bots. We
171 # don't respond to commands sent with "Precedence: bulk|junk|list"
172 # unless they explicitly "X-Ack: yes", but not all mail 'bots are
173 # correctly configured, so we max out the number of responses we'll
174 # give to an address in a single day.
176 # BAW: We wait until now to make this decision since our sender may
177 # not be self.msg.get_sender(), but I'm not sure this is right.
178 recip
= self
.returnaddr
or self
.msg
.get_sender()
179 if not autorespond_to_sender(self
.mlist
, recip
, self
.msgdata
['lang']):
181 msg
= Message
.UserNotification(
183 self
.mlist
.GetBouncesEmail(),
184 _('The results of your email commands'),
185 lang
=self
.msgdata
['lang'])
186 msg
.set_type('multipart/mixed')
188 orig
= MIMEMessage(self
.msg
)
194 class CommandRunner(Runner
):
195 QDIR
= config
.CMDQUEUE_DIR
197 def _dispose(self
, mlist
, msg
, msgdata
):
198 # The policy here is similar to the Replybot policy. If a message has
199 # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard
200 # it to prevent replybot response storms.
201 precedence
= msg
.get('precedence', '').lower()
202 ack
= msg
.get('x-ack', '').lower()
203 if ack
<> 'yes' and precedence
in ('bulk', 'junk', 'list'):
204 log
.info('Precedence: %s message discarded by: %s',
205 precedence
, mlist
.GetRequestEmail())
207 # Do replybot for commands
209 Replybot
.process(mlist
, msg
, msgdata
)
210 if mlist
.autorespond_requests
== 1:
211 log
.info('replied and discard')
214 # Now craft the response
215 res
= Results(mlist
, msg
, msgdata
)
216 # This message will have been delivered to one of mylist-request,
217 # mylist-join, or mylist-leave, and the message metadata will contain
218 # a key to which one was used.
219 if msgdata
.get('torequest'):
221 elif msgdata
.get('tojoin'):
222 res
.do_command('join')
223 elif msgdata
.get('toleave'):
224 res
.do_command('leave')
225 elif msgdata
.get('toconfirm'):
226 mo
= re
.match(config
.VERP_CONFIRM_REGEXP
, msg
.get('to', ''))
228 res
.do_command('confirm', (mo
.group('cookie'),))