1 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
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 Response as _Response
43 from .handler import UnsignedMessage as _UnsignedMessage
44 from .handler.get import run as _handle_get
45 from .handler.submission import run as _handle_submission
46 from .handler.submission import InvalidSubmission as _InvalidSubmission
49 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
52 class NoReturnPath (_InvalidMessage):
53 def __init__(self, address, **kwargs):
54 if 'error' not in kwargs:
55 kwargs['error'] = 'no Return-Path'
56 super(NoReturnPath, self).__init__(**kwargs)
59 class UnregisteredAddress (_InvalidMessage):
60 def __init__(self, address, **kwargs):
61 if 'error' not in kwargs:
62 kwargs['error'] = 'unregistered address {}'.format(address)
63 super(UnregisteredAddress, self).__init__(**kwargs)
64 self.address = address
67 class AmbiguousAddress (_InvalidMessage):
68 def __init__(self, address, people, **kwargs):
69 if 'error' not in kwargs:
70 kwargs['error'] = 'ambiguous address {}'.format(address)
71 super(AmbiguousAddress, self).__init__(**kwargs)
72 self.address = address
76 class WrongSignatureMessage (_InsecureMessage):
77 def __init__(self, pgp_key=None, fingerprints=None, decrypted=None,
79 if 'error' not in kwargs:
80 kwargs['error'] = 'not signed by the expected key'
81 super(WrongSignatureMessage, self).__init__(**kwargs)
82 self.pgp_key = pgp_key
83 self.fingerprints = fingerprints
84 self.decrypted = decrypted
86 class UnverifiedSignatureMessage (_InsecureMessage):
87 def __init__(self, signature=None, decrypted=None, **kwargs):
88 if 'error' not in kwargs:
89 kwargs['error'] = 'unverified signature'
90 super(UnverifiedSignatureMessage, self).__init__(**kwargs)
91 self.signature = signature
92 self.decrypted = decrypted
95 class SubjectlessMessage (_InvalidSubjectMessage):
96 def __init__(self, **kwargs):
97 if 'error' not in kwargs:
98 kwargs['error'] = 'no subject'
99 super(SubjectlessMessage, self).__init__(**kwargs)
102 class InvalidHandlerMessage (_InvalidSubjectMessage):
103 def __init__(self, target=None, handlers=None, **kwargs):
104 if 'error' not in kwargs:
105 kwargs['error'] = 'no handler for {!r}'.format(target)
106 super(InvalidHandlerMessage, self).__init__(**kwargs)
108 self.handlers = handlers
111 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
112 output=None, continue_after_invalid_message=False, max_late=0,
113 trust_email_infrastructure=False,
116 'submit': _handle_submission,
117 }, respond=None, dry_run=False, **kwargs):
118 """Run from procmail to sort incomming submissions
120 For example, you can setup your ``.procmailrc`` like this::
125 DEFAULT=$MAILDIR/mbox
126 LOGFILE=$MAILDIR/procmail.log
128 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
130 # Grab all incoming homeworks emails. This rule eats matching emails
131 # (i.e. no further procmail processing).
133 * ^Subject:.*\[phys160:submit]
134 | "$PYGRADE_MAILPIPE" mailpipe
136 If you don't want procmail to eat the message, you can use the
137 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
139 >>> from io import StringIO
140 >>> from pgp_mime.email import encodedMIMEText
141 >>> from .handler import InvalidMessage, Response
142 >>> from .test.course import StubCourse
144 >>> course = StubCourse()
145 >>> def respond(message):
146 ... print('respond with:\\n{}'.format(message.as_string()))
147 >>> def process(message):
149 ... basedir=course.basedir, course=course.course,
150 ... stream=StringIO(message.as_string()),
151 ... output=course.mailbox,
152 ... continue_after_invalid_message=True,
154 >>> message = encodedMIMEText('The answer is 42.')
155 >>> message['Message-ID'] = '<123.456@home.net>'
156 >>> message['Received'] = (
157 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
158 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
159 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
160 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
161 >>> message['To'] = 'phys101 <phys101@tower.edu>'
162 >>> message['Subject'] = '[submit] assignment 1'
164 Messages with unrecognized ``Return-Path``\s are silently dropped:
166 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
167 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
174 Response to a message from an unregistered person:
176 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
177 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
179 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
181 Content-Disposition: inline
183 From: Robot101 <phys101@tower.edu>
184 Reply-to: Robot101 <phys101@tower.edu>
185 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
186 Subject: unregistered address invalid.return.path@home.net
188 --===============...==
189 Content-Type: multipart/mixed; boundary="===============...=="
192 --===============...==
193 Content-Type: text/plain; charset="us-ascii"
195 Content-Transfer-Encoding: 7bit
196 Content-Disposition: inline
198 invalid.return.path@home.net,
200 Your email address is not registered with pygrader for
201 Physics 101. If you feel it should be, contact your professor
207 --===============...==
208 Content-Type: message/rfc822
211 Content-Type: text/plain; charset="us-ascii"
213 Content-Transfer-Encoding: 7bit
214 Content-Disposition: inline
215 Message-ID: <123.456@home.net>
216 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)
217 From: Billy B <bb@greyhavens.net>
218 To: phys101 <phys101@tower.edu>
219 Subject: [submit] assignment 1
220 Return-Path: <invalid.return.path@home.net>
223 --===============...==--
224 --===============...==
226 Content-Transfer-Encoding: 7bit
227 Content-Description: OpenPGP digital signature
228 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
230 -----BEGIN PGP SIGNATURE-----
231 Version: GnuPG v2.0.19 (GNU/Linux)
234 -----END PGP SIGNATURE-----
236 --===============...==--
238 If we add a valid ``Return-Path``, we get the expected delivery:
240 >>> del message['Return-Path']
241 >>> message['Return-Path'] = '<bb@greyhavens.net>'
242 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
244 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
246 Content-Disposition: inline
248 From: Robot101 <phys101@tower.edu>
249 Reply-to: Robot101 <phys101@tower.edu>
250 To: Bilbo Baggins <bb@shire.org>
251 Subject: Received Assignment 1 submission
253 --===============...==
254 Content-Type: text/plain; charset="us-ascii"
256 Content-Disposition: inline
257 Content-Transfer-Encoding: 7bit
261 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
266 --===============...==
268 Content-Transfer-Encoding: 7bit
269 Content-Description: OpenPGP digital signature
270 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
272 -----BEGIN PGP SIGNATURE-----
273 Version: GnuPG v2.0.19 (GNU/Linux)
276 -----END PGP SIGNATURE-----
278 --===============...==--
280 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
282 Bilbo_Baggins/Assignment_1
283 Bilbo_Baggins/Assignment_1/mail
284 Bilbo_Baggins/Assignment_1/mail/cur
285 Bilbo_Baggins/Assignment_1/mail/new
286 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
287 Bilbo_Baggins/Assignment_1/mail/tmp
295 The last ``Received`` is used to timestamp the message:
297 >>> del message['Message-ID']
298 >>> message['Message-ID'] = '<abc.def@home.net>'
299 >>> del message['Received']
300 >>> message['Received'] = (
301 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
302 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
303 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
304 >>> message['Received'] = (
305 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
306 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
307 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
308 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
310 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
312 Content-Disposition: inline
314 From: Robot101 <phys101@tower.edu>
315 Reply-to: Robot101 <phys101@tower.edu>
316 To: Bilbo Baggins <bb@shire.org>
317 Subject: Received Assignment 1 submission
319 --===============...==
320 Content-Type: text/plain; charset="us-ascii"
322 Content-Disposition: inline
323 Content-Transfer-Encoding: 7bit
327 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
332 --===============...==
334 Content-Transfer-Encoding: 7bit
335 Content-Description: OpenPGP digital signature
336 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
338 -----BEGIN PGP SIGNATURE-----
339 Version: GnuPG v2.0.19 (GNU/Linux)
342 -----END PGP SIGNATURE-----
344 --===============...==--
346 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
348 Bilbo_Baggins/Assignment_1
349 Bilbo_Baggins/Assignment_1/late
350 Bilbo_Baggins/Assignment_1/mail
351 Bilbo_Baggins/Assignment_1/mail/cur
352 Bilbo_Baggins/Assignment_1/mail/new
353 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
354 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
355 Bilbo_Baggins/Assignment_1/mail/tmp
364 You can send receipts to the acknowledge incoming messages, which
365 includes warnings about dropped messages (except for messages
366 without ``Return-Path`` and messages where the ``Return-Path``
367 email belongs to multiple ``People``. The former should only
368 occur with malicious emails, and the latter with improper pygrader
371 Response to a successful submission:
373 >>> del message['Message-ID']
374 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
375 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
377 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
379 Content-Disposition: inline
381 From: Robot101 <phys101@tower.edu>
382 Reply-to: Robot101 <phys101@tower.edu>
383 To: Bilbo Baggins <bb@shire.org>
384 Subject: Received Assignment 1 submission
386 --===============...==
387 Content-Type: text/plain; charset="us-ascii"
389 Content-Disposition: inline
390 Content-Transfer-Encoding: 7bit
394 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
399 --===============...==
401 Content-Transfer-Encoding: 7bit
402 Content-Description: OpenPGP digital signature
403 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
405 -----BEGIN PGP SIGNATURE-----
406 Version: GnuPG v2.0.19 (GNU/Linux)
409 -----END PGP SIGNATURE-----
411 --===============...==--
413 Response to a submission on an unsubmittable assignment:
415 >>> del message['Subject']
416 >>> message['Subject'] = '[submit] attendance 1'
417 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
419 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
421 Content-Disposition: inline
423 From: Robot101 <phys101@tower.edu>
424 Reply-to: Robot101 <phys101@tower.edu>
425 To: Bilbo Baggins <bb@shire.org>
426 Subject: Received invalid Attendance 1 submission
428 --===============...==
429 Content-Type: multipart/mixed; boundary="===============...=="
432 --===============...==
433 Content-Type: text/plain; charset="us-ascii"
435 Content-Transfer-Encoding: 7bit
436 Content-Disposition: inline
440 We received your submission for Attendance 1, but you are not
441 allowed to submit that assignment via email.
446 --===============...==
447 Content-Type: message/rfc822
450 Content-Type: text/plain; charset="us-ascii"
452 Content-Transfer-Encoding: 7bit
453 Content-Disposition: inline
454 From: Billy B <bb@greyhavens.net>
455 To: phys101 <phys101@tower.edu>
456 Return-Path: <bb@greyhavens.net>
457 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)
458 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)
459 Message-ID: <hgi.jlk@home.net>
460 Subject: [submit] attendance 1
463 --===============...==--
464 --===============...==
466 Content-Transfer-Encoding: 7bit
467 Content-Description: OpenPGP digital signature
468 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
470 -----BEGIN PGP SIGNATURE-----
471 Version: GnuPG v2.0.19 (GNU/Linux)
474 -----END PGP SIGNATURE-----
476 --===============...==--
478 Response to a bad subject:
480 >>> del message['Subject']
481 >>> message['Subject'] = 'need help for the first homework'
482 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
484 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
486 Content-Disposition: inline
488 From: Robot101 <phys101@tower.edu>
489 Reply-to: Robot101 <phys101@tower.edu>
490 To: Bilbo Baggins <bb@shire.org>
491 Subject: no tag in 'need help for the first homework'
493 --===============...==
494 Content-Type: multipart/mixed; boundary="===============...=="
497 --===============...==
498 Content-Type: text/plain; charset="us-ascii"
500 Content-Transfer-Encoding: 7bit
501 Content-Disposition: inline
505 We received an email message from you with an invalid
511 --===============...==
512 Content-Type: message/rfc822
515 Content-Type: text/plain; charset="us-ascii"
517 Content-Transfer-Encoding: 7bit
518 Content-Disposition: inline
519 From: Billy B <bb@greyhavens.net>
520 To: phys101 <phys101@tower.edu>
521 Return-Path: <bb@greyhavens.net>
522 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)
523 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)
524 Message-ID: <hgi.jlk@home.net>
525 Subject: need help for the first homework
528 --===============...==--
529 --===============...==
531 Content-Transfer-Encoding: 7bit
532 Content-Description: OpenPGP digital signature
533 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
535 -----BEGIN PGP SIGNATURE-----
536 Version: GnuPG v2.0.19 (GNU/Linux)
539 -----END PGP SIGNATURE-----
541 --===============...==--
543 Response to a missing subject:
545 >>> del message['Subject']
546 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
548 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
550 Content-Disposition: inline
552 From: Robot101 <phys101@tower.edu>
553 Reply-to: Robot101 <phys101@tower.edu>
554 To: Bilbo Baggins <bb@shire.org>
555 Subject: no subject in <hgi.jlk@home.net>
557 --===============...==
558 Content-Type: multipart/mixed; boundary="===============...=="
561 --===============...==
562 Content-Type: text/plain; charset="us-ascii"
564 Content-Transfer-Encoding: 7bit
565 Content-Disposition: inline
569 We received an email message from you without a subject.
574 --===============...==
575 Content-Type: message/rfc822
578 Content-Type: text/plain; charset="us-ascii"
580 Content-Transfer-Encoding: 7bit
581 Content-Disposition: inline
582 From: Billy B <bb@greyhavens.net>
583 To: phys101 <phys101@tower.edu>
584 Return-Path: <bb@greyhavens.net>
585 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)
586 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)
587 Message-ID: <hgi.jlk@home.net>
590 --===============...==--
591 --===============...==
593 Content-Transfer-Encoding: 7bit
594 Content-Description: OpenPGP digital signature
595 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
597 -----BEGIN PGP SIGNATURE-----
598 Version: GnuPG v2.0.19 (GNU/Linux)
601 -----END PGP SIGNATURE-----
603 --===============...==--
605 Response to an insecure message from a person with a PGP key:
607 >>> student = course.course.person(email='bb@greyhavens.net')
608 >>> student.pgp_key = '4332B6E3'
609 >>> del message['Subject']
610 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
612 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
614 Content-Disposition: inline
616 From: Robot101 <phys101@tower.edu>
617 Reply-to: Robot101 <phys101@tower.edu>
618 To: Bilbo Baggins <bb@shire.org>
619 Subject: unsigned message <hgi.jlk@home.net>
621 --===============...==
623 Content-Transfer-Encoding: 7bit
624 Content-Type: application/pgp-encrypted; charset="us-ascii"
628 --===============...==
630 Content-Transfer-Encoding: 7bit
631 Content-Description: OpenPGP encrypted message
632 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
634 -----BEGIN PGP MESSAGE-----
635 Version: GnuPG v2.0.19 (GNU/Linux)
638 -----END PGP MESSAGE-----
640 --===============...==--
646 for original,message,person,subject,target in _load_messages(
647 course=course, stream=stream, mailbox=mailbox, input_=input_,
648 output=output, dry_run=dry_run,
649 continue_after_invalid_message=continue_after_invalid_message,
650 trust_email_infrastructure=trust_email_infrastructure,
653 handler = _get_handler(handlers=handlers, target=target)
654 _LOG.debug('handling {}'.format(target))
656 basedir=basedir, course=course, message=message,
657 person=person, subject=subject,
659 trust_email_infrastructure=trust_email_infrastructure,
661 except _InvalidMessage as error:
662 error.course = course
663 error.message = original
664 for attribute,value in [('person', person),
665 ('subject', subject),
667 if (value is not None and
668 getattr(error, attribute, None) is None):
669 setattr(error, attribute, value)
670 _LOG.warn('invalid message {}'.format(error.message_id()))
671 if not continue_after_invalid_message:
673 _LOG.warn('{}'.format(error))
675 response = _get_error_response(error)
677 except _Response as response:
679 msg = response.message
680 if not response.complete:
681 author = course.robot
683 msg = response.message
684 if isinstance(response.message, _MIMEText):
685 # Manipulate body (based on pgp_mime.append_text)
686 original_encoding = msg.get_charset().input_charset
687 original_payload = str(
688 msg.get_payload(decode=True), original_encoding)
694 target.alias(), original_payload, author.alias())
695 new_encoding = _pgp_mime.guess_encoding(new_payload)
696 if msg.get('content-transfer-encoding', None):
697 # clear CTE so set_payload will set it properly
698 del msg['content-transfer-encoding']
699 msg.set_payload(new_payload, new_encoding)
700 subject = msg['Subject']
701 assert subject is not None, msg
703 msg = _construct_email(
704 author=author, targets=[person], subject=subject,
708 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
709 continue_after_invalid_message=False,
710 trust_email_infrastructure=False, respond=None,
713 _LOG.debug('loading message from {}'.format(stream))
715 messages = [(None,_message_from_file(stream))]
716 if output is not None:
717 ombox = _mailbox.Maildir(output, factory=None, create=True)
718 elif mailbox == 'mbox':
719 mbox = _mailbox.mbox(input_, factory=None, create=False)
720 messages = mbox.items()
721 if output is not None:
722 ombox = _mailbox.mbox(output, factory=None, create=True)
723 elif mailbox == 'maildir':
724 mbox = _mailbox.Maildir(input_, factory=None, create=False)
726 for key,msg in mbox.items():
727 subpath = mbox._lookup(key)
728 if subpath.endswith('.gitignore'):
729 _LOG.debug('skipping non-message {}'.format(subpath))
731 messages.append((key, msg))
732 if output is not None:
733 ombox = _mailbox.Maildir(output, factory=None, create=True)
735 raise ValueError(mailbox)
736 messages.sort(key=_get_message_time)
737 for key,msg in messages:
739 ret = _parse_message(
740 course=course, message=msg,
741 trust_email_infrastructure=trust_email_infrastructure)
742 except _InvalidMessage as error:
744 _LOG.warn('invalid message {}'.format(error.message_id()))
745 if not continue_after_invalid_message:
747 _LOG.warn('{}'.format(error))
749 response = _get_error_response(error)
750 if response is not None:
753 if output is not None and dry_run is False:
754 # move message from input mailbox to output mailbox
760 def _parse_message(course, message, trust_email_infrastructure=False):
761 """Parse an incoming email and respond if neccessary.
763 Return ``(msg, person, assignment, time)`` on successful parsing.
764 Return ``None`` on failure.
767 person = subject = target = None
769 person = _get_message_person(course=course, message=message)
771 _LOG.debug('verify message is from {}'.format(person))
773 message = _get_verified_message(message, person.pgp_key)
774 except _UnsignedMessage as error:
775 if trust_email_infrastructure:
776 _LOG.warn('{}'.format(error))
779 subject = _get_message_subject(message=message)
780 target = _get_message_target(subject=subject)
781 except _InvalidMessage as error:
782 error.course = course
783 error.message = original
784 for attribute,value in [('person', person),
785 ('subject', subject),
787 if (value is not None and
788 getattr(error, attribute, None) is None):
789 setattr(error, attribute, value)
791 return (original, message, person, subject, target)
793 def _get_message_person(course, message):
794 sender = message['Return-Path'] # RFC 822
796 raise NoReturnPath(message)
797 sender = sender[1:-1] # strip wrapping '<' and '>'
798 people = list(course.find_people(email=sender))
800 raise UnregisteredAddress(message=message, address=sender)
802 raise AmbiguousAddress(message=message, address=sender, people=people)
805 def _get_message_subject(message):
807 >>> from email.header import Header
808 >>> from pgp_mime.email import encodedMIMEText
809 >>> message = encodedMIMEText('The answer is 42.')
810 >>> message['Message-ID'] = 'msg-id'
811 >>> _get_message_subject(message=message)
812 Traceback (most recent call last):
814 pygrader.mailpipe.SubjectlessMessage: no subject
815 >>> del message['Subject']
816 >>> subject = Header('unicode part', 'utf-8')
817 >>> subject.append('-ascii part', 'ascii')
818 >>> message['Subject'] = subject.encode()
819 >>> _get_message_subject(message=message)
820 'unicode part-ascii part'
821 >>> del message['Subject']
822 >>> message['Subject'] = 'clean subject'
823 >>> _get_message_subject(message=message)
826 if message['Subject'] is None:
827 raise SubjectlessMessage(subject=None, message=message)
829 parts = _decode_header(message['Subject'])
831 for string,encoding in parts:
834 if not isinstance(string, str):
835 string = str(string, encoding)
836 part_strings.append(string)
837 subject = ''.join(part_strings)
838 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
839 return subject.lower().replace('#', '')
841 def _get_message_target(subject):
843 >>> _get_message_target(subject='no tag')
844 Traceback (most recent call last):
846 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
847 >>> _get_message_target(subject='[] empty tag')
848 Traceback (most recent call last):
850 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
851 >>> _get_message_target(subject='[abc] empty tag')
853 >>> _get_message_target(subject='[phys160:abc] empty tag')
856 match = _TAG_REGEXP.match(subject)
858 raise _InvalidSubjectMessage(
859 subject=subject, error='no tag in {!r}'.format(subject))
862 raise _InvalidSubjectMessage(
863 subject=subject, error='empty tag in {!r}'.format(subject))
864 target = tag.rsplit(':', 1)[-1]
865 _LOG.debug('extracted target {} -> {}'.format(subject, target))
868 def _get_handler(handlers, target):
870 handler = handlers[target]
871 except KeyError as error:
872 raise InvalidHandlerMessage(
873 target=target, handlers=handlers) from error
876 def _get_verified_message(message, pgp_key):
879 >>> from pgp_mime import sign, encodedMIMEText
881 The student composes a message...
883 >>> message = encodedMIMEText('1.23 joules')
885 ... and signs it (with the pgp-mime test key).
887 >>> signed = sign(message, signers=['pgp-mime-test'])
889 As it is being delivered, the message picks up extra headers.
891 >>> signed['Message-ID'] = '<01234567@home.net>'
892 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
893 >>> signed['Received'] = 'from smtp.home.net ...'
895 We check that the message is signed, and that it is signed by the
898 >>> signed.authenticated
899 Traceback (most recent call last):
901 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
902 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
903 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
904 Content-Type: text/plain; charset="us-ascii"
906 Content-Transfer-Encoding: 7bit
907 Content-Disposition: inline
908 Message-ID: <01234567@home.net>
909 Received: from smtp.mail.uu.edu ...
910 Received: from smtp.home.net ...
913 >>> our_message.authenticated
916 If it is signed, but not by the right key, we get an error.
918 >>> print(_get_verified_message(signed, pgp_key='01234567'))
919 Traceback (most recent call last):
921 pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
923 If it is not signed at all, we get another error.
925 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
926 Traceback (most recent call last):
928 pygrader.handler.UnsignedMessage: unsigned message
930 mid = message['message-id']
932 decrypted,verified,result = _pgp_mime.verify(message=message)
933 except (ValueError, AssertionError) as error:
934 raise _UnsignedMessage(message=message) from error
935 _LOG.debug(str(result, 'utf-8'))
936 tree = _etree.fromstring(result.replace(b'\x00', b''))
939 for signature in tree.findall('.//signature'):
940 for fingerprint in signature.iterchildren('fpr'):
941 fingerprints.append(fingerprint)
942 matches = [f for f in fingerprints if f.text.endswith(pgp_key)]
943 if len(matches) == 0:
944 raise WrongSignatureMessage(
945 message=message, pgp_key=pgp_key, fingerprints=fingerprints,
949 sumhex = list(signature.iterchildren('summary'))[0].get('value')
950 summary = int(sumhex, 16)
952 raise UnverifiedSignatureMessage(
953 message=message, signature=signature, decrypted=decrypted)
954 # otherwise, we may have an untrusted key. We'll count that
955 # as verified here, because the caller is explicity looking
956 # for signatures by this fingerprint.
957 for k,v in message.items(): # copy over useful headers
958 if k.lower() not in ['content-type',
960 'content-disposition',
963 decrypted.authenticated = True
966 def _get_error_response(error):
967 author = error.course.robot
968 target = getattr(error, 'person', None)
970 if isinstance(error, _InvalidSubmission):
971 subject = 'Received invalid {} submission'.format(
972 error.assignment.name)
974 'We received your submission for {}, but you are not\n'
975 'allowed to submit that assignment via email.'
976 ).format(error.assignment.name)
977 elif isinstance(error, InvalidHandlerMessage):
978 targets = sorted(error.handlers.keys())
981 'In fact, there are no available handlers for this\n'
985 'Perhaps you meant to use one of the following:\n'
986 ' {}').format('\n '.join(targets))
988 'We got an email from you with the following subject:\n'
990 'which does not match any submittable handler name for\n'
992 '{}').format(error.subject, error.course.name, hint)
993 elif isinstance(error, SubjectlessMessage):
994 subject = 'no subject in {}'.format(error.message['Message-ID'])
995 text = 'We received an email message from you without a subject.'
996 elif isinstance(error, AmbiguousAddress):
998 'Multiple people match {} ({})'.format(
999 error.address, ', '.join(p.name for p in error.people)))
1000 elif isinstance(error, UnregisteredAddress):
1001 target = _Person(name=error.address, emails=[error.address])
1003 'Your email address is not registered with pygrader for\n'
1004 '{}. If you feel it should be, contact your professor\n'
1005 'or TA.').format(error.course.name)
1006 elif isinstance(error, NoReturnPath):
1008 elif isinstance(error, _InvalidAssignmentSubject):
1009 if error.assignments:
1011 'but it matches several assignments:\n'
1012 ' * {}').format('\n * '.join(
1013 a.name for a in error.assignments))
1015 # prefer a submittable example assignment
1017 a for a in error.course.assignments if a.submittable]
1018 assignments += course.assignments # but fall back to any one
1020 'Remember to use the full name for the assignment in the\n'
1021 'subject. For example:\n'
1022 ' {} submission').format(assignments[0].name)
1024 'We got an email from you with the following subject:\n'
1025 ' {!r}\n{}').format(error.subject, hint)
1026 elif isinstance(error, _InvalidStudentSubject):
1028 'We got an email from you with the following subject:\n'
1030 'but it matches several students:\n'
1032 error.subject, '\n * '.join(s.name for s in error.students))
1033 elif isinstance(error, _InvalidSubjectMessage):
1035 'We received an email message from you with an invalid\n'
1037 elif isinstance(error, _UnsignedMessage):
1038 subject = 'unsigned message {}'.format(error.message['Message-ID'])
1040 'We received an email message from you without a valid\n'
1042 elif isinstance(error, _InvalidMessage):
1045 raise NotImplementedError((type(error), error))
1047 raise NotImplementedError((type(error), error))
1048 return _construct_response(
1056 '{}\n'.format(target.alias(), text, author.alias())),
1057 original=error.message)
1059 def _get_message_time(key_message):
1060 "Key function for sorting mailbox (key,message) tuples."
1061 key,message = key_message
1062 return _message_time(message)