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