mailpipe: update doctests now that I'm using GnuPG v2.0.19.
[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     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
600     mid = msg['Message-ID']
601     sender = msg['Return-Path']  # RFC 822
602     if sender is None:
603         _LOG.debug(_color_string(
604                 string='no Return-Path in {}'.format(mid), color=lowlight))
605         return None
606     sender = sender[1:-1]  # strip wrapping '<' and '>'
607
608     people = list(course.find_people(email=sender))
609     if len(people) == 0:
610         _LOG.warn(_color_string(
611                 string='no person found to match {}'.format(sender),
612                 color=bad))
613         if respond:
614             person = _Person(name=sender, emails=[sender])
615             response_subject = 'unregistered address {}'.format(sender)
616             response_text = (
617                 '{},\n\n'
618                 'Your email address is not registered with pygrader for\n'
619                 '{}.  If you feel it should be, contact your professor\n'
620                 'or TA.\n\n'
621                 'Yours,\n{}').format(
622                 sender, course.name, course.robot.alias())
623             response = _construct_response(
624                 author=course.robot, targets=[person],
625                 subject=response_subject, text=response_text, original=msg)
626             respond(response)
627         return None
628     if len(people) > 1:
629         _LOG.warn(_color_string(
630                 string='multiple people match {} ({})'.format(
631                     sender, ', '.join(str(p) for p in people)),
632                 color=bad))
633         return None
634     person = people[0]
635
636     if person.pgp_key:
637         original = msg
638         msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
639         if msg is None:
640             if respond:
641                 response_subject = 'unsigned message {}'.format(mid)
642                 response_text = (
643                     '{},\n\n'
644                     'We received an email message from you without a valid\n'
645                     'PGP signature.\n\n'
646                     'Yours,\n{}').format(
647                     person.alias(), course.robot.alias())
648                 response = _construct_response(
649                     author=course.robot, targets=[person],
650                     subject=response_subject, text=response_text,
651                     original=original)
652                 respond(response)
653             return None
654
655     if msg['Subject'] is None:
656         _LOG.warn(_color_string(
657                 string='no subject in {}'.format(mid), color=bad))
658         if respond:
659             response_subject = 'no subject in {}'.format(mid)
660             response_text = (
661                 '{},\n\n'
662                 'We received an email message from you without a subject.\n\n'
663                 'Yours,\n{}').format(
664                 person.alias(), course.robot.alias())
665             response = _construct_response(
666                 author=course.robot, targets=[person],
667                 subject=response_subject, text=response_text, original=msg)
668             respond(response)
669         return None
670     parts = _decode_header(msg['Subject'])
671     if len(parts) != 1:
672         _LOG.warn(_color_string(
673                 string='multi-part header {}'.format(parts), color=bad))
674         return None
675     subject,encoding = parts[0]
676     if encoding is None:
677         encoding = 'ascii'
678     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
679
680     subject = subject.lower().replace('#', '')
681     for assignment in course.assignments:
682         if _match_assignment(assignment, subject):
683             break
684     if not _match_assignment(assignment, subject):
685         _LOG.warn(_color_string(
686                 string='no assignment found in {}'.format(repr(subject)),
687                 color=bad))
688         if respond:
689             response_subject = "received '{}'".format(subject)
690             submittable_assignments = [
691                 a for a in course.assignments if a.submittable]
692             if not submittable_assignments:
693                 hint = (
694                     'In fact, there are no submittable assignments for\n'
695                     'this course!\n')
696             else:
697                 hint = (
698                     'Remember to use the full name for the assignment in the\n'
699                     'subject.  For example:\n'
700                     '  {} submission\n\n').format(
701                     submittable_assignments[0].name)
702             response_text = (
703                 '{},\n\n'
704                 'We got an email from you with the following subject:\n'
705                 '  {}\n'
706                 'which does not match any submittable assignment name for\n'
707                 '{}.\n'
708                 '{}'
709                 'Yours,\n{}').format(
710                 person.alias(), repr(subject), course.name, hint,
711                 course.robot.alias())
712             response = _construct_response(
713                 author=course.robot, targets=[person],
714                 subject=response_subject, text=response_text, original=msg)
715             respond(response)
716         return None
717
718     if not assignment.submittable:
719         response_subject = 'received invalid {} submission'.format(
720             assignment.name)
721         response_text = (
722             '{},\n\n'
723             'We received your submission for {}, but you are not allowed\n'
724             'to submit that assignment via email.\n\n'
725             'Yours,\n{}').format(
726             person.alias(), assignment.name, course.robot.alias())
727         response = _construct_response(
728             author=course.robot, targets=[person],
729             subject=response_subject, text=response_text, original=msg)
730         respond(response)
731         
732     time = _message_time(message=msg, use_color=use_color)
733
734     if respond:
735         response_subject = 'received {} submission'.format(assignment.name)
736         if time:
737             time_str = 'on {}'.format(_formatdate(time))
738         else:
739             time_str = 'at an unknown time'
740         response_text = (
741             '{},\n\n'
742             'We received your submission for {} {}.\n\n'
743             'Yours,\n{}').format(
744             person.alias(), assignment.name, time_str, course.robot.alias())
745         response = _construct_response(
746             author=course.robot, targets=[person],
747             subject=response_subject, text=response_text, original=msg)
748         respond(response)
749     return (msg, person, assignment, time)
750
751 def _match_assignment(assignment, subject):
752     return assignment.name.lower() in subject
753
754 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
755                              dry_run=False):
756     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
757     try:
758         _os.makedirs(assignment_path)
759     except OSError:
760         pass
761     mpath = _os_path.join(assignment_path, 'mail')
762     try:
763         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
764     except _mailbox.NoSuchMailboxError as e:
765         _LOG.debug(_color_string(
766                 string='could not open mailbox at {}'.format(mpath),
767                 color=bad))
768         mbox = None
769         new_msg = True
770     else:
771         new_msg = True
772         for other_msg in mbox:
773             if other_msg['Message-ID'] == msg['Message-ID']:
774                 new_msg = False
775                 break
776     if new_msg:
777         _LOG.debug(_color_string(
778                 string='saving email from {} to {}'.format(
779                     person, assignment_path), color=good))
780         if mbox is not None and not dry_run:
781             mdmsg = _mailbox.MaildirMessage(msg)
782             mdmsg.add_flag('S')
783             mbox.add(mdmsg)
784             mbox.close()
785     else:
786         _LOG.debug(_color_string(
787                 string='already found {} in {}'.format(
788                     msg['Message-ID'], mpath), color=good))
789
790 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
791                 dry_run=False):
792     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
793     if time > assignment.due + max_late:
794         dt = time - assignment.due
795         _LOG.warn(_color_string(
796                 string='{} {} late by {} seconds ({} hours)'.format(
797                     person.name, assignment.name, dt, dt/3600.),
798                 color=bad))
799         if not dry_run:
800             _set_late(basedir=basedir, assignment=assignment, person=person)
801
802 def _get_verified_message(message, pgp_key, use_color=None):
803     """
804
805     >>> from pgp_mime import sign, encodedMIMEText
806
807     The student composes a message...
808
809     >>> message = encodedMIMEText('1.23 joules')
810
811     ... and signs it (with the pgp-mime test key).
812
813     >>> signed = sign(message, signers=['pgp-mime-test'])
814
815     As it is being delivered, the message picks up extra headers.
816
817     >>> signed['Message-ID'] = '<01234567@home.net>'
818     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
819     >>> signed['Received'] = 'from smtp.home.net ...'
820
821     We check that the message is signed, and that it is signed by the
822     appropriate key.
823
824     >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
825     >>> print(our_message.as_string())  # doctest: +REPORT_UDIFF
826     Content-Type: text/plain; charset="us-ascii"
827     MIME-Version: 1.0
828     Content-Transfer-Encoding: 7bit
829     Content-Disposition: inline
830     Message-ID: <01234567@home.net>
831     Received: from smtp.mail.uu.edu ...
832     Received: from smtp.home.net ...
833     <BLANKLINE>
834     1.23 joules
835
836     If it is signed, but not by the right key, we get ``None``.
837
838     >>> print(_get_verified_message(signed, pgp_key='01234567'))
839     None
840
841     If it is not signed at all, we get ``None``.
842
843     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
844     None
845     """
846     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
847     mid = message['message-id']
848     try:
849         decrypted,verified,result = _verify(message=message)
850     except (ValueError, AssertionError):
851         _LOG.warn(_color_string(
852                 string='could not verify {} (not signed?)'.format(mid),
853                 color=bad))
854         return None
855     _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
856     tree = _etree.fromstring(result.replace(b'\x00', b''))
857     match = None
858     for signature in tree.findall('.//signature'):
859         for fingerprint in signature.iterchildren('fpr'):
860             if fingerprint.text.endswith(pgp_key):
861                 match = signature
862                 break
863     if match is None:
864         _LOG.warn(_color_string(
865                 string='{} is not signed by the expected key'.format(mid),
866                 color=bad))
867         return None
868     if not verified:
869         sumhex = list(signature.iterchildren('summary'))[0].get('value')
870         summary = int(sumhex, 16)
871         if summary != 0:
872             _LOG.warn(_color_string(
873                     string='{} has an unverified signature'.format(mid),
874                     color=bad))
875             return None
876         # otherwise, we may have an untrusted key.  We'll count that
877         # as verified here, because the caller is explicity looking
878         # for signatures by this fingerprint.
879     for k,v in message.items(): # copy over useful headers
880         if k.lower() not in ['content-type',
881                              'mime-version',
882                              'content-disposition',
883                              ]:
884             decrypted[k] = v
885     return decrypted