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