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 .color import color_string as _color_string
33 from .color import standard_colors as _standard_colors
34 from .email import construct_email as _construct_email
35 from .email import construct_response as _construct_response
36 from .model.person import Person as _Person
38 from .handler import InvalidMessage as _InvalidMessage
39 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
40 from .handler import Response as _Response
41 from .handler import UnsignedMessage as _UnsignedMessage
42 from .handler.get import InvalidStudent as _InvalidStudent
43 from .handler.get import run as _handle_get
44 from .handler.submission import InvalidAssignment as _InvalidAssignment
45 from .handler.submission import run as _handle_submission
48 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
51 class NoReturnPath (_InvalidMessage):
52 def __init__(self, address, **kwargs):
53 if 'error' not in kwargs:
54 kwargs['error'] = 'no Return-Path'
55 super(NoReturnPath, self).__init__(**kwargs)
58 class UnregisteredAddress (_InvalidMessage):
59 def __init__(self, address, **kwargs):
60 if 'error' not in kwargs:
61 kwargs['error'] = 'unregistered address {}'.format(address)
62 super(UnregisteredAddress, self).__init__(**kwargs)
63 self.address = address
66 class AmbiguousAddress (_InvalidMessage):
67 def __init__(self, address, people, **kwargs):
68 if 'error' not in kwargs:
69 kwargs['error'] = 'ambiguous address {}'.format(address)
70 super(AmbiguousAddress, self).__init__(**kwargs)
71 self.address = address
75 class SubjectlessMessage (_InvalidSubjectMessage):
76 def __init__(self, **kwargs):
77 if 'error' not in kwargs:
78 kwargs['error'] = 'no subject'
79 super(SubjectlessMessage, self).__init__(**kwargs)
82 class InvalidHandlerMessage (_InvalidSubjectMessage):
83 def __init__(self, target=None, handlers=None, **kwargs):
84 if 'error' not in kwargs:
85 kwargs['error'] = 'no handler for {!r}'.format(
86 kwargs.get('target', None))
87 super(InvalidHandlerMessage, self).__init__(**kwargs)
89 self.handlers = handlers
92 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
93 output=None, continue_after_invalid_message=False, max_late=0,
96 'submit': _handle_submission,
97 }, respond=None, use_color=None, dry_run=False, **kwargs):
98 """Run from procmail to sort incomming submissions
100 For example, you can setup your ``.procmailrc`` like this::
105 DEFAULT=$MAILDIR/mbox
106 LOGFILE=$MAILDIR/procmail.log
108 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
110 # Grab all incoming homeworks emails. This rule eats matching emails
111 # (i.e. no further procmail processing).
113 * ^Subject:.*\[phys160:submit]
114 | "$PYGRADE_MAILPIPE" mailpipe
116 If you don't want procmail to eat the message, you can use the
117 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
119 >>> from io import StringIO
120 >>> from pgp_mime.email import encodedMIMEText
121 >>> from .handler import InvalidMessage, Response
122 >>> from .test.course import StubCourse
124 >>> course = StubCourse()
125 >>> def respond(message):
126 ... print('respond with:\\n{}'.format(message.as_string()))
127 >>> def process(message):
129 ... basedir=course.basedir, course=course.course,
130 ... stream=StringIO(message.as_string()),
131 ... output=course.mailbox,
132 ... continue_after_invalid_message=True,
134 >>> message = encodedMIMEText('The answer is 42.')
135 >>> message['Message-ID'] = '<123.456@home.net>'
136 >>> message['Received'] = (
137 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
138 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
139 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
140 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
141 >>> message['To'] = 'phys101 <phys101@tower.edu>'
142 >>> message['Subject'] = '[submit] assignment 1'
144 Messages with unrecognized ``Return-Path``\s are silently dropped:
146 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
147 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
154 Response to a message from an unregistered person:
156 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
157 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
159 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
161 Content-Disposition: inline
163 From: Robot101 <phys101@tower.edu>
164 Reply-to: Robot101 <phys101@tower.edu>
165 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
166 Subject: unregistered address invalid.return.path@home.net
168 --===============...==
169 Content-Type: multipart/mixed; boundary="===============...=="
172 --===============...==
173 Content-Type: text/plain; charset="us-ascii"
175 Content-Transfer-Encoding: 7bit
176 Content-Disposition: inline
178 invalid.return.path@home.net,
180 Your email address is not registered with pygrader for
181 Physics 101. If you feel it should be, contact your professor
187 --===============...==
188 Content-Type: message/rfc822
191 Content-Type: text/plain; charset="us-ascii"
193 Content-Transfer-Encoding: 7bit
194 Content-Disposition: inline
195 Message-ID: <123.456@home.net>
196 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)
197 From: Billy B <bb@greyhavens.net>
198 To: phys101 <phys101@tower.edu>
199 Subject: [submit] assignment 1
200 Return-Path: <invalid.return.path@home.net>
203 --===============...==--
204 --===============...==
206 Content-Transfer-Encoding: 7bit
207 Content-Description: OpenPGP digital signature
208 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
210 -----BEGIN PGP SIGNATURE-----
211 Version: GnuPG v2.0.19 (GNU/Linux)
214 -----END PGP SIGNATURE-----
216 --===============...==--
218 If we add a valid ``Return-Path``, we get the expected delivery:
220 >>> del message['Return-Path']
221 >>> message['Return-Path'] = '<bb@greyhavens.net>'
222 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
224 Content-Type: text/plain; charset="us-ascii"
226 Content-Disposition: inline
227 Content-Transfer-Encoding: 7bit
231 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
237 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
239 Bilbo_Baggins/Assignment_1
240 Bilbo_Baggins/Assignment_1/mail
241 Bilbo_Baggins/Assignment_1/mail/cur
242 Bilbo_Baggins/Assignment_1/mail/new
243 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
244 Bilbo_Baggins/Assignment_1/mail/tmp
252 The last ``Received`` is used to timestamp the message:
254 >>> del message['Message-ID']
255 >>> message['Message-ID'] = '<abc.def@home.net>'
256 >>> del message['Received']
257 >>> message['Received'] = (
258 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
259 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
260 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
261 >>> message['Received'] = (
262 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
263 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
264 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
265 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
267 Content-Type: text/plain; charset="us-ascii"
269 Content-Disposition: inline
270 Content-Transfer-Encoding: 7bit
274 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
279 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
281 Bilbo_Baggins/Assignment_1
282 Bilbo_Baggins/Assignment_1/late
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/new/...:2,S
288 Bilbo_Baggins/Assignment_1/mail/tmp
297 You can send receipts to the acknowledge incoming messages, which
298 includes warnings about dropped messages (except for messages
299 without ``Return-Path`` and messages where the ``Return-Path``
300 email belongs to multiple ``People``. The former should only
301 occur with malicious emails, and the latter with improper pygrader
304 Response to a successful submission:
306 >>> del message['Message-ID']
307 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
308 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
310 Content-Type: text/plain; charset="us-ascii"
312 Content-Disposition: inline
313 Content-Transfer-Encoding: 7bit
317 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
323 Response to a submission on an unsubmittable assignment:
325 >>> del message['Subject']
326 >>> message['Subject'] = '[submit] attendance 1'
327 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
329 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
331 Content-Disposition: inline
333 From: Robot101 <phys101@tower.edu>
334 Reply-to: Robot101 <phys101@tower.edu>
335 To: Bilbo Baggins <bb@shire.org>
336 Subject: Received invalid Attendance 1 submission
338 --===============...==
339 Content-Type: multipart/mixed; boundary="===============...=="
342 --===============...==
343 Content-Type: text/plain; charset="us-ascii"
345 Content-Transfer-Encoding: 7bit
346 Content-Disposition: inline
350 We received your submission for Attendance 1, but you are not
351 allowed to submit that assignment via email.
356 --===============...==
357 Content-Type: message/rfc822
360 Content-Type: text/plain; charset="us-ascii"
362 Content-Transfer-Encoding: 7bit
363 Content-Disposition: inline
364 From: Billy B <bb@greyhavens.net>
365 To: phys101 <phys101@tower.edu>
366 Return-Path: <bb@greyhavens.net>
367 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)
368 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)
369 Message-ID: <hgi.jlk@home.net>
370 Subject: [submit] attendance 1
373 --===============...==--
374 --===============...==
376 Content-Transfer-Encoding: 7bit
377 Content-Description: OpenPGP digital signature
378 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
380 -----BEGIN PGP SIGNATURE-----
381 Version: GnuPG v2.0.19 (GNU/Linux)
384 -----END PGP SIGNATURE-----
386 --===============...==--
388 Response to a bad subject:
390 >>> del message['Subject']
391 >>> message['Subject'] = 'need help for the first homework'
392 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
394 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
396 Content-Disposition: inline
398 From: Robot101 <phys101@tower.edu>
399 Reply-to: Robot101 <phys101@tower.edu>
400 To: Bilbo Baggins <bb@shire.org>
401 Subject: no tag in 'need help for the first homework'
403 --===============...==
404 Content-Type: multipart/mixed; boundary="===============...=="
407 --===============...==
408 Content-Type: text/plain; charset="us-ascii"
410 Content-Transfer-Encoding: 7bit
411 Content-Disposition: inline
415 We received an email message from you with an invalid
421 --===============...==
422 Content-Type: message/rfc822
425 Content-Type: text/plain; charset="us-ascii"
427 Content-Transfer-Encoding: 7bit
428 Content-Disposition: inline
429 From: Billy B <bb@greyhavens.net>
430 To: phys101 <phys101@tower.edu>
431 Return-Path: <bb@greyhavens.net>
432 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)
433 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)
434 Message-ID: <hgi.jlk@home.net>
435 Subject: need help for the first homework
438 --===============...==--
439 --===============...==
441 Content-Transfer-Encoding: 7bit
442 Content-Description: OpenPGP digital signature
443 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
445 -----BEGIN PGP SIGNATURE-----
446 Version: GnuPG v2.0.19 (GNU/Linux)
449 -----END PGP SIGNATURE-----
451 --===============...==--
453 Response to a missing subject:
455 >>> del message['Subject']
456 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
458 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
460 Content-Disposition: inline
462 From: Robot101 <phys101@tower.edu>
463 Reply-to: Robot101 <phys101@tower.edu>
464 To: Bilbo Baggins <bb@shire.org>
465 Subject: no subject in <hgi.jlk@home.net>
467 --===============...==
468 Content-Type: multipart/mixed; boundary="===============...=="
471 --===============...==
472 Content-Type: text/plain; charset="us-ascii"
474 Content-Transfer-Encoding: 7bit
475 Content-Disposition: inline
479 We received an email message from you without a subject.
484 --===============...==
485 Content-Type: message/rfc822
488 Content-Type: text/plain; charset="us-ascii"
490 Content-Transfer-Encoding: 7bit
491 Content-Disposition: inline
492 From: Billy B <bb@greyhavens.net>
493 To: phys101 <phys101@tower.edu>
494 Return-Path: <bb@greyhavens.net>
495 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)
496 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)
497 Message-ID: <hgi.jlk@home.net>
500 --===============...==--
501 --===============...==
503 Content-Transfer-Encoding: 7bit
504 Content-Description: OpenPGP digital signature
505 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
507 -----BEGIN PGP SIGNATURE-----
508 Version: GnuPG v2.0.19 (GNU/Linux)
511 -----END PGP SIGNATURE-----
513 --===============...==--
515 Response to an insecure message from a person with a PGP key:
517 >>> student = course.course.person(email='bb@greyhavens.net')
518 >>> student.pgp_key = '4332B6E3'
519 >>> del message['Subject']
520 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
522 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
524 Content-Disposition: inline
526 From: Robot101 <phys101@tower.edu>
527 Reply-to: Robot101 <phys101@tower.edu>
528 To: Bilbo Baggins <bb@shire.org>
529 Subject: unsigned message <hgi.jlk@home.net>
531 --===============...==
533 Content-Transfer-Encoding: 7bit
534 Content-Type: application/pgp-encrypted; charset="us-ascii"
538 --===============...==
540 Content-Transfer-Encoding: 7bit
541 Content-Description: OpenPGP encrypted message
542 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
544 -----BEGIN PGP MESSAGE-----
545 Version: GnuPG v2.0.19 (GNU/Linux)
548 -----END PGP MESSAGE-----
550 --===============...==--
554 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
557 for original,message,person,subject,target in _load_messages(
558 course=course, stream=stream, mailbox=mailbox, input_=input_,
559 output=output, use_color=use_color, dry_run=dry_run,
560 continue_after_invalid_message=continue_after_invalid_message,
563 handler = _get_handler(handlers=handlers, target=target)
565 basedir=basedir, course=course, message=message,
566 person=person, subject=subject,
567 max_late=max_late, use_color=use_color, dry_run=dry_run)
568 except _InvalidMessage as error:
569 if not continue_after_invalid_message:
572 error.course = course
573 error.message = original
574 if person is not None and not hasattr(error, 'person'):
575 error.person = person
576 if subject is not None and not hasattr(error, 'subject'):
577 error.subject = subject
578 if target is not None and not hasattr(error, 'target'):
579 error.target = target
580 response = _get_error_response(error)
582 except _Response as response:
584 author = course.robot
586 msg = response.message
587 if isinstance(response.message, _MIMEText):
588 # Manipulate body (based on pgp_mime.append_text)
589 original_encoding = msg.get_charset().input_charset
590 original_payload = str(
591 msg.get_payload(decode=True), original_encoding)
597 target.alias(), original_payload, author.alias())
598 new_encoding = _pgp_mime.guess_encoding(new_payload)
599 if msg.get('content-transfer-encoding', None):
600 # clear CTE so set_payload will set it properly
601 del msg['content-transfer-encoding']
602 msg.set_payload(new_payload, new_encoding)
603 subject = msg['Subject']
605 assert subject is not None, msg
606 msg = _construct_email(
607 author=author, targets=[person], subject=subject,
609 respond(response.message)
612 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
613 continue_after_invalid_message=False, respond=None,
614 use_color=None, dry_run=False):
617 messages = [(None,_message_from_file(stream))]
618 if output is not None:
619 ombox = _mailbox.Maildir(output, factory=None, create=True)
620 elif mailbox == 'mbox':
621 mbox = _mailbox.mbox(input_, factory=None, create=False)
622 messages = mbox.items()
623 if output is not None:
624 ombox = _mailbox.mbox(output, factory=None, create=True)
625 elif mailbox == 'maildir':
626 mbox = _mailbox.Maildir(input_, factory=None, create=False)
627 messages = mbox.items()
628 if output is not None:
629 ombox = _mailbox.Maildir(output, factory=None, create=True)
631 raise ValueError(mailbox)
632 for key,msg in messages:
634 ret = _parse_message(
635 course=course, message=msg, use_color=use_color)
636 except _InvalidMessage as error:
637 if not continue_after_invalid_message:
640 response = _get_error_response(error)
641 if response is not None:
644 if output is not None and dry_run is False:
645 # move message from input mailbox to output mailbox
651 def _parse_message(course, message, use_color=None):
652 """Parse an incoming email and respond if neccessary.
654 Return ``(msg, person, assignment, time)`` on successful parsing.
655 Return ``None`` on failure.
658 person = subject = target = None
660 person = _get_message_person(
661 course=course, message=message, use_color=use_color)
663 message = _get_decoded_message(
664 course=course, message=message, person=person,
666 subject = _get_message_subject(message=message, use_color=use_color)
667 target = _get_message_target(subject=subject, use_color=use_color)
668 except _InvalidMessage as error:
669 error.course = course
670 error.message = original
671 if person is not None and not hasattr(error, 'person'):
672 error.person = person
673 if subject is not None and not hasattr(error, 'subject'):
674 error.subject = subject
675 if target is not None and not hasattr(error, 'target'):
676 error.target = target
678 return (original, message, person, subject, target)
680 def _get_message_person(course, message, use_color=None):
681 sender = message['Return-Path'] # RFC 822
683 raise NoReturnPath(message)
684 sender = sender[1:-1] # strip wrapping '<' and '>'
685 people = list(course.find_people(email=sender))
687 raise UnregisteredAddress(message=message, address=sender)
689 raise AmbiguousAddress(message=message, address=sender, people=people)
692 def _get_decoded_message(course, message, person, use_color=None):
693 msg = _get_verified_message(
694 message, person.pgp_key, use_color=use_color)
696 raise _UnsignedMessage(message=message)
699 def _get_message_subject(message, use_color=None):
701 >>> from email.header import Header
702 >>> from pgp_mime.email import encodedMIMEText
703 >>> message = encodedMIMEText('The answer is 42.')
704 >>> message['Message-ID'] = 'msg-id'
705 >>> _get_message_subject(message=message)
706 Traceback (most recent call last):
708 pygrader.mailpipe.SubjectlessMessage: no subject
709 >>> del message['Subject']
710 >>> subject = Header('unicode part', 'utf-8')
711 >>> subject.append('-ascii part', 'ascii')
712 >>> message['Subject'] = subject.encode()
713 >>> _get_message_subject(message=message)
714 'unicode part-ascii part'
715 >>> del message['Subject']
716 >>> message['Subject'] = 'clean subject'
717 >>> _get_message_subject(message=message)
720 if message['Subject'] is None:
721 raise SubjectlessMessage(subject=None, message=message)
723 parts = _decode_header(message['Subject'])
725 for string,encoding in parts:
728 if not isinstance(string, str):
729 string = str(string, encoding)
730 part_strings.append(string)
731 subject = ''.join(part_strings)
732 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
733 return subject.lower().replace('#', '')
735 def _get_message_target(subject, use_color=None):
737 >>> _get_message_target(subject='no tag')
738 Traceback (most recent call last):
740 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
741 >>> _get_message_target(subject='[] empty tag')
742 Traceback (most recent call last):
744 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
745 >>> _get_message_target(subject='[abc] empty tag')
747 >>> _get_message_target(subject='[phys160:abc] empty tag')
750 match = _TAG_REGEXP.match(subject)
752 raise _InvalidSubjectMessage(
753 subject=subject, error='no tag in {!r}'.format(subject))
756 raise _InvalidSubjectMessage(
757 subject=subject, error='empty tag in {!r}'.format(subject))
758 target = tag.rsplit(':', 1)[-1]
759 _LOG.debug('extracted target {} -> {}'.format(subject, target))
762 def _get_handler(handlers, target, use_color=None):
764 handler = handlers[target]
766 response_subject = 'no handler for {}'.format(target)
767 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
768 _LOG.debug(_color_string(string=response_subject, color=bad))
769 raise InvalidHandlerMessage(target=target, handlers=handlers)
772 def _get_verified_message(message, pgp_key, use_color=None):
775 >>> from pgp_mime import sign, encodedMIMEText
777 The student composes a message...
779 >>> message = encodedMIMEText('1.23 joules')
781 ... and signs it (with the pgp-mime test key).
783 >>> signed = sign(message, signers=['pgp-mime-test'])
785 As it is being delivered, the message picks up extra headers.
787 >>> signed['Message-ID'] = '<01234567@home.net>'
788 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
789 >>> signed['Received'] = 'from smtp.home.net ...'
791 We check that the message is signed, and that it is signed by the
794 >>> signed.authenticated
795 Traceback (most recent call last):
797 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
798 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
799 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
800 Content-Type: text/plain; charset="us-ascii"
802 Content-Transfer-Encoding: 7bit
803 Content-Disposition: inline
804 Message-ID: <01234567@home.net>
805 Received: from smtp.mail.uu.edu ...
806 Received: from smtp.home.net ...
809 >>> our_message.authenticated
812 If it is signed, but not by the right key, we get ``None``.
814 >>> print(_get_verified_message(signed, pgp_key='01234567'))
817 If it is not signed at all, we get ``None``.
819 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
822 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
823 mid = message['message-id']
825 decrypted,verified,result = _pgp_mime.verify(message=message)
826 except (ValueError, AssertionError):
827 _LOG.warn(_color_string(
828 string='could not verify {} (not signed?)'.format(mid),
831 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
832 tree = _etree.fromstring(result.replace(b'\x00', b''))
834 for signature in tree.findall('.//signature'):
835 for fingerprint in signature.iterchildren('fpr'):
836 if fingerprint.text.endswith(pgp_key):
840 _LOG.warn(_color_string(
841 string='{} is not signed by the expected key'.format(mid),
845 sumhex = list(signature.iterchildren('summary'))[0].get('value')
846 summary = int(sumhex, 16)
848 _LOG.warn(_color_string(
849 string='{} 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)