Ran update-copyright.py.
[pygrader.git] / pygrader / mailpipe.py
1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
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 from email.mime.text import MIMEText as _MIMEText
24 import mailbox as _mailbox
25 import re as _re
26 import sys as _sys
27
28 import pgp_mime as _pgp_mime
29 from lxml import etree as _etree
30
31 from . import LOG as _LOG
32 from .email import construct_email as _construct_email
33 from .email import construct_response as _construct_response
34 from .extract_mime import message_time as _message_time
35 from .model.person import Person as _Person
36
37 from .handler import InsecureMessage as _InsecureMessage
38 from .handler import InvalidAssignmentSubject as _InvalidAssignmentSubject
39 from .handler import InvalidMessage as _InvalidMessage
40 from .handler import InvalidStudentSubject as _InvalidStudentSubject
41 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
42 from .handler import PermissionViolationMessage as _PermissionViolationMessage
43 from .handler import Response as _Response
44 from .handler import UnsignedMessage as _UnsignedMessage
45 from .handler.get import run as _handle_get
46 from .handler.grade import run as _handle_grade
47 from .handler.grade import MissingGradeMessage as _MissingGradeMessage
48 from .handler.submission import run as _handle_submission
49 from .handler.submission import InvalidSubmission as _InvalidSubmission
50
51
52 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
53
54
55 class NoReturnPath (_InvalidMessage):
56     def __init__(self, address, **kwargs):
57         if 'error' not in kwargs:
58             kwargs['error'] = 'no Return-Path'
59         super(NoReturnPath, self).__init__(**kwargs)
60
61
62 class UnregisteredAddress (_InvalidMessage):
63     def __init__(self, address, **kwargs):
64         if 'error' not in kwargs:
65             kwargs['error'] = 'unregistered address {}'.format(address)
66         super(UnregisteredAddress, self).__init__(**kwargs)
67         self.address = address
68
69
70 class AmbiguousAddress (_InvalidMessage):
71     def __init__(self, address, people, **kwargs):
72         if 'error' not in kwargs:
73             kwargs['error'] = 'ambiguous address {}'.format(address)
74         super(AmbiguousAddress, self).__init__(**kwargs)
75         self.address = address
76         self.people = people
77
78
79 class WrongSignatureMessage (_InsecureMessage):
80     def __init__(self, pgp_key=None, fingerprints=None, decrypted=None,
81                  **kwargs):
82         if 'error' not in kwargs:
83             kwargs['error'] = 'not signed by the expected key'
84         super(WrongSignatureMessage, self).__init__(**kwargs)
85         self.pgp_key = pgp_key
86         self.fingerprints = fingerprints
87         self.decrypted = decrypted
88
89 class UnverifiedSignatureMessage (_InsecureMessage):
90     def __init__(self, signature=None, decrypted=None, **kwargs):
91         if 'error' not in kwargs:
92             kwargs['error'] = 'unverified signature'
93         super(UnverifiedSignatureMessage, self).__init__(**kwargs)
94         self.signature = signature
95         self.decrypted = decrypted
96
97
98 class SubjectlessMessage (_InvalidSubjectMessage):
99     def __init__(self, **kwargs):
100         if 'error' not in kwargs:
101             kwargs['error'] = 'no subject'
102         super(SubjectlessMessage, self).__init__(**kwargs)
103
104
105 class InvalidHandlerMessage (_InvalidSubjectMessage):
106     def __init__(self, target=None, handlers=None, **kwargs):
107         if 'error' not in kwargs:
108             kwargs['error'] = 'no handler for {!r}'.format(target)
109         super(InvalidHandlerMessage, self).__init__(**kwargs)
110         self.target = target
111         self.handlers = handlers
112
113
114 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
115              output=None, continue_after_invalid_message=False, max_late=0,
116              trust_email_infrastructure=False,
117              handlers={
118         'get': _handle_get,
119         'grade': _handle_grade,
120         'submit': _handle_submission,
121         }, respond=None, dry_run=False, **kwargs):
122     """Run from procmail to sort incomming submissions
123
124     For example, you can setup your ``.procmailrc`` like this::
125
126       SHELL=/bin/sh
127       DEFAULT=$MAIL
128       MAILDIR=$HOME/mail
129       DEFAULT=$MAILDIR/mbox
130       LOGFILE=$MAILDIR/procmail.log
131       #VERBOSE=yes
132       PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
133
134       # Grab all incoming homeworks emails.  This rule eats matching emails
135       # (i.e. no further procmail processing).
136       :0
137       * ^Subject:.*\[phys160:submit]
138       | "$PYGRADE_MAILPIPE" mailpipe
139
140     If you don't want procmail to eat the message, you can use the
141     ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
142
143     >>> from io import StringIO
144     >>> from pgp_mime.email import encodedMIMEText
145     >>> from .handler import InvalidMessage, Response
146     >>> from .test.course import StubCourse
147
148     >>> course = StubCourse()
149     >>> def respond(message):
150     ...     print('respond with:\\n{}'.format(message.as_string()))
151     >>> def process(message):
152     ...     mailpipe(
153     ...         basedir=course.basedir, course=course.course,
154     ...         stream=StringIO(message.as_string()),
155     ...         output=course.mailbox,
156     ...         continue_after_invalid_message=True,
157     ...         respond=respond)
158     >>> message = encodedMIMEText('The answer is 42.')
159     >>> message['Message-ID'] = '<123.456@home.net>'
160     >>> message['Received'] = (
161     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
162     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
163     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
164     >>> message['From'] = 'Billy B <bb@greyhavens.net>'
165     >>> message['To'] = 'phys101 <phys101@tower.edu>'
166     >>> message['Subject'] = '[submit] assignment 1'
167
168     Messages with unrecognized ``Return-Path``\s are silently dropped:
169
170     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
171     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
172     course.conf
173     mail
174     mail/cur
175     mail/new
176     mail/tmp
177
178     Response to a message from an unregistered person:
179
180     >>> message['Return-Path'] = '<invalid.return.path@home.net>'
181     >>> process(message)  # 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: "invalid.return.path@home.net" <invalid.return.path@home.net>
190     Subject: unregistered address invalid.return.path@home.net
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     invalid.return.path@home.net,
203     <BLANKLINE>
204     Your email address is not registered with pygrader for
205     Physics 101.  If you feel it should be, contact your professor
206     or TA.
207     <BLANKLINE>
208     Yours,
209     phys-101 robot
210     <BLANKLINE>
211     --===============...==
212     Content-Type: message/rfc822
213     MIME-Version: 1.0
214     <BLANKLINE>
215     Content-Type: text/plain; charset="us-ascii"
216     MIME-Version: 1.0
217     Content-Transfer-Encoding: 7bit
218     Content-Disposition: inline
219     Message-ID: <123.456@home.net>
220     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>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)
221     From: Billy B <bb@greyhavens.net>
222     To: phys101 <phys101@tower.edu>
223     Subject: [submit] assignment 1
224     Return-Path: <invalid.return.path@home.net>
225     <BLANKLINE>
226     The answer is 42.
227     --===============...==--
228     --===============...==
229     MIME-Version: 1.0
230     Content-Transfer-Encoding: 7bit
231     Content-Description: OpenPGP digital signature
232     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
233     <BLANKLINE>
234     -----BEGIN PGP SIGNATURE-----
235     Version: GnuPG v2.0.19 (GNU/Linux)
236     <BLANKLINE>
237     ...
238     -----END PGP SIGNATURE-----
239     <BLANKLINE>
240     --===============...==--
241
242     If we add a valid ``Return-Path``, we get the expected delivery:
243
244     >>> del message['Return-Path']
245     >>> message['Return-Path'] = '<bb@greyhavens.net>'
246     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
247     respond with:
248     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
249     MIME-Version: 1.0
250     Content-Disposition: inline
251     Date: ...
252     From: Robot101 <phys101@tower.edu>
253     Reply-to: Robot101 <phys101@tower.edu>
254     To: Bilbo Baggins <bb@shire.org>
255     Subject: Received Assignment 1 submission
256     <BLANKLINE>
257     --===============...==
258     Content-Type: text/plain; charset="us-ascii"
259     MIME-Version: 1.0
260     Content-Disposition: inline
261     Content-Transfer-Encoding: 7bit
262     <BLANKLINE>
263     Billy,
264     <BLANKLINE>
265     We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
266     <BLANKLINE>
267     Yours,
268     phys-101 robot
269     <BLANKLINE>
270     --===============...==
271     MIME-Version: 1.0
272     Content-Transfer-Encoding: 7bit
273     Content-Description: OpenPGP digital signature
274     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
275     <BLANKLINE>
276     -----BEGIN PGP SIGNATURE-----
277     Version: GnuPG v2.0.19 (GNU/Linux)
278     <BLANKLINE>
279     ...
280     -----END PGP SIGNATURE-----
281     <BLANKLINE>
282     --===============...==--
283
284     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
285     Bilbo_Baggins
286     Bilbo_Baggins/Assignment_1
287     Bilbo_Baggins/Assignment_1/mail
288     Bilbo_Baggins/Assignment_1/mail/cur
289     Bilbo_Baggins/Assignment_1/mail/new
290     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
291     Bilbo_Baggins/Assignment_1/mail/tmp
292     course.conf
293     mail
294     mail/cur
295     mail/new
296     mail/new/...
297     mail/tmp
298
299     The last ``Received`` is used to timestamp the message:
300
301     >>> del message['Message-ID']
302     >>> message['Message-ID'] = '<abc.def@home.net>'
303     >>> del message['Received']
304     >>> message['Received'] = (
305     ...     'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
306     ...     'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
307     ...     'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
308     >>> message['Received'] = (
309     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
310     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
311     ...     'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
312     >>> process(message)  # 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: Received Assignment 1 submission
322     <BLANKLINE>
323     --===============...==
324     Content-Type: text/plain; charset="us-ascii"
325     MIME-Version: 1.0
326     Content-Disposition: inline
327     Content-Transfer-Encoding: 7bit
328     <BLANKLINE>
329     Billy,
330     <BLANKLINE>
331     We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
332     <BLANKLINE>
333     Yours,
334     phys-101 robot
335     <BLANKLINE>
336     --===============...==
337     MIME-Version: 1.0
338     Content-Transfer-Encoding: 7bit
339     Content-Description: OpenPGP digital signature
340     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
341     <BLANKLINE>
342     -----BEGIN PGP SIGNATURE-----
343     Version: GnuPG v2.0.19 (GNU/Linux)
344     <BLANKLINE>
345     ...
346     -----END PGP SIGNATURE-----
347     <BLANKLINE>
348     --===============...==--
349
350     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
351     Bilbo_Baggins
352     Bilbo_Baggins/Assignment_1
353     Bilbo_Baggins/Assignment_1/late
354     Bilbo_Baggins/Assignment_1/mail
355     Bilbo_Baggins/Assignment_1/mail/cur
356     Bilbo_Baggins/Assignment_1/mail/new
357     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
358     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
359     Bilbo_Baggins/Assignment_1/mail/tmp
360     course.conf
361     mail
362     mail/cur
363     mail/new
364     mail/new/...
365     mail/new/...
366     mail/tmp
367
368     You can send receipts to the acknowledge incoming messages, which
369     includes warnings about dropped messages (except for messages
370     without ``Return-Path`` and messages where the ``Return-Path``
371     email belongs to multiple ``People``.  The former should only
372     occur with malicious emails, and the latter with improper pygrader
373     configurations).
374
375     Response to a successful submission:
376
377     >>> del message['Message-ID']
378     >>> message['Message-ID'] = '<hgi.jlk@home.net>'
379     >>> process(message)  # 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: Received Assignment 1 submission
389     <BLANKLINE>
390     --===============...==
391     Content-Type: text/plain; charset="us-ascii"
392     MIME-Version: 1.0
393     Content-Disposition: inline
394     Content-Transfer-Encoding: 7bit
395     <BLANKLINE>
396     Billy,
397     <BLANKLINE>
398     We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
399     <BLANKLINE>
400     Yours,
401     phys-101 robot
402     <BLANKLINE>
403     --===============...==
404     MIME-Version: 1.0
405     Content-Transfer-Encoding: 7bit
406     Content-Description: OpenPGP digital signature
407     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
408     <BLANKLINE>
409     -----BEGIN PGP SIGNATURE-----
410     Version: GnuPG v2.0.19 (GNU/Linux)
411     <BLANKLINE>
412     ...
413     -----END PGP SIGNATURE-----
414     <BLANKLINE>
415     --===============...==--
416
417     Response to a submission on an unsubmittable assignment:
418
419     >>> del message['Subject']
420     >>> message['Subject'] = '[submit] attendance 1'
421     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
422     respond with:
423     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
424     MIME-Version: 1.0
425     Content-Disposition: inline
426     Date: ...
427     From: Robot101 <phys101@tower.edu>
428     Reply-to: Robot101 <phys101@tower.edu>
429     To: Bilbo Baggins <bb@shire.org>
430     Subject: Received invalid Attendance 1 submission
431     <BLANKLINE>
432     --===============...==
433     Content-Type: multipart/mixed; boundary="===============...=="
434     MIME-Version: 1.0
435     <BLANKLINE>
436     --===============...==
437     Content-Type: text/plain; charset="us-ascii"
438     MIME-Version: 1.0
439     Content-Transfer-Encoding: 7bit
440     Content-Disposition: inline
441     <BLANKLINE>
442     Billy,
443     <BLANKLINE>
444     We received your submission for Attendance 1, but you are not
445     allowed to submit that assignment via email.
446     <BLANKLINE>
447     Yours,
448     phys-101 robot
449     <BLANKLINE>
450     --===============...==
451     Content-Type: message/rfc822
452     MIME-Version: 1.0
453     <BLANKLINE>
454     Content-Type: text/plain; charset="us-ascii"
455     MIME-Version: 1.0
456     Content-Transfer-Encoding: 7bit
457     Content-Disposition: inline
458     From: Billy B <bb@greyhavens.net>
459     To: phys101 <phys101@tower.edu>
460     Return-Path: <bb@greyhavens.net>
461     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)
462     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)
463     Message-ID: <hgi.jlk@home.net>
464     Subject: [submit] attendance 1
465     <BLANKLINE>
466     The answer is 42.
467     --===============...==--
468     --===============...==
469     MIME-Version: 1.0
470     Content-Transfer-Encoding: 7bit
471     Content-Description: OpenPGP digital signature
472     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
473     <BLANKLINE>
474     -----BEGIN PGP SIGNATURE-----
475     Version: GnuPG v2.0.19 (GNU/Linux)
476     <BLANKLINE>
477     ...
478     -----END PGP SIGNATURE-----
479     <BLANKLINE>
480     --===============...==--
481
482     Response to a bad subject:
483
484     >>> del message['Subject']
485     >>> message['Subject'] = 'need help for the first homework'
486     >>> process(message)  # 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: Bilbo Baggins <bb@shire.org>
495     Subject: no tag in 'need help for the first homework'
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     Billy,
508     <BLANKLINE>
509     We received an email message from you with an invalid
510     subject.
511     <BLANKLINE>
512     Yours,
513     phys-101 robot
514     <BLANKLINE>
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     Return-Path: <bb@greyhavens.net>
526     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)
527     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)
528     Message-ID: <hgi.jlk@home.net>
529     Subject: need help for the first homework
530     <BLANKLINE>
531     The answer is 42.
532     --===============...==--
533     --===============...==
534     MIME-Version: 1.0
535     Content-Transfer-Encoding: 7bit
536     Content-Description: OpenPGP digital signature
537     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
538     <BLANKLINE>
539     -----BEGIN PGP SIGNATURE-----
540     Version: GnuPG v2.0.19 (GNU/Linux)
541     <BLANKLINE>
542     ...
543     -----END PGP SIGNATURE-----
544     <BLANKLINE>
545     --===============...==--
546
547     Response to a missing subject:
548
549     >>> del message['Subject']
550     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
551     respond with:
552     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
553     MIME-Version: 1.0
554     Content-Disposition: inline
555     Date: ...
556     From: Robot101 <phys101@tower.edu>
557     Reply-to: Robot101 <phys101@tower.edu>
558     To: Bilbo Baggins <bb@shire.org>
559     Subject: no subject in <hgi.jlk@home.net>
560     <BLANKLINE>
561     --===============...==
562     Content-Type: multipart/mixed; boundary="===============...=="
563     MIME-Version: 1.0
564     <BLANKLINE>
565     --===============...==
566     Content-Type: text/plain; charset="us-ascii"
567     MIME-Version: 1.0
568     Content-Transfer-Encoding: 7bit
569     Content-Disposition: inline
570     <BLANKLINE>
571     Billy,
572     <BLANKLINE>
573     We received an email message from you without a subject.
574     <BLANKLINE>
575     Yours,
576     phys-101 robot
577     <BLANKLINE>
578     --===============...==
579     Content-Type: message/rfc822
580     MIME-Version: 1.0
581     <BLANKLINE>
582     Content-Type: text/plain; charset="us-ascii"
583     MIME-Version: 1.0
584     Content-Transfer-Encoding: 7bit
585     Content-Disposition: inline
586     From: Billy B <bb@greyhavens.net>
587     To: phys101 <phys101@tower.edu>
588     Return-Path: <bb@greyhavens.net>
589     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)
590     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)
591     Message-ID: <hgi.jlk@home.net>
592     <BLANKLINE>
593     The answer is 42.
594     --===============...==--
595     --===============...==
596     MIME-Version: 1.0
597     Content-Transfer-Encoding: 7bit
598     Content-Description: OpenPGP digital signature
599     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
600     <BLANKLINE>
601     -----BEGIN PGP SIGNATURE-----
602     Version: GnuPG v2.0.19 (GNU/Linux)
603     <BLANKLINE>
604     ...
605     -----END PGP SIGNATURE-----
606     <BLANKLINE>
607     --===============...==--
608
609     Response to an insecure message from a person with a PGP key:
610
611     >>> student = course.course.person(email='bb@greyhavens.net')
612     >>> student.pgp_key = '4332B6E3'
613     >>> del message['Subject']
614     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
615     respond with:
616     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
617     MIME-Version: 1.0
618     Content-Disposition: inline
619     Date: ...
620     From: Robot101 <phys101@tower.edu>
621     Reply-to: Robot101 <phys101@tower.edu>
622     To: Bilbo Baggins <bb@shire.org>
623     Subject: unsigned message <hgi.jlk@home.net>
624     <BLANKLINE>
625     --===============...==
626     MIME-Version: 1.0
627     Content-Transfer-Encoding: 7bit
628     Content-Type: application/pgp-encrypted; charset="us-ascii"
629     <BLANKLINE>
630     Version: 1
631     <BLANKLINE>
632     --===============...==
633     MIME-Version: 1.0
634     Content-Transfer-Encoding: 7bit
635     Content-Description: OpenPGP encrypted message
636     Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
637     <BLANKLINE>
638     -----BEGIN PGP MESSAGE-----
639     Version: GnuPG v2.0.19 (GNU/Linux)
640     <BLANKLINE>
641     ...
642     -----END PGP MESSAGE-----
643     <BLANKLINE>
644     --===============...==--
645
646     >>> course.cleanup()
647     """
648     if stream is None:
649         stream = _sys.stdin
650     for original,message,person,subject,target in _load_messages(
651         course=course, stream=stream, mailbox=mailbox, input_=input_,
652         output=output, dry_run=dry_run,
653         continue_after_invalid_message=continue_after_invalid_message,
654         trust_email_infrastructure=trust_email_infrastructure,
655         respond=respond):
656         try:
657             handler = _get_handler(handlers=handlers, target=target)
658             _LOG.debug('handling {}'.format(target))
659             handler(
660                 basedir=basedir, course=course, message=message,
661                 person=person, subject=subject,
662                 max_late=max_late,
663                 trust_email_infrastructure=trust_email_infrastructure,
664                 dry_run=dry_run)
665         except _InvalidMessage as error:
666             error.course = course
667             error.message = original
668             for attribute,value in [('person', person),
669                                     ('subject', subject),
670                                     ('target', target)]:
671                 if (value is not None and
672                     getattr(error, attribute, None) is None):
673                     setattr(error, attribute, value)
674             _LOG.warn('invalid message {}'.format(error.message_id()))
675             if not continue_after_invalid_message:
676                 raise
677             _LOG.warn('{}'.format(error))
678             if respond:
679                 response = _get_error_response(error)
680                 respond(response)
681         except _Response as response:
682             if respond:
683                 msg = response.message
684                 if not response.complete:
685                     author = course.robot
686                     target = person
687                     msg = response.message
688                     if isinstance(response.message, _MIMEText):
689                         # Manipulate body (based on pgp_mime.append_text)
690                         original_encoding = msg.get_charset().input_charset
691                         original_payload = str(
692                             msg.get_payload(decode=True), original_encoding)
693                         new_payload = (
694                             '{},\n\n'
695                             '{}\n\n'
696                             'Yours,\n'
697                             '{}\n').format(
698                             target.alias(), original_payload, author.alias())
699                         new_encoding = _pgp_mime.guess_encoding(new_payload)
700                         if msg.get('content-transfer-encoding', None):
701                             # clear CTE so set_payload will set it properly
702                             del msg['content-transfer-encoding']
703                         msg.set_payload(new_payload, new_encoding)
704                     subject = msg['Subject']
705                     assert subject is not None, msg
706                     del msg['Subject']
707                     msg = _construct_email(
708                         author=author, targets=[person], subject=subject,
709                         message=msg)
710                 respond(msg)
711
712 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
713                    continue_after_invalid_message=False,
714                    trust_email_infrastructure=False, respond=None,
715                    dry_run=False):
716     if mailbox is None:
717         _LOG.debug('loading message from {}'.format(stream))
718         mbox = None
719         messages = [(None,_message_from_file(stream))]
720         if output is not None:
721             ombox = _mailbox.Maildir(output, factory=None, create=True)
722     elif mailbox == 'mbox':
723         mbox = _mailbox.mbox(input_, factory=None, create=False)
724         messages = mbox.items()
725         if output is not None:
726             ombox = _mailbox.mbox(output, factory=None, create=True)
727     elif mailbox == 'maildir':
728         mbox = _mailbox.Maildir(input_, factory=None, create=False)
729         messages = []
730         for key,msg in mbox.items():
731             subpath = mbox._lookup(key)
732             if subpath.endswith('.gitignore'):
733                 _LOG.debug('skipping non-message {}'.format(subpath))
734                 continue
735             messages.append((key, msg))
736         if output is not None:
737             ombox = _mailbox.Maildir(output, factory=None, create=True)
738     else:
739         raise ValueError(mailbox)
740     messages.sort(key=_get_message_time)
741     for key,msg in messages:
742         try:
743             ret = _parse_message(
744                 course=course, message=msg,
745                 trust_email_infrastructure=trust_email_infrastructure)
746         except _InvalidMessage as error:
747             error.message = msg
748             _LOG.warn('invalid message {}'.format(error.message_id()))
749             if not continue_after_invalid_message:
750                 raise
751             _LOG.warn('{}'.format(error))
752             if respond:
753                 response = _get_error_response(error)
754                 if response is not None:
755                     respond(response)
756             continue
757         if output is not None and dry_run is False:
758             # move message from input mailbox to output mailbox
759             ombox.add(msg)
760             if mbox is not None:
761                 del mbox[key]
762         yield ret
763
764 def _parse_message(course, message, trust_email_infrastructure=False):
765     """Parse an incoming email and respond if neccessary.
766
767     Return ``(msg, person, assignment, time)`` on successful parsing.
768     Return ``None`` on failure.
769     """
770     original = message
771     person = subject = target = None
772     try:
773         person = _get_message_person(course=course, message=message)
774         if person.pgp_key:
775             _LOG.debug('verify message is from {}'.format(person))
776             try:
777                 message = _get_verified_message(message, person.pgp_key)
778             except _UnsignedMessage as error:
779                 if trust_email_infrastructure:
780                     _LOG.warn('{}'.format(error))
781                 else:
782                     raise
783         subject = _get_message_subject(message=message)
784         target = _get_message_target(subject=subject)
785     except _InvalidMessage as error:
786         error.course = course
787         error.message = original
788         for attribute,value in [('person', person),
789                                 ('subject', subject),
790                                 ('target', target)]:
791             if (value is not None and
792                 getattr(error, attribute, None) is None):
793                 setattr(error, attribute, value)
794         raise
795     return (original, message, person, subject, target)
796
797 def _get_message_person(course, message):
798     sender = message['Return-Path']  # RFC 822
799     if sender is None:
800         raise NoReturnPath(message)
801     sender = sender[1:-1]  # strip wrapping '<' and '>'
802     people = list(course.find_people(email=sender))
803     if len(people) == 0:
804         raise UnregisteredAddress(message=message, address=sender)
805     if len(people) > 1:
806         raise AmbiguousAddress(message=message, address=sender, people=people)
807     return people[0]
808
809 def _get_message_subject(message):
810     """
811     >>> from email.header import Header
812     >>> from pgp_mime.email import encodedMIMEText
813     >>> message = encodedMIMEText('The answer is 42.')
814     >>> message['Message-ID'] = 'msg-id'
815     >>> _get_message_subject(message=message)
816     Traceback (most recent call last):
817       ...
818     pygrader.mailpipe.SubjectlessMessage: no subject
819     >>> del message['Subject']
820     >>> subject = Header('unicode part', 'utf-8')
821     >>> subject.append('-ascii part', 'ascii')
822     >>> message['Subject'] = subject.encode()
823     >>> _get_message_subject(message=message)
824     'unicode part-ascii part'
825     >>> del message['Subject']
826     >>> message['Subject'] = 'clean subject'
827     >>> _get_message_subject(message=message)
828     'clean subject'
829     """
830     if message['Subject'] is None:
831         raise SubjectlessMessage(subject=None, message=message)
832
833     parts = _decode_header(message['Subject'])
834     part_strings = []
835     for string,encoding in parts:
836         if encoding is None:
837             encoding = 'ascii'
838         if not isinstance(string, str):
839             string = str(string, encoding)
840         part_strings.append(string)
841     subject = ''.join(part_strings)
842     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
843     return subject.lower().replace('#', '')
844
845 def _get_message_target(subject):
846     """
847     >>> _get_message_target(subject='no tag')
848     Traceback (most recent call last):
849       ...
850     pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
851     >>> _get_message_target(subject='[] empty tag')
852     Traceback (most recent call last):
853       ...
854     pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
855     >>> _get_message_target(subject='[abc] empty tag')
856     'abc'
857     >>> _get_message_target(subject='[phys160:abc] empty tag')
858     'abc'
859     """
860     match = _TAG_REGEXP.match(subject)
861     if match is None:
862         raise _InvalidSubjectMessage(
863             subject=subject, error='no tag in {!r}'.format(subject))
864     tag = match.group(1)
865     if tag == '':
866         raise _InvalidSubjectMessage(
867             subject=subject, error='empty tag in {!r}'.format(subject))
868     target = tag.rsplit(':', 1)[-1]
869     _LOG.debug('extracted target {} -> {}'.format(subject, target))
870     return target
871
872 def _get_handler(handlers, target):
873     try:
874         handler = handlers[target]
875     except KeyError as error:
876         raise InvalidHandlerMessage(
877             target=target, handlers=handlers) from error
878     return handler
879
880 def _get_verified_message(message, pgp_key):
881     """
882
883     >>> from pgp_mime import sign, encodedMIMEText
884
885     The student composes a message...
886
887     >>> message = encodedMIMEText('1.23 joules')
888
889     ... and signs it (with the pgp-mime test key).
890
891     >>> signed = sign(message, signers=['pgp-mime-test'])
892
893     As it is being delivered, the message picks up extra headers.
894
895     >>> signed['Message-ID'] = '<01234567@home.net>'
896     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
897     >>> signed['Received'] = 'from smtp.home.net ...'
898
899     We check that the message is signed, and that it is signed by the
900     appropriate key.
901
902     >>> signed.authenticated
903     Traceback (most recent call last):
904       ...
905     AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
906     >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
907     >>> print(our_message.as_string())  # doctest: +REPORT_UDIFF
908     Content-Type: text/plain; charset="us-ascii"
909     MIME-Version: 1.0
910     Content-Transfer-Encoding: 7bit
911     Content-Disposition: inline
912     Message-ID: <01234567@home.net>
913     Received: from smtp.mail.uu.edu ...
914     Received: from smtp.home.net ...
915     <BLANKLINE>
916     1.23 joules
917     >>> our_message.authenticated
918     True
919
920     If it is signed, but not by the right key, we get an error.
921
922     >>> print(_get_verified_message(signed, pgp_key='01234567'))
923     Traceback (most recent call last):
924       ...
925     pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
926
927     If it is not signed at all, we get another error.
928
929     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
930     Traceback (most recent call last):
931       ...
932     pygrader.handler.UnsignedMessage: unsigned message
933     """
934     mid = message['message-id']
935     try:
936         decrypted,verified,result = _pgp_mime.verify(message=message)
937     except (ValueError, AssertionError) as error:
938         raise _UnsignedMessage(message=message) from error
939     _LOG.debug(str(result, 'utf-8'))
940     tree = _etree.fromstring(result.replace(b'\x00', b''))
941     match = None
942     fingerprints = []
943     for signature in tree.findall('.//signature'):
944         for fingerprint in signature.iterchildren('fpr'):
945             fingerprints.append(fingerprint)
946     matches = [f for f in fingerprints if f.text.endswith(pgp_key)]
947     if len(matches) == 0:
948         raise WrongSignatureMessage(
949             message=message, pgp_key=pgp_key, fingerprints=fingerprints,
950             decrypted=decrypted)
951     match = matches[0]
952     if not verified:
953         sumhex = list(signature.iterchildren('summary'))[0].get('value')
954         summary = int(sumhex, 16)
955         if summary != 0:
956             raise UnverifiedSignatureMessage(
957                 message=message, signature=signature, decrypted=decrypted)
958         # otherwise, we may have an untrusted key.  We'll count that
959         # as verified here, because the caller is explicity looking
960         # for signatures by this fingerprint.
961     for k,v in message.items(): # copy over useful headers
962         if k.lower() not in ['content-type',
963                              'mime-version',
964                              'content-disposition',
965                              ]:
966             decrypted[k] = v
967     decrypted.authenticated = True
968     return decrypted
969
970 def _get_error_response(error):
971     author = error.course.robot
972     target = getattr(error, 'person', None)
973     subject = str(error)
974     if isinstance(error, _InvalidSubmission):
975         subject = 'Received invalid {} submission'.format(
976             error.assignment.name)
977         text = (
978             'We received your submission for {}, but you are not\n'
979             'allowed to submit that assignment via email.'
980             ).format(error.assignment.name)
981     elif isinstance(error, _MissingGradeMessage):
982         subject = 'No grade in {!r}'.format(error.subject)
983         text = (
984             'Your grade submission did not include a text/plain\n'
985             'part containing the new grade and comment.'
986             )
987     elif isinstance(error, InvalidHandlerMessage):
988         targets = sorted(error.handlers.keys())
989         if not targets:
990             hint = (
991                 'In fact, there are no available handlers for this\n'
992                 'course!')
993         else:
994             hint = (
995                 'Perhaps you meant to use one of the following:\n'
996                 '  {}').format('\n  '.join(targets))
997         text = (
998             'We got an email from you with the following subject:\n'
999             '  {!r}\n'
1000             'which does not match any submittable handler name for\n'
1001             '{}.\n'
1002             '{}').format(error.subject, error.course.name, hint)
1003     elif isinstance(error, SubjectlessMessage):
1004         subject = 'no subject in {}'.format(error.message['Message-ID'])
1005         text = 'We received an email message from you without a subject.'
1006     elif isinstance(error, AmbiguousAddress):
1007         text = (
1008             'Multiple people match {} ({})'.format(
1009                 error.address, ', '.join(p.name for p in error.people)))
1010     elif isinstance(error, UnregisteredAddress):
1011         target = _Person(name=error.address, emails=[error.address])
1012         text = (
1013             'Your email address is not registered with pygrader for\n'
1014             '{}.  If you feel it should be, contact your professor\n'
1015             'or TA.').format(error.course.name)
1016     elif isinstance(error, NoReturnPath):
1017         return
1018     elif isinstance(error, _InvalidAssignmentSubject):
1019         if error.assignments:
1020             hint = (
1021                 'but it matches several assignments:\n'
1022                 '  * {}').format('\n  * '.join(
1023                     a.name for a in error.assignments))
1024         else:
1025             # prefer a submittable example assignment
1026             assignments = [
1027                 a for a in error.course.assignments if a.submittable]
1028             assignments += course.assignments  # but fall back to any one
1029             hint = (
1030                 'Remember to use the full name for the assignment in the\n'
1031                 'subject.  For example:\n'
1032                 '  {} submission').format(assignments[0].name)
1033         text = (
1034             'We got an email from you with the following subject:\n'
1035             '  {!r}\n{}').format(error.subject, hint)
1036     elif isinstance(error, _InvalidStudentSubject):
1037         text = (
1038             'We got an email from you with the following subject:\n'
1039             '  {!r}\n'
1040             'but it matches several students:\n'
1041             '  * {}').format(
1042             error.subject, '\n  * '.join(s.name for s in error.students))
1043     elif isinstance(error, _InvalidSubjectMessage):
1044         text = (
1045             'We received an email message from you with an invalid\n'
1046             'subject.')
1047     elif isinstance(error, _UnsignedMessage):
1048         subject = 'unsigned message {}'.format(error.message['Message-ID'])
1049         text = (
1050             'We received an email message from you without a valid\n'
1051             'PGP signature.'
1052             )
1053     elif isinstance(error, _PermissionViolationMessage):
1054         text = (
1055             'We got an email from you with the following subject:\n'
1056             '  {!r}\n'
1057             "but you can't do that unless you belong to one of the\n"
1058             'following groups:\n'
1059             '  * {}').format(
1060             error.subject, '\n  * '.join(error.allowed_groups))
1061     elif isinstance(error, _InvalidMessage):
1062         text = subject
1063     else:
1064         raise NotImplementedError((type(error), error))
1065     if target is None:
1066         raise NotImplementedError((type(error), error))
1067     return _construct_response(
1068         author=author,
1069         targets=[target],
1070         subject=subject,
1071         text=(
1072             '{},\n\n'
1073             '{}\n\n'
1074             'Yours,\n'
1075             '{}\n'.format(target.alias(), text, author.alias())),
1076         original=error.message)
1077
1078 def _get_message_time(key_message):
1079     "Key function for sorting mailbox (key,message) tuples."
1080     key,message = key_message
1081     return _message_time(message)