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