mailpipe: split initial _parse_message processing into sub-functions.
[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 from email.utils import formatdate as _formatdate
22 import hashlib as _hashlib
23 import locale as _locale
24 import mailbox as _mailbox
25 import os as _os
26 import os.path as _os_path
27 import sys as _sys
28 import time as _time
29
30 from pgp_mime import verify as _verify
31 from lxml import etree as _etree
32
33 from . import LOG as _LOG
34 from .color import standard_colors as _standard_colors
35 from .color import color_string as _color_string
36 from .email import construct_response as _construct_response
37 from .extract_mime import extract_mime as _extract_mime
38 from .extract_mime import message_time as _message_time
39 from .model.person import Person as _Person
40 from .storage import assignment_path as _assignment_path
41 from .storage import set_late as _set_late
42
43
44 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
45              output=None, max_late=0, respond=None, use_color=None,
46              dry_run=False, **kwargs):
47     """Run from procmail to sort incomming submissions
48
49     For example, you can setup your ``.procmailrc`` like this::
50
51       SHELL=/bin/sh
52       DEFAULT=$MAIL
53       MAILDIR=$HOME/mail
54       DEFAULT=$MAILDIR/mbox
55       LOGFILE=$MAILDIR/procmail.log
56       #VERBOSE=yes
57       PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
58
59       # Grab all incoming homeworks emails.  This rule eats matching emails
60       # (i.e. no further procmail processing).
61       :0
62       * ^Subject:.*\[phys160-sub]
63       | "$PYGRADE_MAILPIPE" mailpipe
64
65     If you don't want procmail to eat the message, you can use the
66     ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
67
68     >>> from asyncore import loop
69     >>> from io import StringIO
70     >>> from pgp_mime.email import encodedMIMEText
71     >>> from pygrader.test.course import StubCourse
72     >>> from pygrader.test.client import MessageSender
73     >>> from pygrader.test.server import SMTPServer
74
75     Messages with unrecognized ``Return-Path``\s are silently dropped:
76
77     >>> course = StubCourse()
78     >>> def process(peer, mailfrom, rcpttos, data):
79     ...     mailpipe(
80     ...         basedir=course.basedir, course=course.course,
81     ...         stream=StringIO(data), output=course.mailbox)
82     >>> message = encodedMIMEText('The answer is 42.')
83     >>> message['Message-ID'] = '<123.456@home.net>'
84     >>> message['Return-Path'] = '<invalid.return.path@home.net>'
85     >>> message['Received'] = (
86     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
87     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
88     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
89     >>> message['From'] = 'Billy B <bb@greyhavens.net>'
90     >>> message['To'] = 'phys101 <phys101@tower.edu>'
91     >>> message['Subject'] = 'assignment 1 submission'
92     >>> messages = [message]
93     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
94     >>> loop()
95     >>> course.print_tree()  # doctest: +REPORT_UDIFF
96     course.conf
97
98     If we add a valid ``Return-Path``, we get the expected delivery:
99
100     >>> server = SMTPServer(
101     ...     ('localhost', 1025), None, process=process, count=1)
102     >>> del message['Return-Path']
103     >>> message['Return-Path'] = '<bb@greyhavens.net>'
104     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
105     >>> loop()
106     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
107     Bilbo_Baggins
108     Bilbo_Baggins/Assignment_1
109     Bilbo_Baggins/Assignment_1/mail
110     Bilbo_Baggins/Assignment_1/mail/cur
111     Bilbo_Baggins/Assignment_1/mail/new
112     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
113     Bilbo_Baggins/Assignment_1/mail/tmp
114     course.conf
115     mail
116     mail/cur
117     mail/new
118     mail/new/...
119     mail/tmp
120
121     The last ``Received`` is used to timestamp the message:
122
123     >>> server = SMTPServer(
124     ...     ('localhost', 1025), None, process=process, count=1)
125     >>> del message['Message-ID']
126     >>> message['Message-ID'] = '<abc.def@home.net>'
127     >>> del message['Received']
128     >>> message['Received'] = (
129     ...     'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
130     ...     'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
131     ...     'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
132     >>> message['Received'] = (
133     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
134     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
135     ...     'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
136     >>> messages = [message]
137     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
138     >>> loop()
139     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
140     Bilbo_Baggins
141     Bilbo_Baggins/Assignment_1
142     Bilbo_Baggins/Assignment_1/late
143     Bilbo_Baggins/Assignment_1/mail
144     Bilbo_Baggins/Assignment_1/mail/cur
145     Bilbo_Baggins/Assignment_1/mail/new
146     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
147     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
148     Bilbo_Baggins/Assignment_1/mail/tmp
149     course.conf
150     mail
151     mail/cur
152     mail/new
153     mail/new/...
154     mail/new/...
155     mail/tmp
156
157     You can send receipts to the acknowledge incoming messages, which
158     includes warnings about dropped messages (except for messages
159     without ``Return-Path`` and messages where the ``Return-Path``
160     email belongs to multiple ``People``.  Both of these cases should
161     only come from problems with pygrader configuration).
162
163     Response to a successful submission:
164
165     >>> def respond(message):
166     ...     print('respond with:\\n{}'.format(message.as_string()))
167     >>> def process(peer, mailfrom, rcpttos, data):
168     ...     mailpipe(
169     ...         basedir=course.basedir, course=course.course,
170     ...         stream=StringIO(data), output=course.mailbox,
171     ...         respond=respond)
172     >>> server = SMTPServer(
173     ...     ('localhost', 1025), None, process=process, count=1)
174     >>> del message['Message-ID']
175     >>> message['Message-ID'] = '<hgi.jlk@home.net>'
176     >>> messages = [message]
177     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
178     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
179     respond with:
180     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
181     MIME-Version: 1.0
182     Content-Disposition: inline
183     Date: ...
184     From: Robot101 <phys101@tower.edu>
185     Reply-to: Robot101 <phys101@tower.edu>
186     To: Bilbo Baggins <bb@shire.org>
187     Subject: received Assignment 1 submission
188     <BLANKLINE>
189     --===============...==
190     Content-Type: multipart/mixed; boundary="===============...=="
191     MIME-Version: 1.0
192     <BLANKLINE>
193     --===============...==
194     Content-Type: text/plain; charset="us-ascii"
195     MIME-Version: 1.0
196     Content-Transfer-Encoding: 7bit
197     Content-Disposition: inline
198     <BLANKLINE>
199     Billy,
200     <BLANKLINE>
201     We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
202     <BLANKLINE>
203     Yours,
204     phys-101 robot
205     --===============...==
206     Content-Type: message/rfc822
207     MIME-Version: 1.0
208     <BLANKLINE>
209     Content-Type: text/plain; charset="us-ascii"
210     MIME-Version: 1.0
211     Content-Transfer-Encoding: 7bit
212     Content-Disposition: inline
213     From: Billy B <bb@greyhavens.net>
214     To: phys101 <phys101@tower.edu>
215     Subject: assignment 1 submission
216     Return-Path: <bb@greyhavens.net>
217     Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
218     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
219     Message-ID: <hgi.jlk@home.net>
220     <BLANKLINE>
221     The answer is 42.
222     --===============...==--
223     --===============...==
224     MIME-Version: 1.0
225     Content-Transfer-Encoding: 7bit
226     Content-Description: OpenPGP digital signature
227     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
228     <BLANKLINE>
229     -----BEGIN PGP SIGNATURE-----
230     Version: GnuPG v2.0.19 (GNU/Linux)
231     <BLANKLINE>
232     ...
233     -----END PGP SIGNATURE-----
234     <BLANKLINE>
235     --===============...==--
236
237     Response to a submission on an unsubmittable assignment:
238
239     >>> server = SMTPServer(
240     ...     ('localhost', 1025), None, process=process, count=1)
241     >>> del message['Subject']
242     >>> message['Subject'] = 'attendance 1 submission'
243     >>> messages = [message]
244     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
245     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
246     respond with:
247     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
248     MIME-Version: 1.0
249     Content-Disposition: inline
250     Date: ...
251     From: Robot101 <phys101@tower.edu>
252     Reply-to: Robot101 <phys101@tower.edu>
253     To: Bilbo Baggins <bb@shire.org>
254     Subject: received invalid Attendance 1 submission
255     <BLANKLINE>
256     --===============...==
257     Content-Type: multipart/mixed; boundary="===============...=="
258     MIME-Version: 1.0
259     <BLANKLINE>
260     --===============...==
261     Content-Type: text/plain; charset="us-ascii"
262     MIME-Version: 1.0
263     Content-Transfer-Encoding: 7bit
264     Content-Disposition: inline
265     <BLANKLINE>
266     Billy,
267     <BLANKLINE>
268     We received your submission for Attendance 1, but you are not allowed
269     to submit that assignment via email.
270     <BLANKLINE>
271     Yours,
272     phys-101 robot
273     --===============...==
274     Content-Type: message/rfc822
275     MIME-Version: 1.0
276     <BLANKLINE>
277     Content-Type: text/plain; charset="us-ascii"
278     MIME-Version: 1.0
279     Content-Transfer-Encoding: 7bit
280     Content-Disposition: inline
281     From: Billy B <bb@greyhavens.net>
282     To: phys101 <phys101@tower.edu>
283     Return-Path: <bb@greyhavens.net>
284     Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
285     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
286     Message-ID: <hgi.jlk@home.net>
287     Subject: attendance 1 submission
288     <BLANKLINE>
289     The answer is 42.
290     --===============...==--
291     --===============...==
292     MIME-Version: 1.0
293     Content-Transfer-Encoding: 7bit
294     Content-Description: OpenPGP digital signature
295     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
296     <BLANKLINE>
297     -----BEGIN PGP SIGNATURE-----
298     Version: GnuPG v2.0.19 (GNU/Linux)
299     <BLANKLINE>
300     ...
301     -----END PGP SIGNATURE-----
302     <BLANKLINE>
303     --===============...==--
304
305     Response to a bad subject:
306
307     >>> server = SMTPServer(
308     ...     ('localhost', 1025), None, process=process, count=1)
309     >>> del message['Subject']
310     >>> message['Subject'] = 'need help for the first homework'
311     >>> messages = [message]
312     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
313     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
314     respond with:
315     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
316     MIME-Version: 1.0
317     Content-Disposition: inline
318     Date: ...
319     From: Robot101 <phys101@tower.edu>
320     Reply-to: Robot101 <phys101@tower.edu>
321     To: Bilbo Baggins <bb@shire.org>
322     Subject: received 'need help for the first homework'
323     <BLANKLINE>
324     --===============...==
325     Content-Type: multipart/mixed; boundary="===============...=="
326     MIME-Version: 1.0
327     <BLANKLINE>
328     --===============...==
329     Content-Type: text/plain; charset="us-ascii"
330     MIME-Version: 1.0
331     Content-Transfer-Encoding: 7bit
332     Content-Disposition: inline
333     <BLANKLINE>
334     Billy,
335     <BLANKLINE>
336     We got an email from you with the following subject:
337       'need help for the first homework'
338     which does not match any submittable assignment name for
339     Physics 101.
340     Remember to use the full name for the assignment in the
341     subject.  For example:
342       Assignment 1 submission
343     <BLANKLINE>
344     Yours,
345     phys-101 robot
346     --===============...==
347     Content-Type: message/rfc822
348     MIME-Version: 1.0
349     <BLANKLINE>
350     Content-Type: text/plain; charset="us-ascii"
351     MIME-Version: 1.0
352     Content-Transfer-Encoding: 7bit
353     Content-Disposition: inline
354     From: Billy B <bb@greyhavens.net>
355     To: phys101 <phys101@tower.edu>
356     Return-Path: <bb@greyhavens.net>
357     Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
358     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
359     Message-ID: <hgi.jlk@home.net>
360     Subject: need help for the first homework
361     <BLANKLINE>
362     The answer is 42.
363     --===============...==--
364     --===============...==
365     MIME-Version: 1.0
366     Content-Transfer-Encoding: 7bit
367     Content-Description: OpenPGP digital signature
368     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
369     <BLANKLINE>
370     -----BEGIN PGP SIGNATURE-----
371     Version: GnuPG v2.0.19 (GNU/Linux)
372     <BLANKLINE>
373     ...
374     -----END PGP SIGNATURE-----
375     <BLANKLINE>
376     --===============...==--
377
378     Response to a missing subject:
379
380     >>> server = SMTPServer(
381     ...     ('localhost', 1025), None, process=process, count=1)
382     >>> del message['Subject']
383     >>> messages = [message]
384     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
385     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
386     respond with:
387     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
388     MIME-Version: 1.0
389     Content-Disposition: inline
390     Date: ...
391     From: Robot101 <phys101@tower.edu>
392     Reply-to: Robot101 <phys101@tower.edu>
393     To: Bilbo Baggins <bb@shire.org>
394     Subject: no subject in <hgi.jlk@home.net>
395     <BLANKLINE>
396     --===============...==
397     Content-Type: multipart/mixed; boundary="===============...=="
398     MIME-Version: 1.0
399     <BLANKLINE>
400     --===============...==
401     Content-Type: text/plain; charset="us-ascii"
402     MIME-Version: 1.0
403     Content-Transfer-Encoding: 7bit
404     Content-Disposition: inline
405     <BLANKLINE>
406     Billy,
407     <BLANKLINE>
408     We received an email message from you without a subject.
409     <BLANKLINE>
410     Yours,
411     phys-101 robot
412     --===============...==
413     Content-Type: message/rfc822
414     MIME-Version: 1.0
415     <BLANKLINE>
416     Content-Type: text/plain; charset="us-ascii"
417     MIME-Version: 1.0
418     Content-Transfer-Encoding: 7bit
419     Content-Disposition: inline
420     From: Billy B <bb@greyhavens.net>
421     To: phys101 <phys101@tower.edu>
422     Return-Path: <bb@greyhavens.net>
423     Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
424     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
425     Message-ID: <hgi.jlk@home.net>
426     <BLANKLINE>
427     The answer is 42.
428     --===============...==--
429     --===============...==
430     MIME-Version: 1.0
431     Content-Transfer-Encoding: 7bit
432     Content-Description: OpenPGP digital signature
433     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
434     <BLANKLINE>
435     -----BEGIN PGP SIGNATURE-----
436     Version: GnuPG v2.0.19 (GNU/Linux)
437     <BLANKLINE>
438     ...
439     -----END PGP SIGNATURE-----
440     <BLANKLINE>
441     --===============...==--
442
443     Response to an insecure message from a person with a PGP key:
444
445     >>> student = course.course.person(email='bb@greyhavens.net')
446     >>> student.pgp_key = '4332B6E3'
447     >>> server = SMTPServer(
448     ...     ('localhost', 1025), None, process=process, count=1)
449     >>> del message['Subject']
450     >>> messages = [message]
451     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
452     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
453     respond with:
454     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
455     MIME-Version: 1.0
456     Content-Disposition: inline
457     Date: ...
458     From: Robot101 <phys101@tower.edu>
459     Reply-to: Robot101 <phys101@tower.edu>
460     To: Bilbo Baggins <bb@shire.org>
461     Subject: unsigned message <hgi.jlk@home.net>
462     <BLANKLINE>
463     --===============...==
464     MIME-Version: 1.0
465     Content-Transfer-Encoding: 7bit
466     Content-Type: application/pgp-encrypted; charset="us-ascii"
467     <BLANKLINE>
468     Version: 1
469     <BLANKLINE>
470     --===============...==
471     MIME-Version: 1.0
472     Content-Transfer-Encoding: 7bit
473     Content-Description: OpenPGP encrypted message
474     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
475     <BLANKLINE>
476     -----BEGIN PGP MESSAGE-----
477     Version: GnuPG v2.0.19 (GNU/Linux)
478     <BLANKLINE>
479     ...
480     -----END PGP MESSAGE-----
481     <BLANKLINE>
482     --===============...==--
483
484     Response to a message from an unregistered person:
485
486     >>> server = SMTPServer(
487     ...     ('localhost', 1025), None, process=process, count=1)
488     >>> del message['Return-Path']
489     >>> message['Return-Path'] = '<invalid.return.path@home.net>'
490     >>> messages = [message]
491     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
492     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
493     respond with:
494     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
495     MIME-Version: 1.0
496     Content-Disposition: inline
497     Date: ...
498     From: Robot101 <phys101@tower.edu>
499     Reply-to: Robot101 <phys101@tower.edu>
500     To: "invalid.return.path@home.net" <invalid.return.path@home.net>
501     Subject: unregistered address invalid.return.path@home.net
502     <BLANKLINE>
503     --===============...==
504     Content-Type: multipart/mixed; boundary="===============...=="
505     MIME-Version: 1.0
506     <BLANKLINE>
507     --===============...==
508     Content-Type: text/plain; charset="us-ascii"
509     MIME-Version: 1.0
510     Content-Transfer-Encoding: 7bit
511     Content-Disposition: inline
512     <BLANKLINE>
513     invalid.return.path@home.net,
514     <BLANKLINE>
515     Your email address is not registered with pygrader for
516     Physics 101.  If you feel it should be, contact your professor
517     or TA.
518     <BLANKLINE>
519     Yours,
520     phys-101 robot
521     --===============...==
522     Content-Type: message/rfc822
523     MIME-Version: 1.0
524     <BLANKLINE>
525     Content-Type: text/plain; charset="us-ascii"
526     MIME-Version: 1.0
527     Content-Transfer-Encoding: 7bit
528     Content-Disposition: inline
529     From: Billy B <bb@greyhavens.net>
530     To: phys101 <phys101@tower.edu>
531     Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
532     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
533     Message-ID: <hgi.jlk@home.net>
534     Return-Path: <invalid.return.path@home.net>
535     <BLANKLINE>
536     The answer is 42.
537     --===============...==--
538     --===============...==
539     MIME-Version: 1.0
540     Content-Transfer-Encoding: 7bit
541     Content-Description: OpenPGP digital signature
542     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
543     <BLANKLINE>
544     -----BEGIN PGP SIGNATURE-----
545     Version: GnuPG v2.0.19 (GNU/Linux)
546     <BLANKLINE>
547     ...
548     -----END PGP SIGNATURE-----
549     <BLANKLINE>
550     --===============...==--
551
552     >>> course.cleanup()
553     """
554     if stream is None:
555         stream = _sys.stdin
556     for msg,person,assignment,time in _load_messages(
557         course=course, stream=stream, mailbox=mailbox, input_=input_,
558         output=output, respond=respond, use_color=use_color, dry_run=dry_run):
559         assignment_path = _assignment_path(basedir, assignment, person)
560         _save_local_message_copy(
561             msg=msg, person=person, assignment_path=assignment_path,
562             use_color=use_color, dry_run=dry_run)
563         _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
564         _check_late(
565             basedir=basedir, assignment=assignment, person=person, time=time,
566             max_late=max_late, use_color=use_color, dry_run=dry_run)
567
568 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
569                    respond=None, use_color=None, dry_run=False):
570     if mailbox is None:
571         mbox = None
572         messages = [(None,_message_from_file(stream))]
573         if output is not None:
574             ombox = _mailbox.Maildir(output, factory=None, create=True)
575     elif mailbox == 'mbox':
576         mbox = _mailbox.mbox(input_, factory=None, create=False)
577         messages = mbox.items()
578         if output is not None:
579             ombox = _mailbox.mbox(output, factory=None, create=True)
580     elif mailbox == 'maildir':
581         mbox = _mailbox.Maildir(input_, factory=None, create=False)
582         messages = mbox.items()
583         if output is not None:
584             ombox = _mailbox.Maildir(output, factory=None, create=True)
585     else:
586         raise ValueError(mailbox)
587     for key,msg in messages:
588         ret = _parse_message(
589             course=course, msg=msg, respond=respond, use_color=use_color)
590         if ret:
591             if output is not None and dry_run is False:
592                 # move message from input mailbox to output mailbox
593                 ombox.add(msg)
594                 if mbox is not None:
595                     del mbox[key]
596             yield ret
597
598 def _parse_message(course, msg, respond=None, use_color=None):
599     """Parse an incoming email and respond if neccessary.
600
601     Return ``(msg, person, assignment, time)`` on successful parsing.
602     Return ``None`` on failure.
603     """
604     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
605     original = msg
606     mid = msg['Message-ID']
607     try:
608         msg,person,subject = _get_message_person_and_subject(
609             course=course, message=msg, original=original, respond=respond,
610             use_color=use_color)
611     except ValueError as error:
612         _LOG.debug(_color_string(string=str(error), color=bad))
613         return None
614
615     for assignment in course.assignments:
616         if _match_assignment(assignment, subject):
617             break
618     if not _match_assignment(assignment, subject):
619         _LOG.warn(_color_string(
620                 string='no assignment found in {}'.format(repr(subject)),
621                 color=bad))
622         if respond:
623             response_subject = "received '{}'".format(subject)
624             submittable_assignments = [
625                 a for a in course.assignments if a.submittable]
626             if not submittable_assignments:
627                 hint = (
628                     'In fact, there are no submittable assignments for\n'
629                     'this course!\n')
630             else:
631                 hint = (
632                     'Remember to use the full name for the assignment in the\n'
633                     'subject.  For example:\n'
634                     '  {} submission\n\n').format(
635                     submittable_assignments[0].name)
636             response_text = (
637                 '{},\n\n'
638                 'We got an email from you with the following subject:\n'
639                 '  {}\n'
640                 'which does not match any submittable assignment name for\n'
641                 '{}.\n'
642                 '{}'
643                 'Yours,\n{}').format(
644                 person.alias(), repr(subject), course.name, hint,
645                 course.robot.alias())
646             response = _construct_response(
647                 author=course.robot, targets=[person],
648                 subject=response_subject, text=response_text, original=msg)
649             respond(response)
650         return None
651
652     if not assignment.submittable:
653         response_subject = 'received invalid {} submission'.format(
654             assignment.name)
655         response_text = (
656             '{},\n\n'
657             'We received your submission for {}, but you are not allowed\n'
658             'to submit that assignment via email.\n\n'
659             'Yours,\n{}').format(
660             person.alias(), assignment.name, course.robot.alias())
661         response = _construct_response(
662             author=course.robot, targets=[person],
663             subject=response_subject, text=response_text, original=msg)
664         respond(response)
665         
666     time = _message_time(message=msg, use_color=use_color)
667
668     if respond:
669         response_subject = 'received {} submission'.format(assignment.name)
670         if time:
671             time_str = 'on {}'.format(_formatdate(time))
672         else:
673             time_str = 'at an unknown time'
674         response_text = (
675             '{},\n\n'
676             'We received your submission for {} {}.\n\n'
677             'Yours,\n{}').format(
678             person.alias(), assignment.name, time_str, course.robot.alias())
679         response = _construct_response(
680             author=course.robot, targets=[person],
681             subject=response_subject, text=response_text, original=msg)
682         respond(response)
683     return (msg, person, assignment, time)
684
685 def _get_message_person(course, message, original, respond=None,
686                         use_color=None):
687     mid = message['Message-ID']
688     sender = message['Return-Path']  # RFC 822
689     if sender is None:
690         raise ValueError('no Return-Path in {}'.format(mid))
691     sender = sender[1:-1]  # strip wrapping '<' and '>'
692     people = list(course.find_people(email=sender))
693     if len(people) == 0:
694         if respond:
695             person = _Person(name=sender, emails=[sender])
696             response_subject = 'unregistered address {}'.format(sender)
697             response_text = (
698                 '{},\n\n'
699                 'Your email address is not registered with pygrader for\n'
700                 '{}.  If you feel it should be, contact your professor\n'
701                 'or TA.\n\n'
702                 'Yours,\n{}').format(
703                 sender, course.name, course.robot.alias())
704             response = _construct_response(
705                 author=course.robot, targets=[person],
706                 subject=response_subject, text=response_text,
707                 original=original)
708             respond(response)
709         raise ValueError('no person found to match {}'.format(sender))
710     if len(people) > 1:
711         raise ValueError('multiple people match {} ({})'.format(
712                 sender, ', '.join(str(p) for p in people)))
713     return people[0]
714
715 def _get_decoded_message(course, message, original, person,
716                          respond=None, use_color=None):
717     message = _get_verified_message(
718         message, person.pgp_key, use_color=use_color)
719     if message is None:
720         if respond:
721             mid = original['Message-ID']
722             response_subject = 'unsigned message {}'.format(mid)
723             response_text = (
724                 '{},\n\n'
725                 'We received an email message from you without a valid\n'
726                 'PGP signature.\n\n'
727                 'Yours,\n{}').format(
728                 person.alias(), course.robot.alias())
729             response = _construct_response(
730                 author=course.robot, targets=[person],
731                 subject=response_subject, text=response_text,
732                 original=original)
733             respond(response)
734         raise ValueError('unsigned message from {}'.format(person.alias()))
735     return message
736
737 def _get_message_subject(course, message, original, person,
738                          respond=None, use_color=None):
739     if message['Subject'] is None:
740         mid = message['Message-ID']
741         response_subject = 'no subject in {}'.format(mid)
742         if respond:
743             response_text = (
744                 '{},\n\n'
745                 'We received an email message from you without a subject.\n\n'
746                 'Yours,\n{}').format(
747                 person.alias(), course.robot.alias())
748             response = _construct_response(
749                 author=course.robot, targets=[person],
750                 subject=response_subject, text=response_text,
751                 original=original)
752             respond(response)
753         raise ValueError(response_subject)
754
755     parts = _decode_header(message['Subject'])
756     if len(parts) != 1:
757         raise ValueError('multi-part header {}'.format(parts))
758     subject,encoding = parts[0]
759     if encoding is None:
760         encoding = 'ascii'
761     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
762     return subject.lower().replace('#', '')
763
764 def _get_message_person_and_subject(course, message, original,
765                                     respond=None, use_color=None):
766     original = message
767     person = _get_message_person(
768         course=course, message=message, original=original,
769         respond=respond, use_color=use_color)
770     if person.pgp_key:
771         message = _get_decoded_message(
772             course=course, message=message, original=original, person=person,
773             respond=respond, use_color=use_color)
774     subject = _get_message_subject(
775         course=course, message=message, original=original, person=person,
776         respond=respond, use_color=use_color)
777     return (message, person, subject)
778
779 def _match_assignment(assignment, subject):
780     return assignment.name.lower() in subject
781
782 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
783                              dry_run=False):
784     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
785     try:
786         _os.makedirs(assignment_path)
787     except OSError:
788         pass
789     mpath = _os_path.join(assignment_path, 'mail')
790     try:
791         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
792     except _mailbox.NoSuchMailboxError as e:
793         _LOG.debug(_color_string(
794                 string='could not open mailbox at {}'.format(mpath),
795                 color=bad))
796         mbox = None
797         new_msg = True
798     else:
799         new_msg = True
800         for other_msg in mbox:
801             if other_msg['Message-ID'] == msg['Message-ID']:
802                 new_msg = False
803                 break
804     if new_msg:
805         _LOG.debug(_color_string(
806                 string='saving email from {} to {}'.format(
807                     person, assignment_path), color=good))
808         if mbox is not None and not dry_run:
809             mdmsg = _mailbox.MaildirMessage(msg)
810             mdmsg.add_flag('S')
811             mbox.add(mdmsg)
812             mbox.close()
813     else:
814         _LOG.debug(_color_string(
815                 string='already found {} in {}'.format(
816                     msg['Message-ID'], mpath), color=good))
817
818 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
819                 dry_run=False):
820     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
821     if time > assignment.due + max_late:
822         dt = time - assignment.due
823         _LOG.warn(_color_string(
824                 string='{} {} late by {} seconds ({} hours)'.format(
825                     person.name, assignment.name, dt, dt/3600.),
826                 color=bad))
827         if not dry_run:
828             _set_late(basedir=basedir, assignment=assignment, person=person)
829
830 def _get_verified_message(message, pgp_key, use_color=None):
831     """
832
833     >>> from pgp_mime import sign, encodedMIMEText
834
835     The student composes a message...
836
837     >>> message = encodedMIMEText('1.23 joules')
838
839     ... and signs it (with the pgp-mime test key).
840
841     >>> signed = sign(message, signers=['pgp-mime-test'])
842
843     As it is being delivered, the message picks up extra headers.
844
845     >>> signed['Message-ID'] = '<01234567@home.net>'
846     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
847     >>> signed['Received'] = 'from smtp.home.net ...'
848
849     We check that the message is signed, and that it is signed by the
850     appropriate key.
851
852     >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
853     >>> print(our_message.as_string())  # doctest: +REPORT_UDIFF
854     Content-Type: text/plain; charset="us-ascii"
855     MIME-Version: 1.0
856     Content-Transfer-Encoding: 7bit
857     Content-Disposition: inline
858     Message-ID: <01234567@home.net>
859     Received: from smtp.mail.uu.edu ...
860     Received: from smtp.home.net ...
861     <BLANKLINE>
862     1.23 joules
863
864     If it is signed, but not by the right key, we get ``None``.
865
866     >>> print(_get_verified_message(signed, pgp_key='01234567'))
867     None
868
869     If it is not signed at all, we get ``None``.
870
871     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
872     None
873     """
874     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
875     mid = message['message-id']
876     try:
877         decrypted,verified,result = _verify(message=message)
878     except (ValueError, AssertionError):
879         _LOG.warn(_color_string(
880                 string='could not verify {} (not signed?)'.format(mid),
881                 color=bad))
882         return None
883     _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
884     tree = _etree.fromstring(result.replace(b'\x00', b''))
885     match = None
886     for signature in tree.findall('.//signature'):
887         for fingerprint in signature.iterchildren('fpr'):
888             if fingerprint.text.endswith(pgp_key):
889                 match = signature
890                 break
891     if match is None:
892         _LOG.warn(_color_string(
893                 string='{} is not signed by the expected key'.format(mid),
894                 color=bad))
895         return None
896     if not verified:
897         sumhex = list(signature.iterchildren('summary'))[0].get('value')
898         summary = int(sumhex, 16)
899         if summary != 0:
900             _LOG.warn(_color_string(
901                     string='{} has an unverified signature'.format(mid),
902                     color=bad))
903             return None
904         # otherwise, we may have an untrusted key.  We'll count that
905         # as verified here, because the caller is explicity looking
906         # for signatures by this fingerprint.
907     for k,v in message.items(): # copy over useful headers
908         if k.lower() not in ['content-type',
909                              'mime-version',
910                              'content-disposition',
911                              ]:
912             decrypted[k] = v
913     return decrypted