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