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