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 .model.person import Person as _Person
36 from .handler import InvalidMessage as _InvalidMessage
37 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
38 from .handler import Response as _Response
39 from .handler import UnsignedMessage as _UnsignedMessage
40 from .handler.get import InvalidStudent as _InvalidStudent
41 from .handler.get import run as _handle_get
42 from .handler.submission import InvalidAssignment as _InvalidAssignment
43 from .handler.submission import run as _handle_submission
46 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
49 class NoReturnPath (_InvalidMessage):
50 def __init__(self, address, **kwargs):
51 if 'error' not in kwargs:
52 kwargs['error'] = 'no Return-Path'
53 super(NoReturnPath, self).__init__(**kwargs)
56 class UnregisteredAddress (_InvalidMessage):
57 def __init__(self, address, **kwargs):
58 if 'error' not in kwargs:
59 kwargs['error'] = 'unregistered address {}'.format(address)
60 super(UnregisteredAddress, self).__init__(**kwargs)
61 self.address = address
64 class AmbiguousAddress (_InvalidMessage):
65 def __init__(self, address, people, **kwargs):
66 if 'error' not in kwargs:
67 kwargs['error'] = 'ambiguous address {}'.format(address)
68 super(AmbiguousAddress, self).__init__(**kwargs)
69 self.address = address
73 class SubjectlessMessage (_InvalidSubjectMessage):
74 def __init__(self, **kwargs):
75 if 'error' not in kwargs:
76 kwargs['error'] = 'no subject'
77 super(SubjectlessMessage, self).__init__(**kwargs)
80 class InvalidHandlerMessage (_InvalidSubjectMessage):
81 def __init__(self, target=None, handlers=None, **kwargs):
82 if 'error' not in kwargs:
83 kwargs['error'] = 'no handler for {!r}'.format(target)
84 super(InvalidHandlerMessage, self).__init__(**kwargs)
86 self.handlers = handlers
89 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
90 output=None, continue_after_invalid_message=False, max_late=0,
93 'submit': _handle_submission,
94 }, respond=None, dry_run=False, **kwargs):
95 """Run from procmail to sort incomming submissions
97 For example, you can setup your ``.procmailrc`` like this::
102 DEFAULT=$MAILDIR/mbox
103 LOGFILE=$MAILDIR/procmail.log
105 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
107 # Grab all incoming homeworks emails. This rule eats matching emails
108 # (i.e. no further procmail processing).
110 * ^Subject:.*\[phys160:submit]
111 | "$PYGRADE_MAILPIPE" mailpipe
113 If you don't want procmail to eat the message, you can use the
114 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
116 >>> from io import StringIO
117 >>> from pgp_mime.email import encodedMIMEText
118 >>> from .handler import InvalidMessage, Response
119 >>> from .test.course import StubCourse
121 >>> course = StubCourse()
122 >>> def respond(message):
123 ... print('respond with:\\n{}'.format(message.as_string()))
124 >>> def process(message):
126 ... basedir=course.basedir, course=course.course,
127 ... stream=StringIO(message.as_string()),
128 ... output=course.mailbox,
129 ... continue_after_invalid_message=True,
131 >>> message = encodedMIMEText('The answer is 42.')
132 >>> message['Message-ID'] = '<123.456@home.net>'
133 >>> message['Received'] = (
134 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
135 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
136 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
137 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
138 >>> message['To'] = 'phys101 <phys101@tower.edu>'
139 >>> message['Subject'] = '[submit] assignment 1'
141 Messages with unrecognized ``Return-Path``\s are silently dropped:
143 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
144 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
151 Response to a message from an unregistered person:
153 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
154 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
156 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
158 Content-Disposition: inline
160 From: Robot101 <phys101@tower.edu>
161 Reply-to: Robot101 <phys101@tower.edu>
162 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
163 Subject: unregistered address invalid.return.path@home.net
165 --===============...==
166 Content-Type: multipart/mixed; boundary="===============...=="
169 --===============...==
170 Content-Type: text/plain; charset="us-ascii"
172 Content-Transfer-Encoding: 7bit
173 Content-Disposition: inline
175 invalid.return.path@home.net,
177 Your email address is not registered with pygrader for
178 Physics 101. If you feel it should be, contact your professor
184 --===============...==
185 Content-Type: message/rfc822
188 Content-Type: text/plain; charset="us-ascii"
190 Content-Transfer-Encoding: 7bit
191 Content-Disposition: inline
192 Message-ID: <123.456@home.net>
193 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)
194 From: Billy B <bb@greyhavens.net>
195 To: phys101 <phys101@tower.edu>
196 Subject: [submit] assignment 1
197 Return-Path: <invalid.return.path@home.net>
200 --===============...==--
201 --===============...==
203 Content-Transfer-Encoding: 7bit
204 Content-Description: OpenPGP digital signature
205 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
207 -----BEGIN PGP SIGNATURE-----
208 Version: GnuPG v2.0.19 (GNU/Linux)
211 -----END PGP SIGNATURE-----
213 --===============...==--
215 If we add a valid ``Return-Path``, we get the expected delivery:
217 >>> del message['Return-Path']
218 >>> message['Return-Path'] = '<bb@greyhavens.net>'
219 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
221 Content-Type: text/plain; charset="us-ascii"
223 Content-Disposition: inline
224 Content-Transfer-Encoding: 7bit
228 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
234 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
236 Bilbo_Baggins/Assignment_1
237 Bilbo_Baggins/Assignment_1/mail
238 Bilbo_Baggins/Assignment_1/mail/cur
239 Bilbo_Baggins/Assignment_1/mail/new
240 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
241 Bilbo_Baggins/Assignment_1/mail/tmp
249 The last ``Received`` is used to timestamp the message:
251 >>> del message['Message-ID']
252 >>> message['Message-ID'] = '<abc.def@home.net>'
253 >>> del message['Received']
254 >>> message['Received'] = (
255 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
256 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
257 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
258 >>> message['Received'] = (
259 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
260 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
261 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
262 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
264 Content-Type: text/plain; charset="us-ascii"
266 Content-Disposition: inline
267 Content-Transfer-Encoding: 7bit
271 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
276 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
278 Bilbo_Baggins/Assignment_1
279 Bilbo_Baggins/Assignment_1/late
280 Bilbo_Baggins/Assignment_1/mail
281 Bilbo_Baggins/Assignment_1/mail/cur
282 Bilbo_Baggins/Assignment_1/mail/new
283 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
284 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
285 Bilbo_Baggins/Assignment_1/mail/tmp
294 You can send receipts to the acknowledge incoming messages, which
295 includes warnings about dropped messages (except for messages
296 without ``Return-Path`` and messages where the ``Return-Path``
297 email belongs to multiple ``People``. The former should only
298 occur with malicious emails, and the latter with improper pygrader
301 Response to a successful submission:
303 >>> del message['Message-ID']
304 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
305 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
307 Content-Type: text/plain; charset="us-ascii"
309 Content-Disposition: inline
310 Content-Transfer-Encoding: 7bit
314 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
320 Response to a submission on an unsubmittable assignment:
322 >>> del message['Subject']
323 >>> message['Subject'] = '[submit] attendance 1'
324 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
326 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
328 Content-Disposition: inline
330 From: Robot101 <phys101@tower.edu>
331 Reply-to: Robot101 <phys101@tower.edu>
332 To: Bilbo Baggins <bb@shire.org>
333 Subject: Received invalid Attendance 1 submission
335 --===============...==
336 Content-Type: multipart/mixed; boundary="===============...=="
339 --===============...==
340 Content-Type: text/plain; charset="us-ascii"
342 Content-Transfer-Encoding: 7bit
343 Content-Disposition: inline
347 We received your submission for Attendance 1, but you are not
348 allowed to submit that assignment via email.
353 --===============...==
354 Content-Type: message/rfc822
357 Content-Type: text/plain; charset="us-ascii"
359 Content-Transfer-Encoding: 7bit
360 Content-Disposition: inline
361 From: Billy B <bb@greyhavens.net>
362 To: phys101 <phys101@tower.edu>
363 Return-Path: <bb@greyhavens.net>
364 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)
365 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)
366 Message-ID: <hgi.jlk@home.net>
367 Subject: [submit] attendance 1
370 --===============...==--
371 --===============...==
373 Content-Transfer-Encoding: 7bit
374 Content-Description: OpenPGP digital signature
375 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
377 -----BEGIN PGP SIGNATURE-----
378 Version: GnuPG v2.0.19 (GNU/Linux)
381 -----END PGP SIGNATURE-----
383 --===============...==--
385 Response to a bad subject:
387 >>> del message['Subject']
388 >>> message['Subject'] = 'need help for the first homework'
389 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
391 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
393 Content-Disposition: inline
395 From: Robot101 <phys101@tower.edu>
396 Reply-to: Robot101 <phys101@tower.edu>
397 To: Bilbo Baggins <bb@shire.org>
398 Subject: no tag in 'need help for the first homework'
400 --===============...==
401 Content-Type: multipart/mixed; boundary="===============...=="
404 --===============...==
405 Content-Type: text/plain; charset="us-ascii"
407 Content-Transfer-Encoding: 7bit
408 Content-Disposition: inline
412 We received an email message from you with an invalid
418 --===============...==
419 Content-Type: message/rfc822
422 Content-Type: text/plain; charset="us-ascii"
424 Content-Transfer-Encoding: 7bit
425 Content-Disposition: inline
426 From: Billy B <bb@greyhavens.net>
427 To: phys101 <phys101@tower.edu>
428 Return-Path: <bb@greyhavens.net>
429 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)
430 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)
431 Message-ID: <hgi.jlk@home.net>
432 Subject: need help for the first homework
435 --===============...==--
436 --===============...==
438 Content-Transfer-Encoding: 7bit
439 Content-Description: OpenPGP digital signature
440 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
442 -----BEGIN PGP SIGNATURE-----
443 Version: GnuPG v2.0.19 (GNU/Linux)
446 -----END PGP SIGNATURE-----
448 --===============...==--
450 Response to a missing subject:
452 >>> del message['Subject']
453 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
455 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
457 Content-Disposition: inline
459 From: Robot101 <phys101@tower.edu>
460 Reply-to: Robot101 <phys101@tower.edu>
461 To: Bilbo Baggins <bb@shire.org>
462 Subject: no subject in <hgi.jlk@home.net>
464 --===============...==
465 Content-Type: multipart/mixed; boundary="===============...=="
468 --===============...==
469 Content-Type: text/plain; charset="us-ascii"
471 Content-Transfer-Encoding: 7bit
472 Content-Disposition: inline
476 We received an email message from you without a subject.
481 --===============...==
482 Content-Type: message/rfc822
485 Content-Type: text/plain; charset="us-ascii"
487 Content-Transfer-Encoding: 7bit
488 Content-Disposition: inline
489 From: Billy B <bb@greyhavens.net>
490 To: phys101 <phys101@tower.edu>
491 Return-Path: <bb@greyhavens.net>
492 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)
493 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)
494 Message-ID: <hgi.jlk@home.net>
497 --===============...==--
498 --===============...==
500 Content-Transfer-Encoding: 7bit
501 Content-Description: OpenPGP digital signature
502 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
504 -----BEGIN PGP SIGNATURE-----
505 Version: GnuPG v2.0.19 (GNU/Linux)
508 -----END PGP SIGNATURE-----
510 --===============...==--
512 Response to an insecure message from a person with a PGP key:
514 >>> student = course.course.person(email='bb@greyhavens.net')
515 >>> student.pgp_key = '4332B6E3'
516 >>> del message['Subject']
517 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
519 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
521 Content-Disposition: inline
523 From: Robot101 <phys101@tower.edu>
524 Reply-to: Robot101 <phys101@tower.edu>
525 To: Bilbo Baggins <bb@shire.org>
526 Subject: unsigned message <hgi.jlk@home.net>
528 --===============...==
530 Content-Transfer-Encoding: 7bit
531 Content-Type: application/pgp-encrypted; charset="us-ascii"
535 --===============...==
537 Content-Transfer-Encoding: 7bit
538 Content-Description: OpenPGP encrypted message
539 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
541 -----BEGIN PGP MESSAGE-----
542 Version: GnuPG v2.0.19 (GNU/Linux)
545 -----END PGP MESSAGE-----
547 --===============...==--
553 for original,message,person,subject,target in _load_messages(
554 course=course, stream=stream, mailbox=mailbox, input_=input_,
555 output=output, dry_run=dry_run,
556 continue_after_invalid_message=continue_after_invalid_message,
559 handler = _get_handler(handlers=handlers, target=target)
561 basedir=basedir, course=course, message=message,
562 person=person, subject=subject,
563 max_late=max_late, dry_run=dry_run)
564 except _InvalidMessage as error:
565 error.course = course
566 error.message = original
567 if person is not None and not hasattr(error, 'person'):
568 error.person = person
569 if subject is not None and not hasattr(error, 'subject'):
570 error.subject = subject
571 if target is not None and not hasattr(error, 'target'):
572 error.target = target
573 _LOG.warn('invalid message {}'.format(error.message_id()))
574 if not continue_after_invalid_message:
576 _LOG.warn('{}'.format(error))
578 response = _get_error_response(error)
580 except _Response as response:
582 author = course.robot
584 msg = response.message
585 if isinstance(response.message, _MIMEText):
586 # Manipulate body (based on pgp_mime.append_text)
587 original_encoding = msg.get_charset().input_charset
588 original_payload = str(
589 msg.get_payload(decode=True), original_encoding)
595 target.alias(), original_payload, author.alias())
596 new_encoding = _pgp_mime.guess_encoding(new_payload)
597 if msg.get('content-transfer-encoding', None):
598 # clear CTE so set_payload will set it properly
599 del msg['content-transfer-encoding']
600 msg.set_payload(new_payload, new_encoding)
601 subject = msg['Subject']
603 assert subject is not None, msg
604 msg = _construct_email(
605 author=author, targets=[person], subject=subject,
607 respond(response.message)
610 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
611 continue_after_invalid_message=False, respond=None,
614 _LOG.debug('loading message from {}'.format(stream))
616 messages = [(None,_message_from_file(stream))]
617 if output is not None:
618 ombox = _mailbox.Maildir(output, factory=None, create=True)
619 elif mailbox == 'mbox':
620 mbox = _mailbox.mbox(input_, factory=None, create=False)
621 messages = mbox.items()
622 if output is not None:
623 ombox = _mailbox.mbox(output, factory=None, create=True)
624 elif mailbox == 'maildir':
625 mbox = _mailbox.Maildir(input_, factory=None, create=False)
627 for key,msg in mbox.items():
628 subpath = mbox._lookup(key)
629 if subpath.endswith('.gitignore'):
630 _LOG.debug('skipping non-message {}'.format(subpath))
632 messages.append((key, msg))
633 if output is not None:
634 ombox = _mailbox.Maildir(output, factory=None, create=True)
636 raise ValueError(mailbox)
637 for key,msg in messages:
639 ret = _parse_message(course=course, message=msg)
640 except _InvalidMessage as error:
642 _LOG.warn('invalid message {}'.format(error.message_id()))
643 if not continue_after_invalid_message:
645 _LOG.warn('{}'.format(error))
647 response = _get_error_response(error)
648 if response is not None:
651 if output is not None and dry_run is False:
652 # move message from input mailbox to output mailbox
658 def _parse_message(course, message):
659 """Parse an incoming email and respond if neccessary.
661 Return ``(msg, person, assignment, time)`` on successful parsing.
662 Return ``None`` on failure.
665 person = subject = target = None
667 person = _get_message_person(course=course, message=message)
669 message = _get_decoded_message(
670 course=course, message=message, person=person)
671 subject = _get_message_subject(message=message)
672 target = _get_message_target(subject=subject)
673 except _InvalidMessage as error:
674 error.course = course
675 error.message = original
676 if person is not None and not hasattr(error, 'person'):
677 error.person = person
678 if subject is not None and not hasattr(error, 'subject'):
679 error.subject = subject
680 if target is not None and not hasattr(error, 'target'):
681 error.target = target
683 return (original, message, person, subject, target)
685 def _get_message_person(course, message):
686 sender = message['Return-Path'] # RFC 822
688 raise NoReturnPath(message)
689 sender = sender[1:-1] # strip wrapping '<' and '>'
690 people = list(course.find_people(email=sender))
692 raise UnregisteredAddress(message=message, address=sender)
694 raise AmbiguousAddress(message=message, address=sender, people=people)
697 def _get_decoded_message(course, message, person):
698 msg = _get_verified_message(message, person.pgp_key)
700 raise _UnsignedMessage(message=message)
703 def _get_message_subject(message):
705 >>> from email.header import Header
706 >>> from pgp_mime.email import encodedMIMEText
707 >>> message = encodedMIMEText('The answer is 42.')
708 >>> message['Message-ID'] = 'msg-id'
709 >>> _get_message_subject(message=message)
710 Traceback (most recent call last):
712 pygrader.mailpipe.SubjectlessMessage: no subject
713 >>> del message['Subject']
714 >>> subject = Header('unicode part', 'utf-8')
715 >>> subject.append('-ascii part', 'ascii')
716 >>> message['Subject'] = subject.encode()
717 >>> _get_message_subject(message=message)
718 'unicode part-ascii part'
719 >>> del message['Subject']
720 >>> message['Subject'] = 'clean subject'
721 >>> _get_message_subject(message=message)
724 if message['Subject'] is None:
725 raise SubjectlessMessage(subject=None, message=message)
727 parts = _decode_header(message['Subject'])
729 for string,encoding in parts:
732 if not isinstance(string, str):
733 string = str(string, encoding)
734 part_strings.append(string)
735 subject = ''.join(part_strings)
736 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
737 return subject.lower().replace('#', '')
739 def _get_message_target(subject):
741 >>> _get_message_target(subject='no tag')
742 Traceback (most recent call last):
744 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
745 >>> _get_message_target(subject='[] empty tag')
746 Traceback (most recent call last):
748 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
749 >>> _get_message_target(subject='[abc] empty tag')
751 >>> _get_message_target(subject='[phys160:abc] empty tag')
754 match = _TAG_REGEXP.match(subject)
756 raise _InvalidSubjectMessage(
757 subject=subject, error='no tag in {!r}'.format(subject))
760 raise _InvalidSubjectMessage(
761 subject=subject, error='empty tag in {!r}'.format(subject))
762 target = tag.rsplit(':', 1)[-1]
763 _LOG.debug('extracted target {} -> {}'.format(subject, target))
766 def _get_handler(handlers, target):
768 handler = handlers[target]
769 except KeyError as error:
770 raise InvalidHandlerMessage(
771 target=target, handlers=handlers) from error
774 def _get_verified_message(message, pgp_key):
777 >>> from pgp_mime import sign, encodedMIMEText
779 The student composes a message...
781 >>> message = encodedMIMEText('1.23 joules')
783 ... and signs it (with the pgp-mime test key).
785 >>> signed = sign(message, signers=['pgp-mime-test'])
787 As it is being delivered, the message picks up extra headers.
789 >>> signed['Message-ID'] = '<01234567@home.net>'
790 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
791 >>> signed['Received'] = 'from smtp.home.net ...'
793 We check that the message is signed, and that it is signed by the
796 >>> signed.authenticated
797 Traceback (most recent call last):
799 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
800 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
801 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
802 Content-Type: text/plain; charset="us-ascii"
804 Content-Transfer-Encoding: 7bit
805 Content-Disposition: inline
806 Message-ID: <01234567@home.net>
807 Received: from smtp.mail.uu.edu ...
808 Received: from smtp.home.net ...
811 >>> our_message.authenticated
814 If it is signed, but not by the right key, we get ``None``.
816 >>> print(_get_verified_message(signed, pgp_key='01234567'))
819 If it is not signed at all, we get ``None``.
821 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
824 mid = message['message-id']
826 decrypted,verified,result = _pgp_mime.verify(message=message)
827 except (ValueError, AssertionError):
828 _LOG.warning('could not verify {} (not signed?)'.format(mid))
830 _LOG.debug(str(result, 'utf-8'))
831 tree = _etree.fromstring(result.replace(b'\x00', b''))
833 for signature in tree.findall('.//signature'):
834 for fingerprint in signature.iterchildren('fpr'):
835 if fingerprint.text.endswith(pgp_key):
839 _LOG.warning('{} is not signed by the expected key'.format(mid))
842 sumhex = list(signature.iterchildren('summary'))[0].get('value')
843 summary = int(sumhex, 16)
845 _LOG.warning('{} has an unverified signature'.format(mid))
847 # otherwise, we may have an untrusted key. We'll count that
848 # as verified here, because the caller is explicity looking
849 # for signatures by this fingerprint.
850 for k,v in message.items(): # copy over useful headers
851 if k.lower() not in ['content-type',
853 'content-disposition',
856 decrypted.authenticated = True
859 def _get_error_response(error):
860 author = error.course.robot
861 target = getattr(error, 'person', None)
863 if isinstance(error, InvalidHandlerMessage):
864 targets = sorted(error.handlers.keys())
867 'In fact, there are no available handlers for this\n'
871 'Perhaps you meant to use one of the following:\n'
872 ' {}').format('\n '.join(targets))
874 'We got an email from you with the following subject:\n'
876 'which does not match any submittable handler name for\n'
878 '{}').format(repr(error.subject), error.course.name, hint)
879 elif isinstance(error, SubjectlessMessage):
880 subject = 'no subject in {}'.format(error.message['Message-ID'])
881 text = 'We received an email message from you without a subject.'
882 elif isinstance(error, AmbiguousAddress):
884 'Multiple people match {} ({})'.format(
885 error.address, ', '.join(p.name for p in error.people)))
886 elif isinstance(error, UnregisteredAddress):
887 target = _Person(name=error.address, emails=[error.address])
889 'Your email address is not registered with pygrader for\n'
890 '{}. If you feel it should be, contact your professor\n'
891 'or TA.').format(error.course.name)
892 elif isinstance(error, NoReturnPath):
894 elif isinstance(error, _InvalidSubjectMessage):
896 'We received an email message from you with an invalid\n'
898 elif isinstance(error, _UnsignedMessage):
899 subject = 'unsigned message {}'.format(error.message['Message-ID'])
901 'We received an email message from you without a valid\n'
903 elif isinstance(error, _InvalidAssignment):
905 'We received your submission for {}, but you are not\n'
906 'allowed to submit that assignment via email.'
907 ).format(error.assignment.name)
908 elif isinstance(error, _InvalidStudent):
910 'We got an email from you with the following subject:\n'
912 'but it matches several students:\n'
914 error.subject, '\n * '.join(s.name for s in error.students))
915 elif isinstance(error, _InvalidMessage):
918 raise NotImplementedError((type(error), error))
920 raise NotImplementedError((type(error), error))
921 return _construct_response(
929 '{}\n'.format(target.alias(), text, author.alias())),
930 original=error.message)