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 InvalidMessage as _InvalidMessage
38 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
39 from .handler import Response as _Response
40 from .handler import UnsignedMessage as _UnsignedMessage
41 from .handler.get import InvalidStudent as _InvalidStudent
42 from .handler.get import run as _handle_get
43 from .handler.submission import InvalidAssignment as _InvalidAssignment
44 from .handler.submission import run as _handle_submission
47 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
50 class NoReturnPath (_InvalidMessage):
51 def __init__(self, address, **kwargs):
52 if 'error' not in kwargs:
53 kwargs['error'] = 'no Return-Path'
54 super(NoReturnPath, self).__init__(**kwargs)
57 class UnregisteredAddress (_InvalidMessage):
58 def __init__(self, address, **kwargs):
59 if 'error' not in kwargs:
60 kwargs['error'] = 'unregistered address {}'.format(address)
61 super(UnregisteredAddress, self).__init__(**kwargs)
62 self.address = address
65 class AmbiguousAddress (_InvalidMessage):
66 def __init__(self, address, people, **kwargs):
67 if 'error' not in kwargs:
68 kwargs['error'] = 'ambiguous address {}'.format(address)
69 super(AmbiguousAddress, self).__init__(**kwargs)
70 self.address = address
74 class SubjectlessMessage (_InvalidSubjectMessage):
75 def __init__(self, **kwargs):
76 if 'error' not in kwargs:
77 kwargs['error'] = 'no subject'
78 super(SubjectlessMessage, self).__init__(**kwargs)
81 class InvalidHandlerMessage (_InvalidSubjectMessage):
82 def __init__(self, target=None, handlers=None, **kwargs):
83 if 'error' not in kwargs:
84 kwargs['error'] = 'no handler for {!r}'.format(target)
85 super(InvalidHandlerMessage, self).__init__(**kwargs)
87 self.handlers = handlers
90 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
91 output=None, continue_after_invalid_message=False, max_late=0,
92 trust_email_infrastructure=False,
95 'submit': _handle_submission,
96 }, respond=None, dry_run=False, **kwargs):
97 """Run from procmail to sort incomming submissions
99 For example, you can setup your ``.procmailrc`` like this::
104 DEFAULT=$MAILDIR/mbox
105 LOGFILE=$MAILDIR/procmail.log
107 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
109 # Grab all incoming homeworks emails. This rule eats matching emails
110 # (i.e. no further procmail processing).
112 * ^Subject:.*\[phys160:submit]
113 | "$PYGRADE_MAILPIPE" mailpipe
115 If you don't want procmail to eat the message, you can use the
116 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
118 >>> from io import StringIO
119 >>> from pgp_mime.email import encodedMIMEText
120 >>> from .handler import InvalidMessage, Response
121 >>> from .test.course import StubCourse
123 >>> course = StubCourse()
124 >>> def respond(message):
125 ... print('respond with:\\n{}'.format(message.as_string()))
126 >>> def process(message):
128 ... basedir=course.basedir, course=course.course,
129 ... stream=StringIO(message.as_string()),
130 ... output=course.mailbox,
131 ... continue_after_invalid_message=True,
133 >>> message = encodedMIMEText('The answer is 42.')
134 >>> message['Message-ID'] = '<123.456@home.net>'
135 >>> message['Received'] = (
136 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
137 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
138 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
139 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
140 >>> message['To'] = 'phys101 <phys101@tower.edu>'
141 >>> message['Subject'] = '[submit] assignment 1'
143 Messages with unrecognized ``Return-Path``\s are silently dropped:
145 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
146 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
153 Response to a message from an unregistered person:
155 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
156 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
158 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
160 Content-Disposition: inline
162 From: Robot101 <phys101@tower.edu>
163 Reply-to: Robot101 <phys101@tower.edu>
164 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
165 Subject: unregistered address invalid.return.path@home.net
167 --===============...==
168 Content-Type: multipart/mixed; boundary="===============...=="
171 --===============...==
172 Content-Type: text/plain; charset="us-ascii"
174 Content-Transfer-Encoding: 7bit
175 Content-Disposition: inline
177 invalid.return.path@home.net,
179 Your email address is not registered with pygrader for
180 Physics 101. If you feel it should be, contact your professor
186 --===============...==
187 Content-Type: message/rfc822
190 Content-Type: text/plain; charset="us-ascii"
192 Content-Transfer-Encoding: 7bit
193 Content-Disposition: inline
194 Message-ID: <123.456@home.net>
195 Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)
196 From: Billy B <bb@greyhavens.net>
197 To: phys101 <phys101@tower.edu>
198 Subject: [submit] assignment 1
199 Return-Path: <invalid.return.path@home.net>
202 --===============...==--
203 --===============...==
205 Content-Transfer-Encoding: 7bit
206 Content-Description: OpenPGP digital signature
207 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
209 -----BEGIN PGP SIGNATURE-----
210 Version: GnuPG v2.0.19 (GNU/Linux)
213 -----END PGP SIGNATURE-----
215 --===============...==--
217 If we add a valid ``Return-Path``, we get the expected delivery:
219 >>> del message['Return-Path']
220 >>> message['Return-Path'] = '<bb@greyhavens.net>'
221 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
223 Content-Type: text/plain; charset="us-ascii"
225 Content-Disposition: inline
226 Content-Transfer-Encoding: 7bit
230 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
236 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
238 Bilbo_Baggins/Assignment_1
239 Bilbo_Baggins/Assignment_1/mail
240 Bilbo_Baggins/Assignment_1/mail/cur
241 Bilbo_Baggins/Assignment_1/mail/new
242 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
243 Bilbo_Baggins/Assignment_1/mail/tmp
251 The last ``Received`` is used to timestamp the message:
253 >>> del message['Message-ID']
254 >>> message['Message-ID'] = '<abc.def@home.net>'
255 >>> del message['Received']
256 >>> message['Received'] = (
257 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
258 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
259 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
260 >>> message['Received'] = (
261 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
262 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
263 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
264 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
266 Content-Type: text/plain; charset="us-ascii"
268 Content-Disposition: inline
269 Content-Transfer-Encoding: 7bit
273 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
278 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
280 Bilbo_Baggins/Assignment_1
281 Bilbo_Baggins/Assignment_1/late
282 Bilbo_Baggins/Assignment_1/mail
283 Bilbo_Baggins/Assignment_1/mail/cur
284 Bilbo_Baggins/Assignment_1/mail/new
285 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
286 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
287 Bilbo_Baggins/Assignment_1/mail/tmp
296 You can send receipts to the acknowledge incoming messages, which
297 includes warnings about dropped messages (except for messages
298 without ``Return-Path`` and messages where the ``Return-Path``
299 email belongs to multiple ``People``. The former should only
300 occur with malicious emails, and the latter with improper pygrader
303 Response to a successful submission:
305 >>> del message['Message-ID']
306 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
307 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
309 Content-Type: text/plain; charset="us-ascii"
311 Content-Disposition: inline
312 Content-Transfer-Encoding: 7bit
316 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
322 Response to a submission on an unsubmittable assignment:
324 >>> del message['Subject']
325 >>> message['Subject'] = '[submit] attendance 1'
326 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
328 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
330 Content-Disposition: inline
332 From: Robot101 <phys101@tower.edu>
333 Reply-to: Robot101 <phys101@tower.edu>
334 To: Bilbo Baggins <bb@shire.org>
335 Subject: Received invalid Attendance 1 submission
337 --===============...==
338 Content-Type: multipart/mixed; boundary="===============...=="
341 --===============...==
342 Content-Type: text/plain; charset="us-ascii"
344 Content-Transfer-Encoding: 7bit
345 Content-Disposition: inline
349 We received your submission for Attendance 1, but you are not
350 allowed to submit that assignment via email.
355 --===============...==
356 Content-Type: message/rfc822
359 Content-Type: text/plain; charset="us-ascii"
361 Content-Transfer-Encoding: 7bit
362 Content-Disposition: inline
363 From: Billy B <bb@greyhavens.net>
364 To: phys101 <phys101@tower.edu>
365 Return-Path: <bb@greyhavens.net>
366 Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
367 Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
368 Message-ID: <hgi.jlk@home.net>
369 Subject: [submit] attendance 1
372 --===============...==--
373 --===============...==
375 Content-Transfer-Encoding: 7bit
376 Content-Description: OpenPGP digital signature
377 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
379 -----BEGIN PGP SIGNATURE-----
380 Version: GnuPG v2.0.19 (GNU/Linux)
383 -----END PGP SIGNATURE-----
385 --===============...==--
387 Response to a bad subject:
389 >>> del message['Subject']
390 >>> message['Subject'] = 'need help for the first homework'
391 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
393 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
395 Content-Disposition: inline
397 From: Robot101 <phys101@tower.edu>
398 Reply-to: Robot101 <phys101@tower.edu>
399 To: Bilbo Baggins <bb@shire.org>
400 Subject: no tag in 'need help for the first homework'
402 --===============...==
403 Content-Type: multipart/mixed; boundary="===============...=="
406 --===============...==
407 Content-Type: text/plain; charset="us-ascii"
409 Content-Transfer-Encoding: 7bit
410 Content-Disposition: inline
414 We received an email message from you with an invalid
420 --===============...==
421 Content-Type: message/rfc822
424 Content-Type: text/plain; charset="us-ascii"
426 Content-Transfer-Encoding: 7bit
427 Content-Disposition: inline
428 From: Billy B <bb@greyhavens.net>
429 To: phys101 <phys101@tower.edu>
430 Return-Path: <bb@greyhavens.net>
431 Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
432 Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
433 Message-ID: <hgi.jlk@home.net>
434 Subject: need help for the first homework
437 --===============...==--
438 --===============...==
440 Content-Transfer-Encoding: 7bit
441 Content-Description: OpenPGP digital signature
442 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
444 -----BEGIN PGP SIGNATURE-----
445 Version: GnuPG v2.0.19 (GNU/Linux)
448 -----END PGP SIGNATURE-----
450 --===============...==--
452 Response to a missing subject:
454 >>> del message['Subject']
455 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
457 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
459 Content-Disposition: inline
461 From: Robot101 <phys101@tower.edu>
462 Reply-to: Robot101 <phys101@tower.edu>
463 To: Bilbo Baggins <bb@shire.org>
464 Subject: no subject in <hgi.jlk@home.net>
466 --===============...==
467 Content-Type: multipart/mixed; boundary="===============...=="
470 --===============...==
471 Content-Type: text/plain; charset="us-ascii"
473 Content-Transfer-Encoding: 7bit
474 Content-Disposition: inline
478 We received an email message from you without a subject.
483 --===============...==
484 Content-Type: message/rfc822
487 Content-Type: text/plain; charset="us-ascii"
489 Content-Transfer-Encoding: 7bit
490 Content-Disposition: inline
491 From: Billy B <bb@greyhavens.net>
492 To: phys101 <phys101@tower.edu>
493 Return-Path: <bb@greyhavens.net>
494 Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
495 Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
496 Message-ID: <hgi.jlk@home.net>
499 --===============...==--
500 --===============...==
502 Content-Transfer-Encoding: 7bit
503 Content-Description: OpenPGP digital signature
504 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
506 -----BEGIN PGP SIGNATURE-----
507 Version: GnuPG v2.0.19 (GNU/Linux)
510 -----END PGP SIGNATURE-----
512 --===============...==--
514 Response to an insecure message from a person with a PGP key:
516 >>> student = course.course.person(email='bb@greyhavens.net')
517 >>> student.pgp_key = '4332B6E3'
518 >>> del message['Subject']
519 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
521 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
523 Content-Disposition: inline
525 From: Robot101 <phys101@tower.edu>
526 Reply-to: Robot101 <phys101@tower.edu>
527 To: Bilbo Baggins <bb@shire.org>
528 Subject: unsigned message <hgi.jlk@home.net>
530 --===============...==
532 Content-Transfer-Encoding: 7bit
533 Content-Type: application/pgp-encrypted; charset="us-ascii"
537 --===============...==
539 Content-Transfer-Encoding: 7bit
540 Content-Description: OpenPGP encrypted message
541 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
543 -----BEGIN PGP MESSAGE-----
544 Version: GnuPG v2.0.19 (GNU/Linux)
547 -----END PGP MESSAGE-----
549 --===============...==--
555 for original,message,person,subject,target in _load_messages(
556 course=course, stream=stream, mailbox=mailbox, input_=input_,
557 output=output, dry_run=dry_run,
558 continue_after_invalid_message=continue_after_invalid_message,
561 handler = _get_handler(handlers=handlers, target=target)
563 basedir=basedir, course=course, message=message,
564 person=person, subject=subject,
566 trust_email_infrastructure=trust_email_infrastructure,
568 except _InvalidMessage as error:
569 error.course = course
570 error.message = original
571 if person is not None and not hasattr(error, 'person'):
572 error.person = person
573 if subject is not None and not hasattr(error, 'subject'):
574 error.subject = subject
575 if target is not None and not hasattr(error, 'target'):
576 error.target = target
577 _LOG.warn('invalid message {}'.format(error.message_id()))
578 if not continue_after_invalid_message:
580 _LOG.warn('{}'.format(error))
582 response = _get_error_response(error)
584 except _Response as response:
586 author = course.robot
588 msg = response.message
589 if isinstance(response.message, _MIMEText):
590 # Manipulate body (based on pgp_mime.append_text)
591 original_encoding = msg.get_charset().input_charset
592 original_payload = str(
593 msg.get_payload(decode=True), original_encoding)
599 target.alias(), original_payload, author.alias())
600 new_encoding = _pgp_mime.guess_encoding(new_payload)
601 if msg.get('content-transfer-encoding', None):
602 # clear CTE so set_payload will set it properly
603 del msg['content-transfer-encoding']
604 msg.set_payload(new_payload, new_encoding)
605 subject = msg['Subject']
607 assert subject is not None, msg
608 msg = _construct_email(
609 author=author, targets=[person], subject=subject,
611 respond(response.message)
614 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
615 continue_after_invalid_message=False, respond=None,
618 _LOG.debug('loading message from {}'.format(stream))
620 messages = [(None,_message_from_file(stream))]
621 if output is not None:
622 ombox = _mailbox.Maildir(output, factory=None, create=True)
623 elif mailbox == 'mbox':
624 mbox = _mailbox.mbox(input_, factory=None, create=False)
625 messages = mbox.items()
626 if output is not None:
627 ombox = _mailbox.mbox(output, factory=None, create=True)
628 elif mailbox == 'maildir':
629 mbox = _mailbox.Maildir(input_, factory=None, create=False)
631 for key,msg in mbox.items():
632 subpath = mbox._lookup(key)
633 if subpath.endswith('.gitignore'):
634 _LOG.debug('skipping non-message {}'.format(subpath))
636 messages.append((key, msg))
637 if output is not None:
638 ombox = _mailbox.Maildir(output, factory=None, create=True)
640 raise ValueError(mailbox)
641 messages = sorted(messages, key=_get_message_time)
642 for key,msg in messages:
644 ret = _parse_message(course=course, message=msg)
645 except _InvalidMessage as error:
647 _LOG.warn('invalid message {}'.format(error.message_id()))
648 if not continue_after_invalid_message:
650 _LOG.warn('{}'.format(error))
652 response = _get_error_response(error)
653 if response is not None:
656 if output is not None and dry_run is False:
657 # move message from input mailbox to output mailbox
663 def _parse_message(course, message):
664 """Parse an incoming email and respond if neccessary.
666 Return ``(msg, person, assignment, time)`` on successful parsing.
667 Return ``None`` on failure.
670 person = subject = target = None
672 person = _get_message_person(course=course, message=message)
674 message = _get_decoded_message(
675 course=course, message=message, person=person)
676 subject = _get_message_subject(message=message)
677 target = _get_message_target(subject=subject)
678 except _InvalidMessage as error:
679 error.course = course
680 error.message = original
681 if person is not None and not hasattr(error, 'person'):
682 error.person = person
683 if subject is not None and not hasattr(error, 'subject'):
684 error.subject = subject
685 if target is not None and not hasattr(error, 'target'):
686 error.target = target
688 return (original, message, person, subject, target)
690 def _get_message_person(course, message):
691 sender = message['Return-Path'] # RFC 822
693 raise NoReturnPath(message)
694 sender = sender[1:-1] # strip wrapping '<' and '>'
695 people = list(course.find_people(email=sender))
697 raise UnregisteredAddress(message=message, address=sender)
699 raise AmbiguousAddress(message=message, address=sender, people=people)
702 def _get_decoded_message(course, message, person):
703 msg = _get_verified_message(message, person.pgp_key)
705 raise _UnsignedMessage(message=message)
708 def _get_message_subject(message):
710 >>> from email.header import Header
711 >>> from pgp_mime.email import encodedMIMEText
712 >>> message = encodedMIMEText('The answer is 42.')
713 >>> message['Message-ID'] = 'msg-id'
714 >>> _get_message_subject(message=message)
715 Traceback (most recent call last):
717 pygrader.mailpipe.SubjectlessMessage: no subject
718 >>> del message['Subject']
719 >>> subject = Header('unicode part', 'utf-8')
720 >>> subject.append('-ascii part', 'ascii')
721 >>> message['Subject'] = subject.encode()
722 >>> _get_message_subject(message=message)
723 'unicode part-ascii part'
724 >>> del message['Subject']
725 >>> message['Subject'] = 'clean subject'
726 >>> _get_message_subject(message=message)
729 if message['Subject'] is None:
730 raise SubjectlessMessage(subject=None, message=message)
732 parts = _decode_header(message['Subject'])
734 for string,encoding in parts:
737 if not isinstance(string, str):
738 string = str(string, encoding)
739 part_strings.append(string)
740 subject = ''.join(part_strings)
741 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
742 return subject.lower().replace('#', '')
744 def _get_message_target(subject):
746 >>> _get_message_target(subject='no tag')
747 Traceback (most recent call last):
749 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
750 >>> _get_message_target(subject='[] empty tag')
751 Traceback (most recent call last):
753 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
754 >>> _get_message_target(subject='[abc] empty tag')
756 >>> _get_message_target(subject='[phys160:abc] empty tag')
759 match = _TAG_REGEXP.match(subject)
761 raise _InvalidSubjectMessage(
762 subject=subject, error='no tag in {!r}'.format(subject))
765 raise _InvalidSubjectMessage(
766 subject=subject, error='empty tag in {!r}'.format(subject))
767 target = tag.rsplit(':', 1)[-1]
768 _LOG.debug('extracted target {} -> {}'.format(subject, target))
771 def _get_handler(handlers, target):
773 handler = handlers[target]
774 except KeyError as error:
775 raise InvalidHandlerMessage(
776 target=target, handlers=handlers) from error
779 def _get_verified_message(message, pgp_key):
782 >>> from pgp_mime import sign, encodedMIMEText
784 The student composes a message...
786 >>> message = encodedMIMEText('1.23 joules')
788 ... and signs it (with the pgp-mime test key).
790 >>> signed = sign(message, signers=['pgp-mime-test'])
792 As it is being delivered, the message picks up extra headers.
794 >>> signed['Message-ID'] = '<01234567@home.net>'
795 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
796 >>> signed['Received'] = 'from smtp.home.net ...'
798 We check that the message is signed, and that it is signed by the
801 >>> signed.authenticated
802 Traceback (most recent call last):
804 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
805 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
806 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
807 Content-Type: text/plain; charset="us-ascii"
809 Content-Transfer-Encoding: 7bit
810 Content-Disposition: inline
811 Message-ID: <01234567@home.net>
812 Received: from smtp.mail.uu.edu ...
813 Received: from smtp.home.net ...
816 >>> our_message.authenticated
819 If it is signed, but not by the right key, we get ``None``.
821 >>> print(_get_verified_message(signed, pgp_key='01234567'))
824 If it is not signed at all, we get ``None``.
826 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
829 mid = message['message-id']
831 decrypted,verified,result = _pgp_mime.verify(message=message)
832 except (ValueError, AssertionError):
833 _LOG.warning('could not verify {} (not signed?)'.format(mid))
835 _LOG.debug(str(result, 'utf-8'))
836 tree = _etree.fromstring(result.replace(b'\x00', b''))
838 for signature in tree.findall('.//signature'):
839 for fingerprint in signature.iterchildren('fpr'):
840 if fingerprint.text.endswith(pgp_key):
844 _LOG.warning('{} is not signed by the expected key'.format(mid))
847 sumhex = list(signature.iterchildren('summary'))[0].get('value')
848 summary = int(sumhex, 16)
850 _LOG.warning('{} has an unverified signature'.format(mid))
852 # otherwise, we may have an untrusted key. We'll count that
853 # as verified here, because the caller is explicity looking
854 # for signatures by this fingerprint.
855 for k,v in message.items(): # copy over useful headers
856 if k.lower() not in ['content-type',
858 'content-disposition',
861 decrypted.authenticated = True
864 def _get_error_response(error):
865 author = error.course.robot
866 target = getattr(error, 'person', None)
868 if isinstance(error, InvalidHandlerMessage):
869 targets = sorted(error.handlers.keys())
872 'In fact, there are no available handlers for this\n'
876 'Perhaps you meant to use one of the following:\n'
877 ' {}').format('\n '.join(targets))
879 'We got an email from you with the following subject:\n'
881 'which does not match any submittable handler name for\n'
883 '{}').format(repr(error.subject), error.course.name, hint)
884 elif isinstance(error, SubjectlessMessage):
885 subject = 'no subject in {}'.format(error.message['Message-ID'])
886 text = 'We received an email message from you without a subject.'
887 elif isinstance(error, AmbiguousAddress):
889 'Multiple people match {} ({})'.format(
890 error.address, ', '.join(p.name for p in error.people)))
891 elif isinstance(error, UnregisteredAddress):
892 target = _Person(name=error.address, emails=[error.address])
894 'Your email address is not registered with pygrader for\n'
895 '{}. If you feel it should be, contact your professor\n'
896 'or TA.').format(error.course.name)
897 elif isinstance(error, NoReturnPath):
899 elif isinstance(error, _InvalidSubjectMessage):
901 'We received an email message from you with an invalid\n'
903 elif isinstance(error, _UnsignedMessage):
904 subject = 'unsigned message {}'.format(error.message['Message-ID'])
906 'We received an email message from you without a valid\n'
908 elif isinstance(error, _InvalidAssignment):
910 'We received your submission for {}, but you are not\n'
911 'allowed to submit that assignment via email.'
912 ).format(error.assignment.name)
913 elif isinstance(error, _InvalidStudent):
915 'We got an email from you with the following subject:\n'
917 'but it matches several students:\n'
919 error.subject, '\n * '.join(s.name for s in error.students))
920 elif isinstance(error, _InvalidMessage):
923 raise NotImplementedError((type(error), error))
925 raise NotImplementedError((type(error), error))
926 return _construct_response(
934 '{}\n'.format(target.alias(), text, author.alias())),
935 original=error.message)
937 def _get_message_time(key_message):
938 "Key function for sorting mailbox (key,message) tuples."
939 key,message = key_message
940 return _message_time(message)