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 import pgp_mime.key as _pgp_mime_key
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, signatures=None, fingerprints=None,
81 decrypted=None, **kwargs):
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.signatures = signatures
87 self.fingerprints = fingerprints
88 self.decrypted = decrypted
91 class UnverifiedSignatureMessage (_InsecureMessage):
92 def __init__(self, signature=None, decrypted=None, **kwargs):
93 if 'error' not in kwargs:
94 kwargs['error'] = 'unverified signature'
95 super(UnverifiedSignatureMessage, self).__init__(**kwargs)
96 self.signature = signature
97 self.decrypted = decrypted
100 class SubjectlessMessage (_InvalidSubjectMessage):
101 def __init__(self, **kwargs):
102 if 'error' not in kwargs:
103 kwargs['error'] = 'no subject'
104 super(SubjectlessMessage, self).__init__(**kwargs)
107 class InvalidHandlerMessage (_InvalidSubjectMessage):
108 def __init__(self, target=None, handlers=None, **kwargs):
109 if 'error' not in kwargs:
110 kwargs['error'] = 'no handler for {!r}'.format(target)
111 super(InvalidHandlerMessage, self).__init__(**kwargs)
113 self.handlers = handlers
116 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
117 output=None, continue_after_invalid_message=False, max_late=0,
118 trust_email_infrastructure=False,
121 'grade': _handle_grade,
122 'submit': _handle_submission,
123 }, respond=None, dry_run=False, **kwargs):
124 """Run from procmail to sort incomming submissions
126 For example, you can setup your ``.procmailrc`` like this::
131 DEFAULT=$MAILDIR/mbox
132 LOGFILE=$MAILDIR/procmail.log
134 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
136 # Grab all incoming homeworks emails. This rule eats matching emails
137 # (i.e. no further procmail processing).
139 * ^Subject:.*\[phys160:submit]
140 | "$PYGRADE_MAILPIPE" mailpipe
142 If you don't want procmail to eat the message, you can use the
143 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
145 >>> from io import StringIO
146 >>> from pgp_mime.email import encodedMIMEText
147 >>> from .handler import InvalidMessage, Response
148 >>> from .test.course import StubCourse
150 >>> course = StubCourse()
151 >>> def respond(message):
152 ... print('respond with:\\n{}'.format(message.as_string()))
153 >>> def process(message):
155 ... basedir=course.basedir, course=course.course,
156 ... stream=StringIO(message.as_string()),
157 ... output=course.mailbox,
158 ... continue_after_invalid_message=True,
160 >>> message = encodedMIMEText('The answer is 42.')
161 >>> message['Message-ID'] = '<123.456@home.net>'
162 >>> message['Received'] = (
163 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
164 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
165 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
166 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
167 >>> message['To'] = 'phys101 <phys101@tower.edu>'
168 >>> message['Subject'] = '[submit] assignment 1'
170 Messages with unrecognized ``Return-Path``\s are silently dropped:
172 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
173 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
180 Response to a message from an unregistered person:
182 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
183 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
185 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
187 Content-Disposition: inline
189 From: Robot101 <phys101@tower.edu>
190 Reply-to: Robot101 <phys101@tower.edu>
191 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
192 Subject: unregistered address invalid.return.path@home.net
194 --===============...==
195 Content-Type: multipart/mixed; boundary="===============...=="
198 --===============...==
199 Content-Type: text/plain; charset="us-ascii"
201 Content-Transfer-Encoding: 7bit
202 Content-Disposition: inline
204 invalid.return.path@home.net,
206 Your email address is not registered with pygrader for
207 Physics 101. If you feel it should be, contact your professor
213 --===============...==
214 Content-Type: message/rfc822
217 Content-Type: text/plain; charset="us-ascii"
219 Content-Transfer-Encoding: 7bit
220 Content-Disposition: inline
221 Message-ID: <123.456@home.net>
222 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)
223 From: Billy B <bb@greyhavens.net>
224 To: phys101 <phys101@tower.edu>
225 Subject: [submit] assignment 1
226 Return-Path: <invalid.return.path@home.net>
229 --===============...==--
230 --===============...==
232 Content-Transfer-Encoding: 7bit
233 Content-Description: OpenPGP digital signature
234 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
236 -----BEGIN PGP SIGNATURE-----
237 Version: GnuPG v2.0.19 (GNU/Linux)
240 -----END PGP SIGNATURE-----
242 --===============...==--
244 If we add a valid ``Return-Path``, we get the expected delivery:
246 >>> del message['Return-Path']
247 >>> message['Return-Path'] = '<bb@greyhavens.net>'
248 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
250 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
252 Content-Disposition: inline
254 From: Robot101 <phys101@tower.edu>
255 Reply-to: Robot101 <phys101@tower.edu>
256 To: Bilbo Baggins <bb@shire.org>
257 Subject: Received Assignment 1 submission
259 --===============...==
260 Content-Type: text/plain; charset="us-ascii"
262 Content-Disposition: inline
263 Content-Transfer-Encoding: 7bit
267 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
272 --===============...==
274 Content-Transfer-Encoding: 7bit
275 Content-Description: OpenPGP digital signature
276 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
278 -----BEGIN PGP SIGNATURE-----
279 Version: GnuPG v2.0.19 (GNU/Linux)
282 -----END PGP SIGNATURE-----
284 --===============...==--
286 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
288 Bilbo_Baggins/Assignment_1
289 Bilbo_Baggins/Assignment_1/mail
290 Bilbo_Baggins/Assignment_1/mail/cur
291 Bilbo_Baggins/Assignment_1/mail/new
292 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
293 Bilbo_Baggins/Assignment_1/mail/tmp
301 The last ``Received`` is used to timestamp the message:
303 >>> del message['Message-ID']
304 >>> message['Message-ID'] = '<abc.def@home.net>'
305 >>> del message['Received']
306 >>> message['Received'] = (
307 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
308 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
309 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
310 >>> message['Received'] = (
311 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
312 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
313 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
314 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
316 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
318 Content-Disposition: inline
320 From: Robot101 <phys101@tower.edu>
321 Reply-to: Robot101 <phys101@tower.edu>
322 To: Bilbo Baggins <bb@shire.org>
323 Subject: Received Assignment 1 submission
325 --===============...==
326 Content-Type: text/plain; charset="us-ascii"
328 Content-Disposition: inline
329 Content-Transfer-Encoding: 7bit
333 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
338 --===============...==
340 Content-Transfer-Encoding: 7bit
341 Content-Description: OpenPGP digital signature
342 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
344 -----BEGIN PGP SIGNATURE-----
345 Version: GnuPG v2.0.19 (GNU/Linux)
348 -----END PGP SIGNATURE-----
350 --===============...==--
352 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
354 Bilbo_Baggins/Assignment_1
355 Bilbo_Baggins/Assignment_1/late
356 Bilbo_Baggins/Assignment_1/mail
357 Bilbo_Baggins/Assignment_1/mail/cur
358 Bilbo_Baggins/Assignment_1/mail/new
359 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
360 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
361 Bilbo_Baggins/Assignment_1/mail/tmp
370 You can send receipts to the acknowledge incoming messages, which
371 includes warnings about dropped messages (except for messages
372 without ``Return-Path`` and messages where the ``Return-Path``
373 email belongs to multiple ``People``. The former should only
374 occur with malicious emails, and the latter with improper pygrader
377 Response to a successful submission:
379 >>> del message['Message-ID']
380 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
381 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
383 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
385 Content-Disposition: inline
387 From: Robot101 <phys101@tower.edu>
388 Reply-to: Robot101 <phys101@tower.edu>
389 To: Bilbo Baggins <bb@shire.org>
390 Subject: Received Assignment 1 submission
392 --===============...==
393 Content-Type: text/plain; charset="us-ascii"
395 Content-Disposition: inline
396 Content-Transfer-Encoding: 7bit
400 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
405 --===============...==
407 Content-Transfer-Encoding: 7bit
408 Content-Description: OpenPGP digital signature
409 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
411 -----BEGIN PGP SIGNATURE-----
412 Version: GnuPG v2.0.19 (GNU/Linux)
415 -----END PGP SIGNATURE-----
417 --===============...==--
419 Response to a submission on an unsubmittable assignment:
421 >>> del message['Subject']
422 >>> message['Subject'] = '[submit] attendance 1'
423 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
425 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
427 Content-Disposition: inline
429 From: Robot101 <phys101@tower.edu>
430 Reply-to: Robot101 <phys101@tower.edu>
431 To: Bilbo Baggins <bb@shire.org>
432 Subject: Received invalid Attendance 1 submission
434 --===============...==
435 Content-Type: multipart/mixed; boundary="===============...=="
438 --===============...==
439 Content-Type: text/plain; charset="us-ascii"
441 Content-Transfer-Encoding: 7bit
442 Content-Disposition: inline
446 We received your submission for Attendance 1, but you are not
447 allowed to submit that assignment via email.
452 --===============...==
453 Content-Type: message/rfc822
456 Content-Type: text/plain; charset="us-ascii"
458 Content-Transfer-Encoding: 7bit
459 Content-Disposition: inline
460 From: Billy B <bb@greyhavens.net>
461 To: phys101 <phys101@tower.edu>
462 Return-Path: <bb@greyhavens.net>
463 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)
464 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)
465 Message-ID: <hgi.jlk@home.net>
466 Subject: [submit] attendance 1
469 --===============...==--
470 --===============...==
472 Content-Transfer-Encoding: 7bit
473 Content-Description: OpenPGP digital signature
474 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
476 -----BEGIN PGP SIGNATURE-----
477 Version: GnuPG v2.0.19 (GNU/Linux)
480 -----END PGP SIGNATURE-----
482 --===============...==--
484 Response to a bad subject:
486 >>> del message['Subject']
487 >>> message['Subject'] = 'need help for the first homework'
488 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
490 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
492 Content-Disposition: inline
494 From: Robot101 <phys101@tower.edu>
495 Reply-to: Robot101 <phys101@tower.edu>
496 To: Bilbo Baggins <bb@shire.org>
497 Subject: no tag in 'need help for the first homework'
499 --===============...==
500 Content-Type: multipart/mixed; boundary="===============...=="
503 --===============...==
504 Content-Type: text/plain; charset="us-ascii"
506 Content-Transfer-Encoding: 7bit
507 Content-Disposition: inline
511 We received an email message from you with an invalid
517 --===============...==
518 Content-Type: message/rfc822
521 Content-Type: text/plain; charset="us-ascii"
523 Content-Transfer-Encoding: 7bit
524 Content-Disposition: inline
525 From: Billy B <bb@greyhavens.net>
526 To: phys101 <phys101@tower.edu>
527 Return-Path: <bb@greyhavens.net>
528 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)
529 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)
530 Message-ID: <hgi.jlk@home.net>
531 Subject: need help for the first homework
534 --===============...==--
535 --===============...==
537 Content-Transfer-Encoding: 7bit
538 Content-Description: OpenPGP digital signature
539 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
541 -----BEGIN PGP SIGNATURE-----
542 Version: GnuPG v2.0.19 (GNU/Linux)
545 -----END PGP SIGNATURE-----
547 --===============...==--
549 Response to a missing subject:
551 >>> del message['Subject']
552 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
554 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
556 Content-Disposition: inline
558 From: Robot101 <phys101@tower.edu>
559 Reply-to: Robot101 <phys101@tower.edu>
560 To: Bilbo Baggins <bb@shire.org>
561 Subject: no subject in <hgi.jlk@home.net>
563 --===============...==
564 Content-Type: multipart/mixed; boundary="===============...=="
567 --===============...==
568 Content-Type: text/plain; charset="us-ascii"
570 Content-Transfer-Encoding: 7bit
571 Content-Disposition: inline
575 We received an email message from you without a subject.
580 --===============...==
581 Content-Type: message/rfc822
584 Content-Type: text/plain; charset="us-ascii"
586 Content-Transfer-Encoding: 7bit
587 Content-Disposition: inline
588 From: Billy B <bb@greyhavens.net>
589 To: phys101 <phys101@tower.edu>
590 Return-Path: <bb@greyhavens.net>
591 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)
592 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)
593 Message-ID: <hgi.jlk@home.net>
596 --===============...==--
597 --===============...==
599 Content-Transfer-Encoding: 7bit
600 Content-Description: OpenPGP digital signature
601 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
603 -----BEGIN PGP SIGNATURE-----
604 Version: GnuPG v2.0.19 (GNU/Linux)
607 -----END PGP SIGNATURE-----
609 --===============...==--
611 Response to an insecure message from a person with a PGP key:
613 >>> student = course.course.person(email='bb@greyhavens.net')
614 >>> student.pgp_key = '4332B6E3'
615 >>> del message['Subject']
616 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
618 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
620 Content-Disposition: inline
622 From: Robot101 <phys101@tower.edu>
623 Reply-to: Robot101 <phys101@tower.edu>
624 To: Bilbo Baggins <bb@shire.org>
625 Subject: unsigned message <hgi.jlk@home.net>
627 --===============...==
629 Content-Transfer-Encoding: 7bit
630 Content-Type: application/pgp-encrypted; charset="us-ascii"
634 --===============...==
636 Content-Transfer-Encoding: 7bit
637 Content-Description: OpenPGP encrypted message
638 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
640 -----BEGIN PGP MESSAGE-----
641 Version: GnuPG v2.0.19 (GNU/Linux)
644 -----END PGP MESSAGE-----
646 --===============...==--
652 for original,message,person,subject,target in _load_messages(
653 course=course, stream=stream, mailbox=mailbox, input_=input_,
654 output=output, dry_run=dry_run,
655 continue_after_invalid_message=continue_after_invalid_message,
656 trust_email_infrastructure=trust_email_infrastructure,
659 handler = _get_handler(handlers=handlers, target=target)
660 _LOG.debug('handling {}'.format(target))
662 basedir=basedir, course=course, message=message,
663 person=person, subject=subject,
665 trust_email_infrastructure=trust_email_infrastructure,
667 except _InvalidMessage as error:
668 error.course = course
669 error.message = original
670 for attribute,value in [('person', person),
671 ('subject', subject),
673 if (value is not None and
674 getattr(error, attribute, None) is None):
675 setattr(error, attribute, value)
676 _LOG.warn('invalid message {}'.format(error.message_id()))
677 if not continue_after_invalid_message:
679 _LOG.warn('{}'.format(error))
681 response = _get_error_response(error)
683 except _Response as response:
685 msg = response.message
686 if not response.complete:
687 author = course.robot
689 msg = response.message
690 if isinstance(response.message, _MIMEText):
691 # Manipulate body (based on pgp_mime.append_text)
692 original_encoding = msg.get_charset().input_charset
693 original_payload = str(
694 msg.get_payload(decode=True), original_encoding)
700 target.alias(), original_payload, author.alias())
701 new_encoding = _pgp_mime.guess_encoding(new_payload)
702 if msg.get('content-transfer-encoding', None):
703 # clear CTE so set_payload will set it properly
704 del msg['content-transfer-encoding']
705 msg.set_payload(new_payload, new_encoding)
706 subject = msg['Subject']
707 assert subject is not None, msg
709 msg = _construct_email(
710 author=author, targets=[person], subject=subject,
714 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
715 continue_after_invalid_message=False,
716 trust_email_infrastructure=False, respond=None,
719 _LOG.debug('loading message from {}'.format(stream))
721 messages = [(None,_message_from_file(stream))]
722 if output is not None:
723 ombox = _mailbox.Maildir(output, factory=None, create=True)
724 elif mailbox == 'mbox':
725 mbox = _mailbox.mbox(input_, factory=None, create=False)
726 messages = mbox.items()
727 if output is not None:
728 ombox = _mailbox.mbox(output, factory=None, create=True)
729 elif mailbox == 'maildir':
730 mbox = _mailbox.Maildir(input_, factory=None, create=False)
732 for key,msg in mbox.items():
733 subpath = mbox._lookup(key)
734 if subpath.endswith('.gitignore'):
735 _LOG.debug('skipping non-message {}'.format(subpath))
737 messages.append((key, msg))
738 if output is not None:
739 ombox = _mailbox.Maildir(output, factory=None, create=True)
741 raise ValueError(mailbox)
742 messages.sort(key=_get_message_time)
743 for key,msg in messages:
745 ret = _parse_message(
746 course=course, message=msg,
747 trust_email_infrastructure=trust_email_infrastructure)
748 except _InvalidMessage as error:
750 _LOG.warn('invalid message {}'.format(error.message_id()))
751 if not continue_after_invalid_message:
753 _LOG.warn('{}'.format(error))
755 response = _get_error_response(error)
756 if response is not None:
759 if output is not None and dry_run is False:
760 # move message from input mailbox to output mailbox
766 def _parse_message(course, message, trust_email_infrastructure=False):
767 """Parse an incoming email and respond if neccessary.
769 Return ``(msg, person, assignment, time)`` on successful parsing.
770 Return ``None`` on failure.
773 person = subject = target = None
775 person = _get_message_person(course=course, message=message)
777 _LOG.debug('verify message is from {}'.format(person))
779 message = _get_verified_message(message, person.pgp_key)
780 except _UnsignedMessage as error:
781 if trust_email_infrastructure:
782 _LOG.warn('{}'.format(error))
785 subject = _get_message_subject(message=message)
786 target = _get_message_target(subject=subject)
787 except _InvalidMessage as error:
788 error.course = course
789 error.message = original
790 for attribute,value in [('person', person),
791 ('subject', subject),
793 if (value is not None and
794 getattr(error, attribute, None) is None):
795 setattr(error, attribute, value)
797 return (original, message, person, subject, target)
799 def _get_message_person(course, message):
800 sender = message['Return-Path'] # RFC 822
802 raise NoReturnPath(message)
803 sender = sender[1:-1] # strip wrapping '<' and '>'
804 people = list(course.find_people(email=sender))
806 raise UnregisteredAddress(message=message, address=sender)
808 raise AmbiguousAddress(message=message, address=sender, people=people)
811 def _get_message_subject(message):
813 >>> from email.header import Header
814 >>> from pgp_mime.email import encodedMIMEText
815 >>> message = encodedMIMEText('The answer is 42.')
816 >>> message['Message-ID'] = 'msg-id'
817 >>> _get_message_subject(message=message)
818 Traceback (most recent call last):
820 pygrader.mailpipe.SubjectlessMessage: no subject
821 >>> del message['Subject']
822 >>> subject = Header('unicode part', 'utf-8')
823 >>> subject.append('-ascii part', 'ascii')
824 >>> message['Subject'] = subject.encode()
825 >>> _get_message_subject(message=message)
826 'unicode part-ascii part'
827 >>> del message['Subject']
828 >>> message['Subject'] = 'clean subject'
829 >>> _get_message_subject(message=message)
832 if message['Subject'] is None:
833 raise SubjectlessMessage(subject=None, message=message)
835 parts = _decode_header(message['Subject'])
837 for string,encoding in parts:
840 if not isinstance(string, str):
841 string = str(string, encoding)
842 part_strings.append(string)
843 subject = ''.join(part_strings)
844 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
845 return subject.lower().replace('#', '')
847 def _get_message_target(subject):
849 >>> _get_message_target(subject='no tag')
850 Traceback (most recent call last):
852 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
853 >>> _get_message_target(subject='[] empty tag')
854 Traceback (most recent call last):
856 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
857 >>> _get_message_target(subject='[abc] empty tag')
859 >>> _get_message_target(subject='[phys160:abc] empty tag')
862 match = _TAG_REGEXP.match(subject)
864 raise _InvalidSubjectMessage(
865 subject=subject, error='no tag in {!r}'.format(subject))
868 raise _InvalidSubjectMessage(
869 subject=subject, error='empty tag in {!r}'.format(subject))
870 target = tag.rsplit(':', 1)[-1]
871 _LOG.debug('extracted target {} -> {}'.format(subject, target))
874 def _get_handler(handlers, target):
876 handler = handlers[target]
877 except KeyError as error:
878 raise InvalidHandlerMessage(
879 target=target, handlers=handlers) from error
882 def _get_verified_message(message, pgp_key):
885 >>> from pgp_mime import sign, encodedMIMEText
887 The student composes a message...
889 >>> message = encodedMIMEText('1.23 joules')
891 ... and signs it (with the pgp-mime test key).
893 >>> signed = sign(message, signers=['pgp-mime-test'])
895 As it is being delivered, the message picks up extra headers.
897 >>> signed['Message-ID'] = '<01234567@home.net>'
898 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
899 >>> signed['Received'] = 'from smtp.home.net ...'
901 We check that the message is signed, and that it is signed by the
904 >>> signed.authenticated
905 Traceback (most recent call last):
907 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
908 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
909 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
910 Content-Type: text/plain; charset="us-ascii"
912 Content-Transfer-Encoding: 7bit
913 Content-Disposition: inline
914 Message-ID: <01234567@home.net>
915 Received: from smtp.mail.uu.edu ...
916 Received: from smtp.home.net ...
919 >>> our_message.authenticated
922 If it is signed, but not by the right key, we get an error.
924 >>> print(_get_verified_message(signed, pgp_key='01234567'))
925 Traceback (most recent call last):
927 pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
929 If it is not signed at all, we get another error.
931 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
932 Traceback (most recent call last):
934 pygrader.handler.UnsignedMessage: unsigned message
936 mid = message['message-id']
938 decrypted,verified,signatures = _pgp_mime.verify(message=message)
939 except (ValueError, AssertionError) as error:
940 raise _UnsignedMessage(message=message) from error
941 for signature in signatures:
942 _LOG.debug(signature.dumps())
944 fingerprints = dict((s.fingerprint, s) for s in signatures)
946 for key in _pgp_mime_key.lookup_keys([s.fingerprint]):
947 if key.fingerprint != s.fingerprint:
948 # the signature was made with a subkey. Add the primary.
949 fingerprints[key.fingerprint] = s
950 if pgp_key.startswith('0x'):
951 key_tail = pgp_key[len('0x'):]
954 matches = [fingerprints[f] for f in fingerprints.keys()
955 if f.endswith(key_tail)]
956 if len(matches) == 0:
957 raise WrongSignatureMessage(
958 message=message, pgp_key=pgp_key, signatures=signatures,
959 fingerprints=fingerprints, decrypted=decrypted)
960 signature = matches[0]
962 problems = [k for k,v in signature.summary.items() if v]
963 for good in ['green', 'valid']:
965 problems.remove(good)
967 raise UnverifiedSignatureMessage(
968 message=message, signature=signature, decrypted=decrypted)
969 # otherwise, we may have an untrusted key. We'll count that
970 # as verified here, because the caller is explicity looking
971 # for signatures by this fingerprint.
972 for k,v in message.items(): # copy over useful headers
973 if k.lower() not in ['content-type',
975 'content-disposition',
978 decrypted.authenticated = True
981 def _get_error_response(error):
982 author = error.course.robot
983 target = getattr(error, 'person', None)
985 if isinstance(error, _InvalidSubmission):
986 subject = 'Received invalid {} submission'.format(
987 error.assignment.name)
989 'We received your submission for {}, but you are not\n'
990 'allowed to submit that assignment via email.'
991 ).format(error.assignment.name)
992 elif isinstance(error, _MissingGradeMessage):
993 subject = 'No grade in {!r}'.format(error.subject)
995 'Your grade submission did not include a text/plain\n'
996 'part containing the new grade and comment.'
998 elif isinstance(error, InvalidHandlerMessage):
999 targets = sorted(error.handlers.keys())
1002 'In fact, there are no available handlers for this\n'
1006 'Perhaps you meant to use one of the following:\n'
1007 ' {}').format('\n '.join(targets))
1009 'We received an email from you with the following subject:\n'
1011 'which does not match any submittable handler name for\n'
1013 '{}').format(error.subject, error.course.name, hint)
1014 elif isinstance(error, SubjectlessMessage):
1015 subject = 'no subject in {}'.format(error.message['Message-ID'])
1016 text = 'We received an email message from you without a subject.'
1017 elif isinstance(error, AmbiguousAddress):
1019 'Multiple people match {} ({})'.format(
1020 error.address, ', '.join(p.name for p in error.people)))
1021 elif isinstance(error, UnregisteredAddress):
1022 target = _Person(name=error.address, emails=[error.address])
1024 'Your email address is not registered with pygrader for\n'
1025 '{}. If you feel it should be, contact your professor\n'
1026 'or TA.').format(error.course.name)
1027 elif isinstance(error, NoReturnPath):
1029 elif isinstance(error, _InvalidAssignmentSubject):
1030 if error.assignments:
1032 'but it matches several assignments:\n'
1033 ' * {}').format('\n * '.join(
1034 a.name for a in error.assignments))
1036 # prefer a submittable example assignment
1038 a for a in error.course.assignments if a.submittable]
1039 assignments += course.assignments # but fall back to any one
1041 'Remember to use the full name for the assignment in the\n'
1042 'subject. For example:\n'
1043 ' {} submission').format(assignments[0].name)
1045 'We received an email from you with the following subject:\n'
1046 ' {!r}\n{}').format(error.subject, hint)
1047 elif isinstance(error, _InvalidStudentSubject):
1049 'We received an email from you with the following subject:\n'
1051 'but it matches several students:\n'
1053 error.subject, '\n * '.join(s.name for s in error.students))
1054 elif isinstance(error, _InvalidSubjectMessage):
1056 'We received an email message from you with an invalid\n'
1058 elif isinstance(error, _UnsignedMessage):
1059 subject = 'unsigned message {}'.format(error.message['Message-ID'])
1061 'We received an email message from you without a PGP\n'
1064 elif isinstance(error, WrongSignatureMessage):
1066 'We received an email message from you without a valid',
1067 'PGP signature. We were expecting a signature by',
1068 '{}, but got signatures by:'.format(error.person.pgp_key),
1070 lines.extend([' {}'.format(s.fingerprint) for s in error.signatures])
1071 text = '\n'.join(lines)
1072 elif isinstance(error, UnverifiedSignatureMessage):
1074 'We received an email message from you with an unverified\n'
1077 'If this is the key you intended to use, contact your\n'
1079 ).format(error.signature.dumps(prefix=' '))
1080 elif isinstance(error, _PermissionViolationMessage):
1082 'We received an email from you with the following subject:\n'
1084 "but you can't do that unless you belong to one of the\n"
1085 'following groups:\n'
1087 error.subject, '\n * '.join(error.allowed_groups))
1088 elif isinstance(error, _InvalidMessage):
1090 'We received an email from you with the following subject:\n'
1092 'but the message was invalid:\n'
1093 ' {}').format(error.subject, error)
1095 raise NotImplementedError((type(error), error))
1097 raise NotImplementedError((type(error), error))
1098 return _construct_response(
1106 '{}\n'.format(target.alias(), text, author.alias())),
1107 original=error.message)
1109 def _get_message_time(key_message):
1110 "Key function for sorting mailbox (key,message) tuples."
1111 key,message = key_message
1112 return _message_time(message)