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