pgp_mime.pgp.verify() no longer needs deepcopy().
[pygrader.git] / pygrader / mailpipe.py
1 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of pygrader.
4 #
5 # pygrader is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pygrader.  If not, see <http://www.gnu.org/licenses/>.
16
17 from __future__ import absolute_import
18
19 from email import message_from_file as _message_from_file
20 from email.header import decode_header as _decode_header
21 import hashlib as _hashlib
22 import locale as _locale
23 import mailbox as _mailbox
24 import os as _os
25 import os.path as _os_path
26 import sys as _sys
27 import time as _time
28
29 from pgp_mime import verify as _verify
30 from lxml import etree as _etree
31
32 from . import LOG as _LOG
33 from .color import standard_colors as _standard_colors
34 from .color import color_string as _color_string
35 from .extract_mime import extract_mime as _extract_mime
36 from .extract_mime import message_time as _message_time
37 from .storage import assignment_path as _assignment_path
38 from .storage import set_late as _set_late
39
40
41 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
42              output=None, max_late=0, use_color=None, dry_run=False, **kwargs):
43     """Run from procmail to sort incomming submissions
44
45     For example, you can setup your ``.procmailrc`` like this::
46
47       SHELL=/bin/sh
48       DEFAULT=$MAIL
49       MAILDIR=$HOME/mail
50       DEFAULT=$MAILDIR/mbox
51       LOGFILE=$MAILDIR/procmail.log
52       #VERBOSE=yes
53       PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
54
55       # Grab all incoming homeworks emails.  This rule eats matching emails
56       # (i.e. no further procmail processing).
57       :0
58       * ^Subject:.*\[phys160-sub]
59       | "$PYGRADE_MAILPIPE" mailpipe
60
61     If you don't want procmail to eat the message, you can use the
62     ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
63
64     >>> from asyncore import loop
65     >>> from io import StringIO
66     >>> from pgp_mime.email import encodedMIMEText
67     >>> from pygrader.test.course import StubCourse
68     >>> from pygrader.test.client import MessageSender
69     >>> from pygrader.test.server import SMTPServer
70
71     Messages with unrecognized ``Return-Path``\s are silently dropped:
72
73     >>> course = StubCourse()
74     >>> def process(peer, mailfrom, rcpttos, data):
75     ...     mailpipe(
76     ...         basedir=course.basedir, course=course.course,
77     ...         stream=StringIO(data), output=course.mailbox)
78     >>> message = encodedMIMEText('The answer is 42.')
79     >>> message['Message-ID'] = '<123.456@home.net>'
80     >>> message['Return-Path'] = '<invalid.return.path@home.net>'
81     >>> message['Received'] = (
82     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
83     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
84     ...     'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
85     >>> message['From'] = 'Billy B <bb@greyhavens.net>'
86     >>> message['To'] = 'S <eye@tower.edu>'
87     >>> message['Subject'] = 'assignment 1 submission'
88     >>> messages = [message]
89     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
90     >>> loop()
91     >>> course.print_tree()  # doctest: +REPORT_UDIFF
92     course.conf
93
94     If we add a valid ``Return-Path``, we get the expected delivery:
95
96     >>> server = SMTPServer(
97     ...     ('localhost', 1025), None, process=process, count=1)
98     >>> del message['Return-Path']
99     >>> message['Return-Path'] = '<bb@greyhavens.net>'
100     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
101     >>> loop()
102     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
103     Bilbo_Baggins
104     Bilbo_Baggins/Assignment_1
105     Bilbo_Baggins/Assignment_1/mail
106     Bilbo_Baggins/Assignment_1/mail/cur
107     Bilbo_Baggins/Assignment_1/mail/new
108     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
109     Bilbo_Baggins/Assignment_1/mail/tmp
110     course.conf
111     mail
112     mail/cur
113     mail/new
114     mail/new/...
115     mail/tmp
116
117     The last ``Received`` is used to timestamp the message:
118
119     >>> server = SMTPServer(
120     ...     ('localhost', 1025), None, process=process, count=1)
121     >>> del message['Message-ID']
122     >>> message['Message-ID'] = '<abc.def@home.net>'
123     >>> del message['Received']
124     >>> message['Received'] = (
125     ...     'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
126     ...     'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
127     ...     'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
128     >>> message['Received'] = (
129     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
130     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
131     ...     'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
132     >>> messages = [message]
133     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
134     >>> loop()
135     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
136     Bilbo_Baggins
137     Bilbo_Baggins/Assignment_1
138     Bilbo_Baggins/Assignment_1/late
139     Bilbo_Baggins/Assignment_1/mail
140     Bilbo_Baggins/Assignment_1/mail/cur
141     Bilbo_Baggins/Assignment_1/mail/new
142     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
143     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
144     Bilbo_Baggins/Assignment_1/mail/tmp
145     course.conf
146     mail
147     mail/cur
148     mail/new
149     mail/new/...
150     mail/new/...
151     mail/tmp
152
153     >>> course.cleanup()
154     """
155     if stream is None:
156         stream = _sys.stdin
157     for msg,person,assignment,time in _load_messages(
158         course=course, stream=stream, mailbox=mailbox, input_=input_,
159         output=output, use_color=use_color, dry_run=dry_run):
160         assignment_path = _assignment_path(basedir, assignment, person)
161         _save_local_message_copy(
162             msg=msg, person=person, assignment_path=assignment_path,
163             use_color=use_color, dry_run=dry_run)
164         _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
165         _check_late(
166             basedir=basedir, assignment=assignment, person=person, time=time,
167             max_late=max_late, use_color=use_color, dry_run=dry_run)
168
169 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
170                    use_color=None, dry_run=False):
171     if mailbox is None:
172         mbox = None
173         messages = [(None,_message_from_file(stream))]
174         if output is not None:
175             ombox = _mailbox.Maildir(output, factory=None, create=True)
176     elif mailbox == 'mbox':
177         mbox = _mailbox.mbox(input_, factory=None, create=False)
178         messages = mbox.items()
179         if output is not None:
180             ombox = _mailbox.mbox(output, factory=None, create=True)
181     elif mailbox == 'maildir':
182         mbox = _mailbox.Maildir(input_, factory=None, create=False)
183         messages = mbox.items()
184         if output is not None:
185             ombox = _mailbox.Maildir(output, factory=None, create=True)
186     else:
187         raise ValueError(mailbox)
188     for key,msg in messages:
189         ret = _parse_message(
190             course=course, msg=msg, use_color=use_color)
191         if ret:
192             if output is not None and dry_run is False:
193                 # move message from input mailbox to output mailbox
194                 ombox.add(msg)
195                 if mbox is not None:
196                     del mbox[key]
197             yield ret
198
199 def _parse_message(course, msg, use_color=None):
200     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
201     mid = msg['Message-ID']
202     sender = msg['Return-Path']  # RFC 822
203     if sender is None:
204         _LOG.debug(_color_string(
205                 string='no Return-Path in {}'.format(mid), color=lowlight))
206         return None
207     sender = sender[1:-1]  # strip wrapping '<' and '>'
208
209     people = list(course.find_people(email=sender))
210     if len(people) == 0:
211         _LOG.warn(_color_string(
212                 string='no person found to match {}'.format(sender),
213                 color=bad))
214         return None
215     if len(people) > 1:
216         _LOG.warn(_color_string(
217                 string='multiple people match {} ({})'.format(
218                     sender, ', '.join(str(p) for p in people)),
219                 color=bad))
220         return None
221     person = people[0]
222
223     if person.pgp_key:
224         msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
225         if msg is None:
226             return None
227
228     if msg['Subject'] is None:
229         _LOG.warn(_color_string(
230                 string='no subject in {}'.format(mid), color=bad))
231         return None
232     parts = _decode_header(msg['Subject'])
233     if len(parts) != 1:
234         _LOG.warn(_color_string(
235                 string='multi-part header {}'.format(parts), color=bad))
236         return None
237     subject,encoding = parts[0]
238     if encoding is None:
239         encoding = 'ascii'
240     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
241     subject = subject.lower().replace('#', '')
242     for assignment in course.assignments:
243         if _match_assignment(assignment, subject):
244             break
245     if not _match_assignment(assignment, subject):
246         _LOG.warn(_color_string(
247                 string='no assignment found in {}'.format(repr(subject)),
248                 color=bad))
249         return None
250
251     time = _message_time(message=msg, use_color=use_color)
252     return (msg, person, assignment, time)
253
254 def _match_assignment(assignment, subject):
255     return assignment.name.lower() in subject
256
257 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
258                              dry_run=False):
259     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
260     try:
261         _os.makedirs(assignment_path)
262     except OSError:
263         pass
264     mpath = _os_path.join(assignment_path, 'mail')
265     try:
266         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
267     except _mailbox.NoSuchMailboxError as e:
268         _LOG.debug(_color_string(
269                 string='could not open mailbox at {}'.format(mpath),
270                 color=bad))
271         mbox = None
272         new_msg = True
273     else:
274         new_msg = True
275         for other_msg in mbox:
276             if other_msg['Message-ID'] == msg['Message-ID']:
277                 new_msg = False
278                 break
279     if new_msg:
280         _LOG.debug(_color_string(
281                 string='saving email from {} to {}'.format(
282                     person, assignment_path), color=good))
283         if mbox is not None and not dry_run:
284             mdmsg = _mailbox.MaildirMessage(msg)
285             mdmsg.add_flag('S')
286             mbox.add(mdmsg)
287             mbox.close()
288     else:
289         _LOG.debug(_color_string(
290                 string='already found {} in {}'.format(
291                     msg['Message-ID'], mpath), color=good))
292
293 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
294                 dry_run=False):
295     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
296     if time > assignment.due + max_late:
297         dt = time - assignment.due
298         _LOG.warn(_color_string(
299                 string='{} {} late by {} seconds ({} hours)'.format(
300                     person.name, assignment.name, dt, dt/3600.),
301                 color=bad))
302         if not dry_run:
303             _set_late(basedir=basedir, assignment=assignment, person=person)
304
305 def _get_verified_message(message, pgp_key, use_color=None):
306     """
307
308     >>> from pgp_mime import sign, encodedMIMEText
309
310     The student composes a message...
311
312     >>> message = encodedMIMEText('1.23 joules')
313
314     ... and signs it (with the pgp-mime test key).
315
316     >>> signed = sign(message, signers=['pgp-mime-test'])
317
318     As it is being delivered, the message picks up extra headers.
319
320     >>> signed['Message-ID'] = '<01234567@home.net>'
321     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
322     >>> signed['Received'] = 'from smtp.home.net ...'
323
324     We check that the message is signed, and that it is signed by the
325     appropriate key.
326
327     >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
328     >>> print(our_message.as_string())  # doctest: +REPORT_UDIFF
329     Content-Type: text/plain; charset="us-ascii"
330     MIME-Version: 1.0
331     Content-Transfer-Encoding: 7bit
332     Content-Disposition: inline
333     Message-ID: <01234567@home.net>
334     Received: from smtp.mail.uu.edu ...
335     Received: from smtp.home.net ...
336     <BLANKLINE>
337     1.23 joules
338
339     If it is signed, but not by the right key, we get ``None``.
340
341     >>> print(_get_verified_message(signed, pgp_key='01234567'))
342     None
343
344     If it is not signed at all, we get ``None``.
345
346     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
347     None
348     """
349     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
350     mid = message['message-id']
351     try:
352         decrypted,verified,result = _verify(message=message)
353     except (ValueError, AssertionError):
354         _LOG.warn(_color_string(
355                 string='could not verify {} (not signed?)'.format(mid),
356                 color=bad))
357         return None
358     _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
359     tree = _etree.fromstring(result.replace(b'\x00', b''))
360     match = None
361     for signature in tree.findall('.//signature'):
362         for fingerprint in signature.iterchildren('fpr'):
363             if fingerprint.text.endswith(pgp_key):
364                 match = signature
365                 break
366     if match is None:
367         _LOG.warn(_color_string(
368                 string='{} is not signed by the expected key'.format(mid),
369                 color=bad))
370         return None
371     if not verified:
372         sumhex = list(signature.iterchildren('summary'))[0].get('value')
373         summary = int(sumhex, 16)
374         if summary != 0:
375             _LOG.warn(_color_string(
376                     string='{} has an unverified signature'.format(mid),
377                     color=bad))
378             return None
379         # otherwise, we may have an untrusted key.  We'll count that
380         # as verified here, because the caller is explicity looking
381         # for signatures by this fingerprint.
382     for k,v in message.items(): # copy over useful headers
383         if k.lower() not in ['content-type',
384                              'mime-version',
385                              'content-disposition',
386                              ]:
387             decrypted[k] = v
388     return decrypted