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 import InsecureMessage as _InsecureMessage
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 WrongSignatureMessage (_InsecureMessage):
76 def __init__(self, pgp_key=None, fingerprints=None, decrypted=None,
78 if 'error' not in kwargs:
79 kwargs['error'] = 'not signed by the expected key'
80 super(WrongSignatureMessage, self).__init__(**kwargs)
81 self.pgp_key = pgp_key
82 self.fingerprints = fingerprints
83 self.decrypted = decrypted
85 class UnverifiedSignatureMessage (_InsecureMessage):
86 def __init__(self, signature=None, decrypted=None, **kwargs):
87 if 'error' not in kwargs:
88 kwargs['error'] = 'unverified signature'
89 super(UnverifiedSignatureMessage, self).__init__(**kwargs)
90 self.signature = signature
91 self.decrypted = decrypted
94 class SubjectlessMessage (_InvalidSubjectMessage):
95 def __init__(self, **kwargs):
96 if 'error' not in kwargs:
97 kwargs['error'] = 'no subject'
98 super(SubjectlessMessage, self).__init__(**kwargs)
101 class InvalidHandlerMessage (_InvalidSubjectMessage):
102 def __init__(self, target=None, handlers=None, **kwargs):
103 if 'error' not in kwargs:
104 kwargs['error'] = 'no handler for {!r}'.format(target)
105 super(InvalidHandlerMessage, self).__init__(**kwargs)
107 self.handlers = handlers
110 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
111 output=None, continue_after_invalid_message=False, max_late=0,
112 trust_email_infrastructure=False,
115 'submit': _handle_submission,
116 }, respond=None, dry_run=False, **kwargs):
117 """Run from procmail to sort incomming submissions
119 For example, you can setup your ``.procmailrc`` like this::
124 DEFAULT=$MAILDIR/mbox
125 LOGFILE=$MAILDIR/procmail.log
127 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
129 # Grab all incoming homeworks emails. This rule eats matching emails
130 # (i.e. no further procmail processing).
132 * ^Subject:.*\[phys160:submit]
133 | "$PYGRADE_MAILPIPE" mailpipe
135 If you don't want procmail to eat the message, you can use the
136 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
138 >>> from io import StringIO
139 >>> from pgp_mime.email import encodedMIMEText
140 >>> from .handler import InvalidMessage, Response
141 >>> from .test.course import StubCourse
143 >>> course = StubCourse()
144 >>> def respond(message):
145 ... print('respond with:\\n{}'.format(message.as_string()))
146 >>> def process(message):
148 ... basedir=course.basedir, course=course.course,
149 ... stream=StringIO(message.as_string()),
150 ... output=course.mailbox,
151 ... continue_after_invalid_message=True,
153 >>> message = encodedMIMEText('The answer is 42.')
154 >>> message['Message-ID'] = '<123.456@home.net>'
155 >>> message['Received'] = (
156 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
157 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
158 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
159 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
160 >>> message['To'] = 'phys101 <phys101@tower.edu>'
161 >>> message['Subject'] = '[submit] assignment 1'
163 Messages with unrecognized ``Return-Path``\s are silently dropped:
165 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
166 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
173 Response to a message from an unregistered person:
175 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
176 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
178 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
180 Content-Disposition: inline
182 From: Robot101 <phys101@tower.edu>
183 Reply-to: Robot101 <phys101@tower.edu>
184 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
185 Subject: unregistered address invalid.return.path@home.net
187 --===============...==
188 Content-Type: multipart/mixed; boundary="===============...=="
191 --===============...==
192 Content-Type: text/plain; charset="us-ascii"
194 Content-Transfer-Encoding: 7bit
195 Content-Disposition: inline
197 invalid.return.path@home.net,
199 Your email address is not registered with pygrader for
200 Physics 101. If you feel it should be, contact your professor
206 --===============...==
207 Content-Type: message/rfc822
210 Content-Type: text/plain; charset="us-ascii"
212 Content-Transfer-Encoding: 7bit
213 Content-Disposition: inline
214 Message-ID: <123.456@home.net>
215 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)
216 From: Billy B <bb@greyhavens.net>
217 To: phys101 <phys101@tower.edu>
218 Subject: [submit] assignment 1
219 Return-Path: <invalid.return.path@home.net>
222 --===============...==--
223 --===============...==
225 Content-Transfer-Encoding: 7bit
226 Content-Description: OpenPGP digital signature
227 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
229 -----BEGIN PGP SIGNATURE-----
230 Version: GnuPG v2.0.19 (GNU/Linux)
233 -----END PGP SIGNATURE-----
235 --===============...==--
237 If we add a valid ``Return-Path``, we get the expected delivery:
239 >>> del message['Return-Path']
240 >>> message['Return-Path'] = '<bb@greyhavens.net>'
241 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
243 Content-Type: text/plain; charset="us-ascii"
245 Content-Disposition: inline
246 Content-Transfer-Encoding: 7bit
250 We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
256 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
258 Bilbo_Baggins/Assignment_1
259 Bilbo_Baggins/Assignment_1/mail
260 Bilbo_Baggins/Assignment_1/mail/cur
261 Bilbo_Baggins/Assignment_1/mail/new
262 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
263 Bilbo_Baggins/Assignment_1/mail/tmp
271 The last ``Received`` is used to timestamp the message:
273 >>> del message['Message-ID']
274 >>> message['Message-ID'] = '<abc.def@home.net>'
275 >>> del message['Received']
276 >>> message['Received'] = (
277 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
278 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
279 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
280 >>> message['Received'] = (
281 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
282 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
283 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
284 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
286 Content-Type: text/plain; charset="us-ascii"
288 Content-Disposition: inline
289 Content-Transfer-Encoding: 7bit
293 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
298 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
300 Bilbo_Baggins/Assignment_1
301 Bilbo_Baggins/Assignment_1/late
302 Bilbo_Baggins/Assignment_1/mail
303 Bilbo_Baggins/Assignment_1/mail/cur
304 Bilbo_Baggins/Assignment_1/mail/new
305 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
306 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
307 Bilbo_Baggins/Assignment_1/mail/tmp
316 You can send receipts to the acknowledge incoming messages, which
317 includes warnings about dropped messages (except for messages
318 without ``Return-Path`` and messages where the ``Return-Path``
319 email belongs to multiple ``People``. The former should only
320 occur with malicious emails, and the latter with improper pygrader
323 Response to a successful submission:
325 >>> del message['Message-ID']
326 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
327 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
329 Content-Type: text/plain; charset="us-ascii"
331 Content-Disposition: inline
332 Content-Transfer-Encoding: 7bit
336 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
342 Response to a submission on an unsubmittable assignment:
344 >>> del message['Subject']
345 >>> message['Subject'] = '[submit] attendance 1'
346 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
348 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
350 Content-Disposition: inline
352 From: Robot101 <phys101@tower.edu>
353 Reply-to: Robot101 <phys101@tower.edu>
354 To: Bilbo Baggins <bb@shire.org>
355 Subject: Received invalid Attendance 1 submission
357 --===============...==
358 Content-Type: multipart/mixed; boundary="===============...=="
361 --===============...==
362 Content-Type: text/plain; charset="us-ascii"
364 Content-Transfer-Encoding: 7bit
365 Content-Disposition: inline
369 We received your submission for Attendance 1, but you are not
370 allowed to submit that assignment via email.
375 --===============...==
376 Content-Type: message/rfc822
379 Content-Type: text/plain; charset="us-ascii"
381 Content-Transfer-Encoding: 7bit
382 Content-Disposition: inline
383 From: Billy B <bb@greyhavens.net>
384 To: phys101 <phys101@tower.edu>
385 Return-Path: <bb@greyhavens.net>
386 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)
387 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)
388 Message-ID: <hgi.jlk@home.net>
389 Subject: [submit] attendance 1
392 --===============...==--
393 --===============...==
395 Content-Transfer-Encoding: 7bit
396 Content-Description: OpenPGP digital signature
397 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
399 -----BEGIN PGP SIGNATURE-----
400 Version: GnuPG v2.0.19 (GNU/Linux)
403 -----END PGP SIGNATURE-----
405 --===============...==--
407 Response to a bad subject:
409 >>> del message['Subject']
410 >>> message['Subject'] = 'need help for the first homework'
411 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
413 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
415 Content-Disposition: inline
417 From: Robot101 <phys101@tower.edu>
418 Reply-to: Robot101 <phys101@tower.edu>
419 To: Bilbo Baggins <bb@shire.org>
420 Subject: no tag in 'need help for the first homework'
422 --===============...==
423 Content-Type: multipart/mixed; boundary="===============...=="
426 --===============...==
427 Content-Type: text/plain; charset="us-ascii"
429 Content-Transfer-Encoding: 7bit
430 Content-Disposition: inline
434 We received an email message from you with an invalid
440 --===============...==
441 Content-Type: message/rfc822
444 Content-Type: text/plain; charset="us-ascii"
446 Content-Transfer-Encoding: 7bit
447 Content-Disposition: inline
448 From: Billy B <bb@greyhavens.net>
449 To: phys101 <phys101@tower.edu>
450 Return-Path: <bb@greyhavens.net>
451 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)
452 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)
453 Message-ID: <hgi.jlk@home.net>
454 Subject: need help for the first homework
457 --===============...==--
458 --===============...==
460 Content-Transfer-Encoding: 7bit
461 Content-Description: OpenPGP digital signature
462 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
464 -----BEGIN PGP SIGNATURE-----
465 Version: GnuPG v2.0.19 (GNU/Linux)
468 -----END PGP SIGNATURE-----
470 --===============...==--
472 Response to a missing subject:
474 >>> del message['Subject']
475 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
477 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
479 Content-Disposition: inline
481 From: Robot101 <phys101@tower.edu>
482 Reply-to: Robot101 <phys101@tower.edu>
483 To: Bilbo Baggins <bb@shire.org>
484 Subject: no subject in <hgi.jlk@home.net>
486 --===============...==
487 Content-Type: multipart/mixed; boundary="===============...=="
490 --===============...==
491 Content-Type: text/plain; charset="us-ascii"
493 Content-Transfer-Encoding: 7bit
494 Content-Disposition: inline
498 We received an email message from you without a subject.
503 --===============...==
504 Content-Type: message/rfc822
507 Content-Type: text/plain; charset="us-ascii"
509 Content-Transfer-Encoding: 7bit
510 Content-Disposition: inline
511 From: Billy B <bb@greyhavens.net>
512 To: phys101 <phys101@tower.edu>
513 Return-Path: <bb@greyhavens.net>
514 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)
515 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)
516 Message-ID: <hgi.jlk@home.net>
519 --===============...==--
520 --===============...==
522 Content-Transfer-Encoding: 7bit
523 Content-Description: OpenPGP digital signature
524 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
526 -----BEGIN PGP SIGNATURE-----
527 Version: GnuPG v2.0.19 (GNU/Linux)
530 -----END PGP SIGNATURE-----
532 --===============...==--
534 Response to an insecure message from a person with a PGP key:
536 >>> student = course.course.person(email='bb@greyhavens.net')
537 >>> student.pgp_key = '4332B6E3'
538 >>> del message['Subject']
539 >>> process(message) # doctest: +REPORT_UDIFF, +ELLIPSIS
541 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
543 Content-Disposition: inline
545 From: Robot101 <phys101@tower.edu>
546 Reply-to: Robot101 <phys101@tower.edu>
547 To: Bilbo Baggins <bb@shire.org>
548 Subject: unsigned message <hgi.jlk@home.net>
550 --===============...==
552 Content-Transfer-Encoding: 7bit
553 Content-Type: application/pgp-encrypted; charset="us-ascii"
557 --===============...==
559 Content-Transfer-Encoding: 7bit
560 Content-Description: OpenPGP encrypted message
561 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
563 -----BEGIN PGP MESSAGE-----
564 Version: GnuPG v2.0.19 (GNU/Linux)
567 -----END PGP MESSAGE-----
569 --===============...==--
575 for original,message,person,subject,target in _load_messages(
576 course=course, stream=stream, mailbox=mailbox, input_=input_,
577 output=output, dry_run=dry_run,
578 continue_after_invalid_message=continue_after_invalid_message,
579 trust_email_infrastructure=trust_email_infrastructure,
582 handler = _get_handler(handlers=handlers, target=target)
583 _LOG.debug('handling {}'.format(target))
585 basedir=basedir, course=course, message=message,
586 person=person, subject=subject,
588 trust_email_infrastructure=trust_email_infrastructure,
590 except _InvalidMessage as error:
591 error.course = course
592 error.message = original
593 if person is not None and not hasattr(error, 'person'):
594 error.person = person
595 if subject is not None and not hasattr(error, 'subject'):
596 error.subject = subject
597 if target is not None and not hasattr(error, 'target'):
598 error.target = target
599 _LOG.warn('invalid message {}'.format(error.message_id()))
600 if not continue_after_invalid_message:
602 _LOG.warn('{}'.format(error))
604 response = _get_error_response(error)
606 except _Response as response:
608 author = course.robot
610 msg = response.message
611 if isinstance(response.message, _MIMEText):
612 # Manipulate body (based on pgp_mime.append_text)
613 original_encoding = msg.get_charset().input_charset
614 original_payload = str(
615 msg.get_payload(decode=True), original_encoding)
621 target.alias(), original_payload, author.alias())
622 new_encoding = _pgp_mime.guess_encoding(new_payload)
623 if msg.get('content-transfer-encoding', None):
624 # clear CTE so set_payload will set it properly
625 del msg['content-transfer-encoding']
626 msg.set_payload(new_payload, new_encoding)
627 subject = msg['Subject']
629 assert subject is not None, msg
630 msg = _construct_email(
631 author=author, targets=[person], subject=subject,
633 respond(response.message)
636 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
637 continue_after_invalid_message=False,
638 trust_email_infrastructure=False, respond=None,
641 _LOG.debug('loading message from {}'.format(stream))
643 messages = [(None,_message_from_file(stream))]
644 if output is not None:
645 ombox = _mailbox.Maildir(output, factory=None, create=True)
646 elif mailbox == 'mbox':
647 mbox = _mailbox.mbox(input_, factory=None, create=False)
648 messages = mbox.items()
649 if output is not None:
650 ombox = _mailbox.mbox(output, factory=None, create=True)
651 elif mailbox == 'maildir':
652 mbox = _mailbox.Maildir(input_, factory=None, create=False)
654 for key,msg in mbox.items():
655 subpath = mbox._lookup(key)
656 if subpath.endswith('.gitignore'):
657 _LOG.debug('skipping non-message {}'.format(subpath))
659 messages.append((key, msg))
660 if output is not None:
661 ombox = _mailbox.Maildir(output, factory=None, create=True)
663 raise ValueError(mailbox)
664 messages = sorted(messages, key=_get_message_time)
665 for key,msg in messages:
667 ret = _parse_message(
668 course=course, message=msg,
669 trust_email_infrastructure=trust_email_infrastructure)
670 except _InvalidMessage as error:
672 _LOG.warn('invalid message {}'.format(error.message_id()))
673 if not continue_after_invalid_message:
675 _LOG.warn('{}'.format(error))
677 response = _get_error_response(error)
678 if response is not None:
681 if output is not None and dry_run is False:
682 # move message from input mailbox to output mailbox
688 def _parse_message(course, message, trust_email_infrastructure=False):
689 """Parse an incoming email and respond if neccessary.
691 Return ``(msg, person, assignment, time)`` on successful parsing.
692 Return ``None`` on failure.
695 person = subject = target = None
697 person = _get_message_person(course=course, message=message)
699 _LOG.debug('verify message is from {}'.format(person))
701 message = _get_verified_message(message, person.pgp_key)
702 except _UnsignedMessage as error:
703 if trust_email_infrastructure:
704 _LOG.warn('{}'.format(error))
707 subject = _get_message_subject(message=message)
708 target = _get_message_target(subject=subject)
709 except _InvalidMessage as error:
710 error.course = course
711 error.message = original
712 if person is not None and not hasattr(error, 'person'):
713 error.person = person
714 if subject is not None and not hasattr(error, 'subject'):
715 error.subject = subject
716 if target is not None and not hasattr(error, 'target'):
717 error.target = target
719 return (original, message, person, subject, target)
721 def _get_message_person(course, message):
722 sender = message['Return-Path'] # RFC 822
724 raise NoReturnPath(message)
725 sender = sender[1:-1] # strip wrapping '<' and '>'
726 people = list(course.find_people(email=sender))
728 raise UnregisteredAddress(message=message, address=sender)
730 raise AmbiguousAddress(message=message, address=sender, people=people)
733 def _get_message_subject(message):
735 >>> from email.header import Header
736 >>> from pgp_mime.email import encodedMIMEText
737 >>> message = encodedMIMEText('The answer is 42.')
738 >>> message['Message-ID'] = 'msg-id'
739 >>> _get_message_subject(message=message)
740 Traceback (most recent call last):
742 pygrader.mailpipe.SubjectlessMessage: no subject
743 >>> del message['Subject']
744 >>> subject = Header('unicode part', 'utf-8')
745 >>> subject.append('-ascii part', 'ascii')
746 >>> message['Subject'] = subject.encode()
747 >>> _get_message_subject(message=message)
748 'unicode part-ascii part'
749 >>> del message['Subject']
750 >>> message['Subject'] = 'clean subject'
751 >>> _get_message_subject(message=message)
754 if message['Subject'] is None:
755 raise SubjectlessMessage(subject=None, message=message)
757 parts = _decode_header(message['Subject'])
759 for string,encoding in parts:
762 if not isinstance(string, str):
763 string = str(string, encoding)
764 part_strings.append(string)
765 subject = ''.join(part_strings)
766 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
767 return subject.lower().replace('#', '')
769 def _get_message_target(subject):
771 >>> _get_message_target(subject='no tag')
772 Traceback (most recent call last):
774 pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
775 >>> _get_message_target(subject='[] empty tag')
776 Traceback (most recent call last):
778 pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
779 >>> _get_message_target(subject='[abc] empty tag')
781 >>> _get_message_target(subject='[phys160:abc] empty tag')
784 match = _TAG_REGEXP.match(subject)
786 raise _InvalidSubjectMessage(
787 subject=subject, error='no tag in {!r}'.format(subject))
790 raise _InvalidSubjectMessage(
791 subject=subject, error='empty tag in {!r}'.format(subject))
792 target = tag.rsplit(':', 1)[-1]
793 _LOG.debug('extracted target {} -> {}'.format(subject, target))
796 def _get_handler(handlers, target):
798 handler = handlers[target]
799 except KeyError as error:
800 raise InvalidHandlerMessage(
801 target=target, handlers=handlers) from error
804 def _get_verified_message(message, pgp_key):
807 >>> from pgp_mime import sign, encodedMIMEText
809 The student composes a message...
811 >>> message = encodedMIMEText('1.23 joules')
813 ... and signs it (with the pgp-mime test key).
815 >>> signed = sign(message, signers=['pgp-mime-test'])
817 As it is being delivered, the message picks up extra headers.
819 >>> signed['Message-ID'] = '<01234567@home.net>'
820 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
821 >>> signed['Received'] = 'from smtp.home.net ...'
823 We check that the message is signed, and that it is signed by the
826 >>> signed.authenticated
827 Traceback (most recent call last):
829 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
830 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
831 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
832 Content-Type: text/plain; charset="us-ascii"
834 Content-Transfer-Encoding: 7bit
835 Content-Disposition: inline
836 Message-ID: <01234567@home.net>
837 Received: from smtp.mail.uu.edu ...
838 Received: from smtp.home.net ...
841 >>> our_message.authenticated
844 If it is signed, but not by the right key, we get an error.
846 >>> print(_get_verified_message(signed, pgp_key='01234567'))
847 Traceback (most recent call last):
849 pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
851 If it is not signed at all, we get another error.
853 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
854 Traceback (most recent call last):
856 pygrader.handler.UnsignedMessage: unsigned message
858 mid = message['message-id']
860 decrypted,verified,result = _pgp_mime.verify(message=message)
861 except (ValueError, AssertionError) as error:
862 raise _UnsignedMessage(message=message) from error
863 _LOG.debug(str(result, 'utf-8'))
864 tree = _etree.fromstring(result.replace(b'\x00', b''))
867 for signature in tree.findall('.//signature'):
868 for fingerprint in signature.iterchildren('fpr'):
869 fingerprints.append(fingerprint)
870 matches = [f for f in fingerprints if f.text.endswith(pgp_key)]
871 if len(matches) == 0:
872 raise WrongSignatureMessage(
873 message=message, pgp_key=pgp_key, fingerprints=fingerprints,
877 sumhex = list(signature.iterchildren('summary'))[0].get('value')
878 summary = int(sumhex, 16)
880 raise UnverifiedSignatureMessage(
881 message=message, signature=signature, decrypted=decrypted)
882 # otherwise, we may have an untrusted key. We'll count that
883 # as verified here, because the caller is explicity looking
884 # for signatures by this fingerprint.
885 for k,v in message.items(): # copy over useful headers
886 if k.lower() not in ['content-type',
888 'content-disposition',
891 decrypted.authenticated = True
894 def _get_error_response(error):
895 author = error.course.robot
896 target = getattr(error, 'person', None)
898 if isinstance(error, InvalidHandlerMessage):
899 targets = sorted(error.handlers.keys())
902 'In fact, there are no available handlers for this\n'
906 'Perhaps you meant to use one of the following:\n'
907 ' {}').format('\n '.join(targets))
909 'We got an email from you with the following subject:\n'
911 'which does not match any submittable handler name for\n'
913 '{}').format(repr(error.subject), error.course.name, hint)
914 elif isinstance(error, SubjectlessMessage):
915 subject = 'no subject in {}'.format(error.message['Message-ID'])
916 text = 'We received an email message from you without a subject.'
917 elif isinstance(error, AmbiguousAddress):
919 'Multiple people match {} ({})'.format(
920 error.address, ', '.join(p.name for p in error.people)))
921 elif isinstance(error, UnregisteredAddress):
922 target = _Person(name=error.address, emails=[error.address])
924 'Your email address is not registered with pygrader for\n'
925 '{}. If you feel it should be, contact your professor\n'
926 'or TA.').format(error.course.name)
927 elif isinstance(error, NoReturnPath):
929 elif isinstance(error, _InvalidSubjectMessage):
931 'We received an email message from you with an invalid\n'
933 elif isinstance(error, _UnsignedMessage):
934 subject = 'unsigned message {}'.format(error.message['Message-ID'])
936 'We received an email message from you without a valid\n'
938 elif isinstance(error, _InvalidAssignment):
940 'We received your submission for {}, but you are not\n'
941 'allowed to submit that assignment via email.'
942 ).format(error.assignment.name)
943 elif isinstance(error, _InvalidStudent):
945 'We got an email from you with the following subject:\n'
947 'but it matches several students:\n'
949 error.subject, '\n * '.join(s.name for s in error.students))
950 elif isinstance(error, _InvalidMessage):
953 raise NotImplementedError((type(error), error))
955 raise NotImplementedError((type(error), error))
956 return _construct_response(
964 '{}\n'.format(target.alias(), text, author.alias())),
965 original=error.message)
967 def _get_message_time(key_message):
968 "Key function for sorting mailbox (key,message) tuples."
969 key,message = key_message
970 return _message_time(message)