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