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