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