1 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
3 # This file is part of pygrader.
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
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.
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/>.
17 "Incoming email processing."
19 from __future__ import absolute_import
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
28 import pgp_mime as _pgp_mime
29 from lxml import etree as _etree
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
37 from .handler import InsecureMessage as _InsecureMessage
38 from .handler import InvalidAssignmentSubject as _InvalidAssignmentSubject
39 from .handler import InvalidMessage as _InvalidMessage
40 from .handler import InvalidStudentSubject as _InvalidStudentSubject
41 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
42 from .handler import PermissionViolationMessage as _PermissionViolationMessage
43 from .handler import Response as _Response
44 from .handler import UnsignedMessage as _UnsignedMessage
45 from .handler.get import run as _handle_get
46 from .handler.grade import run as _handle_grade
47 from .handler.grade import MissingGradeMessage as _MissingGradeMessage
48 from .handler.submission import run as _handle_submission
49 from .handler.submission import InvalidSubmission as _InvalidSubmission
52 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
55 class NoReturnPath (_InvalidMessage):
56 def __init__(self, address, **kwargs):
57 if 'error' not in kwargs:
58 kwargs['error'] = 'no Return-Path'
59 super(NoReturnPath, self).__init__(**kwargs)
62 class UnregisteredAddress (_InvalidMessage):
63 def __init__(self, address, **kwargs):
64 if 'error' not in kwargs:
65 kwargs['error'] = 'unregistered address {}'.format(address)
66 super(UnregisteredAddress, self).__init__(**kwargs)
67 self.address = address
70 class AmbiguousAddress (_InvalidMessage):
71 def __init__(self, address, people, **kwargs):
72 if 'error' not in kwargs:
73 kwargs['error'] = 'ambiguous address {}'.format(address)
74 super(AmbiguousAddress, self).__init__(**kwargs)
75 self.address = address
79 class WrongSignatureMessage (_InsecureMessage):
80 def __init__(self, pgp_key=None, fingerprints=None, decrypted=None,
82 if 'error' not in kwargs:
83 kwargs['error'] = 'not signed by the expected key'
84 super(WrongSignatureMessage, self).__init__(**kwargs)
85 self.pgp_key = pgp_key
86 self.fingerprints = fingerprints
87 self.decrypted = decrypted
89 class UnverifiedSignatureMessage (_InsecureMessage):
90 def __init__(self, signature=None, decrypted=None, **kwargs):
91 if 'error' not in kwargs:
92 kwargs['error'] = 'unverified signature'
93 super(UnverifiedSignatureMessage, self).__init__(**kwargs)
94 self.signature = signature
95 self.decrypted = decrypted
98 class SubjectlessMessage (_InvalidSubjectMessage):
99 def __init__(self, **kwargs):
100 if 'error' not in kwargs:
101 kwargs['error'] = 'no subject'
102 super(SubjectlessMessage, self).__init__(**kwargs)
105 class InvalidHandlerMessage (_InvalidSubjectMessage):
106 def __init__(self, target=None, handlers=None, **kwargs):
107 if 'error' not in kwargs:
108 kwargs['error'] = 'no handler for {!r}'.format(target)
109 super(InvalidHandlerMessage, self).__init__(**kwargs)
111 self.handlers = handlers
114 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
115 output=None, continue_after_invalid_message=False, max_late=0,
116 trust_email_infrastructure=False,
119 'grade': _handle_grade,
120 'submit': _handle_submission,
121 }, respond=None, dry_run=False, **kwargs):
122 """Run from procmail to sort incomming submissions
124 For example, you can setup your ``.procmailrc`` like this::
129 DEFAULT=$MAILDIR/mbox
130 LOGFILE=$MAILDIR/procmail.log
132 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
134 # Grab all incoming homeworks emails. This rule eats matching emails
135 # (i.e. no further procmail processing).
137 * ^Subject:.*\[phys160:submit]
138 | "$PYGRADE_MAILPIPE" mailpipe
140 If you don't want procmail to eat the message, you can use the
141 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
143 >>> from io import StringIO
144 >>> from pgp_mime.email import encodedMIMEText
145 >>> from .handler import InvalidMessage, Response
146 >>> from .test.course import StubCourse
148 >>> course = StubCourse()
149 >>> def respond(message):
150 ... print('respond with:\\n{}'.format(message.as_string()))
151 >>> def process(message):
153 ... basedir=course.basedir, course=course.course,
154 ... stream=StringIO(message.as_string()),
155 ... output=course.mailbox,
156 ... continue_after_invalid_message=True,
158 >>> message = encodedMIMEText('The answer is 42.')
159 >>> message['Message-ID'] = '<123.456@home.net>'
160 >>> message['Received'] = (
161 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
162 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
163 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
164 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
165 >>> message['To'] = 'phys101 <phys101@tower.edu>'
166 >>> message['Subject'] = '[submit] assignment 1'
168 Messages with unrecognized ``Return-Path``\s are silently dropped:
170 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
171 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
178 Response to a message from an unregistered person:
180 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
181 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
183 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
185 Content-Disposition: inline
187 From: Robot101 <phys101@tower.edu>
188 Reply-to: Robot101 <phys101@tower.edu>
189 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
190 Subject: unregistered address invalid.return.path@home.net
192 --===============...==
193 Content-Type: multipart/mixed; boundary="===============...=="
196 --===============...==
197 Content-Type: text/plain; charset="us-ascii"
199 Content-Transfer-Encoding: 7bit
200 Content-Disposition: inline
202 invalid.return.path@home.net,
204 Your email address is not registered with pygrader for
205 Physics 101. If you feel it should be, contact your professor
211 --===============...==
212 Content-Type: message/rfc822
215 Content-Type: text/plain; charset="us-ascii"
217 Content-Transfer-Encoding: 7bit
218 Content-Disposition: inline
219 Message-ID: <123.456@home.net>
220 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)
221 From: Billy B <bb@greyhavens.net>
222 To: phys101 <phys101@tower.edu>
223 Subject: [submit] assignment 1
224 Return-Path: <invalid.return.path@home.net>
227 --===============...==--
228 --===============...==
230 Content-Transfer-Encoding: 7bit
231 Content-Description: OpenPGP digital signature
232 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
234 -----BEGIN PGP SIGNATURE-----
235 Version: GnuPG v2.0.19 (GNU/Linux)
238 -----END PGP SIGNATURE-----
240 --===============...==--
242 If we add a valid ``Return-Path``, we get the expected delivery:
244 >>> del message['Return-Path']
245 >>> message['Return-Path'] = '<bb@greyhavens.net>'
246 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
248 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
250 Content-Disposition: inline
252 From: Robot101 <phys101@tower.edu>
253 Reply-to: Robot101 <phys101@tower.edu>
254 To: Bilbo Baggins <bb@shire.org>
255 Subject: Received Assignment 1 submission
257 --===============...==
258 Content-Type: text/plain; charset="us-ascii"
260 Content-Disposition: inline
261 Content-Transfer-Encoding: 7bit
265 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
270 --===============...==
272 Content-Transfer-Encoding: 7bit
273 Content-Description: OpenPGP digital signature
274 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
276 -----BEGIN PGP SIGNATURE-----
277 Version: GnuPG v2.0.19 (GNU/Linux)
280 -----END PGP SIGNATURE-----
282 --===============...==--
284 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
286 Bilbo_Baggins/Assignment_1
287 Bilbo_Baggins/Assignment_1/mail
288 Bilbo_Baggins/Assignment_1/mail/cur
289 Bilbo_Baggins/Assignment_1/mail/new
290 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
291 Bilbo_Baggins/Assignment_1/mail/tmp
299 The last ``Received`` is used to timestamp the message:
301 >>> del message['Message-ID']
302 >>> message['Message-ID'] = '<abc.def@home.net>'
303 >>> del message['Received']
304 >>> message['Received'] = (
305 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
306 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
307 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
308 >>> message['Received'] = (
309 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
310 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
311 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
312 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
314 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
316 Content-Disposition: inline
318 From: Robot101 <phys101@tower.edu>
319 Reply-to: Robot101 <phys101@tower.edu>
320 To: Bilbo Baggins <bb@shire.org>
321 Subject: Received Assignment 1 submission
323 --===============...==
324 Content-Type: text/plain; charset="us-ascii"
326 Content-Disposition: inline
327 Content-Transfer-Encoding: 7bit
331 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
336 --===============...==
338 Content-Transfer-Encoding: 7bit
339 Content-Description: OpenPGP digital signature
340 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
342 -----BEGIN PGP SIGNATURE-----
343 Version: GnuPG v2.0.19 (GNU/Linux)
346 -----END PGP SIGNATURE-----
348 --===============...==--
350 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
352 Bilbo_Baggins/Assignment_1
353 Bilbo_Baggins/Assignment_1/late
354 Bilbo_Baggins/Assignment_1/mail
355 Bilbo_Baggins/Assignment_1/mail/cur
356 Bilbo_Baggins/Assignment_1/mail/new
357 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
358 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
359 Bilbo_Baggins/Assignment_1/mail/tmp
368 You can send receipts to the acknowledge incoming messages, which
369 includes warnings about dropped messages (except for messages
370 without ``Return-Path`` and messages where the ``Return-Path``
371 email belongs to multiple ``People``. The former should only
372 occur with malicious emails, and the latter with improper pygrader
375 Response to a successful submission:
377 >>> del message['Message-ID']
378 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
379 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
381 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
383 Content-Disposition: inline
385 From: Robot101 <phys101@tower.edu>
386 Reply-to: Robot101 <phys101@tower.edu>
387 To: Bilbo Baggins <bb@shire.org>
388 Subject: Received Assignment 1 submission
390 --===============...==
391 Content-Type: text/plain; charset="us-ascii"
393 Content-Disposition: inline
394 Content-Transfer-Encoding: 7bit
398 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
403 --===============...==
405 Content-Transfer-Encoding: 7bit
406 Content-Description: OpenPGP digital signature
407 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
409 -----BEGIN PGP SIGNATURE-----
410 Version: GnuPG v2.0.19 (GNU/Linux)
413 -----END PGP SIGNATURE-----
415 --===============...==--
417 Response to a submission on an unsubmittable assignment:
419 >>> del message['Subject']
420 >>> message['Subject'] = '[submit] attendance 1'
421 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
423 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
425 Content-Disposition: inline
427 From: Robot101 <phys101@tower.edu>
428 Reply-to: Robot101 <phys101@tower.edu>
429 To: Bilbo Baggins <bb@shire.org>
430 Subject: Received invalid Attendance 1 submission
432 --===============...==
433 Content-Type: multipart/mixed; boundary="===============...=="
436 --===============...==
437 Content-Type: text/plain; charset="us-ascii"
439 Content-Transfer-Encoding: 7bit
440 Content-Disposition: inline
444 We received your submission for Attendance 1, but you are not
445 allowed to submit that assignment via email.
450 --===============...==
451 Content-Type: message/rfc822
454 Content-Type: text/plain; charset="us-ascii"
456 Content-Transfer-Encoding: 7bit
457 Content-Disposition: inline
458 From: Billy B <bb@greyhavens.net>
459 To: phys101 <phys101@tower.edu>
460 Return-Path: <bb@greyhavens.net>
461 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)
462 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)
463 Message-ID: <hgi.jlk@home.net>
464 Subject: [submit] attendance 1
467 --===============...==--
468 --===============...==
470 Content-Transfer-Encoding: 7bit
471 Content-Description: OpenPGP digital signature
472 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
474 -----BEGIN PGP SIGNATURE-----
475 Version: GnuPG v2.0.19 (GNU/Linux)
478 -----END PGP SIGNATURE-----
480 --===============...==--
482 Response to a bad subject:
484 >>> del message['Subject']
485 >>> message['Subject'] = 'need help for the first homework'
486 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
488 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
490 Content-Disposition: inline
492 From: Robot101 <phys101@tower.edu>
493 Reply-to: Robot101 <phys101@tower.edu>
494 To: Bilbo Baggins <bb@shire.org>
495 Subject: no tag in 'need help for the first homework'
497 --===============...==
498 Content-Type: multipart/mixed; boundary="===============...=="
501 --===============...==
502 Content-Type: text/plain; charset="us-ascii"
504 Content-Transfer-Encoding: 7bit
505 Content-Disposition: inline
509 We received an email message from you with an invalid
515 --===============...==
516 Content-Type: message/rfc822
519 Content-Type: text/plain; charset="us-ascii"
521 Content-Transfer-Encoding: 7bit
522 Content-Disposition: inline
523 From: Billy B <bb@greyhavens.net>
524 To: phys101 <phys101@tower.edu>
525 Return-Path: <bb@greyhavens.net>
526 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)
527 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)
528 Message-ID: <hgi.jlk@home.net>
529 Subject: need help for the first homework
532 --===============...==--
533 --===============...==
535 Content-Transfer-Encoding: 7bit
536 Content-Description: OpenPGP digital signature
537 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
539 -----BEGIN PGP SIGNATURE-----
540 Version: GnuPG v2.0.19 (GNU/Linux)
543 -----END PGP SIGNATURE-----
545 --===============...==--
547 Response to a missing subject:
549 >>> del message['Subject']
550 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
552 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
554 Content-Disposition: inline
556 From: Robot101 <phys101@tower.edu>
557 Reply-to: Robot101 <phys101@tower.edu>
558 To: Bilbo Baggins <bb@shire.org>
559 Subject: no subject in <hgi.jlk@home.net>
561 --===============...==
562 Content-Type: multipart/mixed; boundary="===============...=="
565 --===============...==
566 Content-Type: text/plain; charset="us-ascii"
568 Content-Transfer-Encoding: 7bit
569 Content-Disposition: inline
573 We received an email message from you without a subject.
578 --===============...==
579 Content-Type: message/rfc822
582 Content-Type: text/plain; charset="us-ascii"
584 Content-Transfer-Encoding: 7bit
585 Content-Disposition: inline
586 From: Billy B <bb@greyhavens.net>
587 To: phys101 <phys101@tower.edu>
588 Return-Path: <bb@greyhavens.net>
589 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)
590 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)
591 Message-ID: <hgi.jlk@home.net>
594 --===============...==--
595 --===============...==
597 Content-Transfer-Encoding: 7bit
598 Content-Description: OpenPGP digital signature
599 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
601 -----BEGIN PGP SIGNATURE-----
602 Version: GnuPG v2.0.19 (GNU/Linux)
605 -----END PGP SIGNATURE-----
607 --===============...==--
609 Response to an insecure message from a person with a PGP key:
611 >>> student = course.course.person(email='bb@greyhavens.net')
612 >>> student.pgp_key = '4332B6E3'
613 >>> del message['Subject']
614 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
616 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
618 Content-Disposition: inline
620 From: Robot101 <phys101@tower.edu>
621 Reply-to: Robot101 <phys101@tower.edu>
622 To: Bilbo Baggins <bb@shire.org>
623 Subject: unsigned message <hgi.jlk@home.net>
625 --===============...==
627 Content-Transfer-Encoding: 7bit
628 Content-Type: application/pgp-encrypted; charset="us-ascii"
632 --===============...==
634 Content-Transfer-Encoding: 7bit
635 Content-Description: OpenPGP encrypted message
636 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
638 -----BEGIN PGP MESSAGE-----
639 Version: GnuPG v2.0.19 (GNU/Linux)
642 -----END PGP MESSAGE-----
644 --===============...==--
650 for original,message,person,subject,target in _load_messages(
651 course=course, stream=stream, mailbox=mailbox, input_=input_,
652 output=output, dry_run=dry_run,
653 continue_after_invalid_message=continue_after_invalid_message,
654 trust_email_infrastructure=trust_email_infrastructure,
657 handler = _get_handler(handlers=handlers, target=target)
658 _LOG.debug('handling {}'.format(target))
660 basedir=basedir, course=course, message=message,
661 person=person, subject=subject,
663 trust_email_infrastructure=trust_email_infrastructure,
665 except _InvalidMessage as error:
666 error.course = course
667 error.message = original
668 for attribute,value in [('person', person),
669 ('subject', subject),
671 if (value is not None and
672 getattr(error, attribute, None) is None):
673 setattr(error, attribute, value)
674 _LOG.warn('invalid message {}'.format(error.message_id()))
675 if not continue_after_invalid_message:
677 _LOG.warn('{}'.format(error))
679 response = _get_error_response(error)
681 except _Response as response:
683 msg = response.message
684 if not response.complete:
685 author = course.robot
687 msg = response.message
688 if isinstance(response.message, _MIMEText):
689 # Manipulate body (based on pgp_mime.append_text)
690 original_encoding = msg.get_charset().input_charset
691 original_payload = str(
692 msg.get_payload(decode=True), original_encoding)
698 target.alias(), original_payload, author.alias())
699 new_encoding = _pgp_mime.guess_encoding(new_payload)
700 if msg.get('content-transfer-encoding', None):
701 # clear CTE so set_payload will set it properly
702 del msg['content-transfer-encoding']
703 msg.set_payload(new_payload, new_encoding)
704 subject = msg['Subject']
705 assert subject is not None, msg
707 msg = _construct_email(
708 author=author, targets=[person], subject=subject,
712 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
713 continue_after_invalid_message=False,
714 trust_email_infrastructure=False, respond=None,
717 _LOG.debug('loading message from {}'.format(stream))
719 messages = [(None,_message_from_file(stream))]
720 if output is not None:
721 ombox = _mailbox.Maildir(output, factory=None, create=True)
722 elif mailbox == 'mbox':
723 mbox = _mailbox.mbox(input_, factory=None, create=False)
724 messages = mbox.items()
725 if output is not None:
726 ombox = _mailbox.mbox(output, factory=None, create=True)
727 elif mailbox == 'maildir':
728 mbox = _mailbox.Maildir(input_, factory=None, create=False)
730 for key,msg in mbox.items():
731 subpath = mbox._lookup(key)
732 if subpath.endswith('.gitignore'):
733 _LOG.debug('skipping non-message {}'.format(subpath))
735 messages.append((key, msg))
736 if output is not None:
737 ombox = _mailbox.Maildir(output, factory=None, create=True)
739 raise ValueError(mailbox)
740 messages.sort(key=_get_message_time)
741 for key,msg in messages:
743 ret = _parse_message(
744 course=course, message=msg,
745 trust_email_infrastructure=trust_email_infrastructure)
746 except _InvalidMessage as error:
748 _LOG.warn('invalid message {}'.format(error.message_id()))
749 if not continue_after_invalid_message:
751 _LOG.warn('{}'.format(error))
753 response = _get_error_response(error)
754 if response is not None:
757 if output is not None and dry_run is False:
758 # move message from input mailbox to output mailbox
764 def _parse_message(course, message, trust_email_infrastructure=False):
765 """Parse an incoming email and respond if neccessary.
767 Return ``(msg, person, assignment, time)`` on successful parsing.
768 Return ``None`` on failure.
771 person = subject = target = None
773 person = _get_message_person(course=course, message=message)
775 _LOG.debug('verify message is from {}'.format(person))
777 message = _get_verified_message(message, person.pgp_key)
778 except _UnsignedMessage as error:
779 if trust_email_infrastructure:
780 _LOG.warn('{}'.format(error))
783 subject = _get_message_subject(message=message)
784 target = _get_message_target(subject=subject)
785 except _InvalidMessage as error:
786 error.course = course
787 error.message = original
788 for attribute,value in [('person', person),
789 ('subject', subject),
791 if (value is not None and
792 getattr(error, attribute, None) is None):
793 setattr(error, attribute, value)
795 return (original, message, person, subject, target)
797 def _get_message_person(course, message):
798 sender = message['Return-Path'] # RFC 822
800 raise NoReturnPath(message)
801 sender = sender[1:-1] # strip wrapping '<' and '>'
802 people = list(course.find_people(email=sender))
804 raise UnregisteredAddress(message=message, address=sender)
806 raise AmbiguousAddress(message=message, address=sender, people=people)
809 def _get_message_subject(message):
811 >>> from email.header import Header
812 >>> from pgp_mime.email import encodedMIMEText
813 >>> message = encodedMIMEText('The answer is 42.')
814 >>> message['Message-ID'] = 'msg-id'
815 >>> _get_message_subject(message=message)
816 Traceback (most recent call last):
818 pygrader.mailpipe.SubjectlessMessage: no subject
819 >>> del message['Subject']
820 >>> subject = Header('unicode part', 'utf-8')
821 >>> subject.append('-ascii part', 'ascii')
822 >>> message['Subject'] = subject.encode()
823 >>> _get_message_subject(message=message)
824 'unicode part-ascii part'
825 >>> del message['Subject']
826 >>> message['Subject'] = 'clean subject'
827 >>> _get_message_subject(message=message)
830 if message['Subject'] is None:
831 raise SubjectlessMessage(subject=None, message=message)
833 parts = _decode_header(message['Subject'])
835 for string,encoding in parts:
838 if not isinstance(string, str):
839 string = str(string, encoding)
840 part_strings.append(string)
841 subject = ''.join(part_strings)
842 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
843 return subject.lower().replace('#', '')
845 def _get_message_target(subject):
847 >>> _get_message_target(subject='no tag')
848 Traceback (most recent call last):
850 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
851 >>> _get_message_target(subject='[] empty tag')
852 Traceback (most recent call last):
854 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
855 >>> _get_message_target(subject='[abc] empty tag')
857 >>> _get_message_target(subject='[phys160:abc] empty tag')
860 match = _TAG_REGEXP.match(subject)
862 raise _InvalidSubjectMessage(
863 subject=subject, error='no tag in {!r}'.format(subject))
866 raise _InvalidSubjectMessage(
867 subject=subject, error='empty tag in {!r}'.format(subject))
868 target = tag.rsplit(':', 1)[-1]
869 _LOG.debug('extracted target {} -> {}'.format(subject, target))
872 def _get_handler(handlers, target):
874 handler = handlers[target]
875 except KeyError as error:
876 raise InvalidHandlerMessage(
877 target=target, handlers=handlers) from error
880 def _get_verified_message(message, pgp_key):
883 >>> from pgp_mime import sign, encodedMIMEText
885 The student composes a message...
887 >>> message = encodedMIMEText('1.23 joules')
889 ... and signs it (with the pgp-mime test key).
891 >>> signed = sign(message, signers=['pgp-mime-test'])
893 As it is being delivered, the message picks up extra headers.
895 >>> signed['Message-ID'] = '<01234567@home.net>'
896 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
897 >>> signed['Received'] = 'from smtp.home.net ...'
899 We check that the message is signed, and that it is signed by the
902 >>> signed.authenticated
903 Traceback (most recent call last):
905 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
906 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
907 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
908 Content-Type: text/plain; charset="us-ascii"
910 Content-Transfer-Encoding: 7bit
911 Content-Disposition: inline
912 Message-ID: <01234567@home.net>
913 Received: from smtp.mail.uu.edu ...
914 Received: from smtp.home.net ...
917 >>> our_message.authenticated
920 If it is signed, but not by the right key, we get an error.
922 >>> print(_get_verified_message(signed, pgp_key='01234567'))
923 Traceback (most recent call last):
925 pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
927 If it is not signed at all, we get another error.
929 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
930 Traceback (most recent call last):
932 pygrader.handler.UnsignedMessage: unsigned message
934 mid = message['message-id']
936 decrypted,verified,result = _pgp_mime.verify(message=message)
937 except (ValueError, AssertionError) as error:
938 raise _UnsignedMessage(message=message) from error
939 _LOG.debug(str(result, 'utf-8'))
940 tree = _etree.fromstring(result.replace(b'\x00', b''))
943 for signature in tree.findall('.//signature'):
944 for fingerprint in signature.iterchildren('fpr'):
945 fingerprints.append(fingerprint)
946 matches = [f for f in fingerprints if f.text.endswith(pgp_key)]
947 if len(matches) == 0:
948 raise WrongSignatureMessage(
949 message=message, pgp_key=pgp_key, fingerprints=fingerprints,
953 sumhex = list(signature.iterchildren('summary'))[0].get('value')
954 summary = int(sumhex, 16)
956 raise UnverifiedSignatureMessage(
957 message=message, signature=signature, decrypted=decrypted)
958 # otherwise, we may have an untrusted key. We'll count that
959 # as verified here, because the caller is explicity looking
960 # for signatures by this fingerprint.
961 for k,v in message.items(): # copy over useful headers
962 if k.lower() not in ['content-type',
964 'content-disposition',
967 decrypted.authenticated = True
970 def _get_error_response(error):
971 author = error.course.robot
972 target = getattr(error, 'person', None)
974 if isinstance(error, _InvalidSubmission):
975 subject = 'Received invalid {} submission'.format(
976 error.assignment.name)
978 'We received your submission for {}, but you are not\n'
979 'allowed to submit that assignment via email.'
980 ).format(error.assignment.name)
981 elif isinstance(error, _MissingGradeMessage):
982 subject = 'No grade in {!r}'.format(error.subject)
984 'Your grade submission did not include a text/plain\n'
985 'part containing the new grade and comment.'
987 elif isinstance(error, InvalidHandlerMessage):
988 targets = sorted(error.handlers.keys())
991 'In fact, there are no available handlers for this\n'
995 'Perhaps you meant to use one of the following:\n'
996 ' {}').format('\n '.join(targets))
998 'We got an email from you with the following subject:\n'
1000 'which does not match any submittable handler name for\n'
1002 '{}').format(error.subject, error.course.name, hint)
1003 elif isinstance(error, SubjectlessMessage):
1004 subject = 'no subject in {}'.format(error.message['Message-ID'])
1005 text = 'We received an email message from you without a subject.'
1006 elif isinstance(error, AmbiguousAddress):
1008 'Multiple people match {} ({})'.format(
1009 error.address, ', '.join(p.name for p in error.people)))
1010 elif isinstance(error, UnregisteredAddress):
1011 target = _Person(name=error.address, emails=[error.address])
1013 'Your email address is not registered with pygrader for\n'
1014 '{}. If you feel it should be, contact your professor\n'
1015 'or TA.').format(error.course.name)
1016 elif isinstance(error, NoReturnPath):
1018 elif isinstance(error, _InvalidAssignmentSubject):
1019 if error.assignments:
1021 'but it matches several assignments:\n'
1022 ' * {}').format('\n * '.join(
1023 a.name for a in error.assignments))
1025 # prefer a submittable example assignment
1027 a for a in error.course.assignments if a.submittable]
1028 assignments += course.assignments # but fall back to any one
1030 'Remember to use the full name for the assignment in the\n'
1031 'subject. For example:\n'
1032 ' {} submission').format(assignments[0].name)
1034 'We got an email from you with the following subject:\n'
1035 ' {!r}\n{}').format(error.subject, hint)
1036 elif isinstance(error, _InvalidStudentSubject):
1038 'We got an email from you with the following subject:\n'
1040 'but it matches several students:\n'
1042 error.subject, '\n * '.join(s.name for s in error.students))
1043 elif isinstance(error, _InvalidSubjectMessage):
1045 'We received an email message from you with an invalid\n'
1047 elif isinstance(error, _UnsignedMessage):
1048 subject = 'unsigned message {}'.format(error.message['Message-ID'])
1050 'We received an email message from you without a valid\n'
1053 elif isinstance(error, _PermissionViolationMessage):
1055 'We got an email from you with the following subject:\n'
1057 "but you can't do that unless you belong to one of the\n"
1058 'following groups:\n'
1060 error.subject, '\n * '.join(error.allowed_groups))
1061 elif isinstance(error, _InvalidMessage):
1064 raise NotImplementedError((type(error), error))
1066 raise NotImplementedError((type(error), error))
1067 return _construct_response(
1075 '{}\n'.format(target.alias(), text, author.alias())),
1076 original=error.message)
1078 def _get_message_time(key_message):
1079 "Key function for sorting mailbox (key,message) tuples."
1080 key,message = key_message
1081 return _message_time(message)