Ran update-copyright.py.
[pygrader.git] / pygrader / mailpipe.py
index 6a42eae380d5e70a9db32f0680ae85adaaaedffc..48c724f1287afce2d7e5de22a2a3561fb507b8c3 100644 (file)
@@ -1,4 +1,4 @@
-# Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
+# Copyright (C) 2012 W. Trevor King <wking@tremily.us>
 #
 # This file is part of pygrader.
 #
@@ -31,16 +31,22 @@ from lxml import etree as _etree
 from . import LOG as _LOG
 from .email import construct_email as _construct_email
 from .email import construct_response as _construct_response
+from .extract_mime import message_time as _message_time
 from .model.person import Person as _Person
 
+from .handler import InsecureMessage as _InsecureMessage
+from .handler import InvalidAssignmentSubject as _InvalidAssignmentSubject
 from .handler import InvalidMessage as _InvalidMessage
+from .handler import InvalidStudentSubject as _InvalidStudentSubject
 from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
+from .handler import PermissionViolationMessage as _PermissionViolationMessage
 from .handler import Response as _Response
 from .handler import UnsignedMessage as _UnsignedMessage
-from .handler.get import InvalidStudent as _InvalidStudent
 from .handler.get import run as _handle_get
-from .handler.submission import InvalidAssignment as _InvalidAssignment
+from .handler.grade import run as _handle_grade
+from .handler.grade import MissingGradeMessage as _MissingGradeMessage
 from .handler.submission import run as _handle_submission
+from .handler.submission import InvalidSubmission as _InvalidSubmission
 
 
 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
@@ -70,6 +76,25 @@ class AmbiguousAddress (_InvalidMessage):
         self.people = people
 
 
+class WrongSignatureMessage (_InsecureMessage):
+    def __init__(self, pgp_key=None, fingerprints=None, decrypted=None,
+                 **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'not signed by the expected key'
+        super(WrongSignatureMessage, self).__init__(**kwargs)
+        self.pgp_key = pgp_key
+        self.fingerprints = fingerprints
+        self.decrypted = decrypted
+
+class UnverifiedSignatureMessage (_InsecureMessage):
+    def __init__(self, signature=None, decrypted=None, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'unverified signature'
+        super(UnverifiedSignatureMessage, self).__init__(**kwargs)
+        self.signature = signature
+        self.decrypted = decrypted
+
+
 class SubjectlessMessage (_InvalidSubjectMessage):
     def __init__(self, **kwargs):
         if 'error' not in kwargs:
@@ -88,8 +113,10 @@ class InvalidHandlerMessage (_InvalidSubjectMessage):
 
 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
              output=None, continue_after_invalid_message=False, max_late=0,
+             trust_email_infrastructure=False,
              handlers={
         'get': _handle_get,
+        'grade': _handle_grade,
         'submit': _handle_submission,
         }, respond=None, dry_run=False, **kwargs):
     """Run from procmail to sort incomming submissions
@@ -218,6 +245,16 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     >>> message['Return-Path'] = '<bb@greyhavens.net>'
     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
+    Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Date: ...
+    From: Robot101 <phys101@tower.edu>
+    Reply-to: Robot101 <phys101@tower.edu>
+    To: Bilbo Baggins <bb@shire.org>
+    Subject: Received Assignment 1 submission
+    <BLANKLINE>
+    --===============...==
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Disposition: inline
@@ -230,6 +267,19 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     Yours,
     phys-101 robot
     <BLANKLINE>
+    --===============...==
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Description: OpenPGP digital signature
+    Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP SIGNATURE-----
+    Version: GnuPG v2.0.19 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
 
     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
     Bilbo_Baggins
@@ -261,6 +311,16 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     ...     'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
+    Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Date: ...
+    From: Robot101 <phys101@tower.edu>
+    Reply-to: Robot101 <phys101@tower.edu>
+    To: Bilbo Baggins <bb@shire.org>
+    Subject: Received Assignment 1 submission
+    <BLANKLINE>
+    --===============...==
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Disposition: inline
@@ -273,6 +333,20 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     Yours,
     phys-101 robot
     <BLANKLINE>
+    --===============...==
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Description: OpenPGP digital signature
+    Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP SIGNATURE-----
+    Version: GnuPG v2.0.19 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
+
     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
     Bilbo_Baggins
     Bilbo_Baggins/Assignment_1
@@ -304,6 +378,16 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     >>> message['Message-ID'] = '<hgi.jlk@home.net>'
     >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
+    Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Date: ...
+    From: Robot101 <phys101@tower.edu>
+    Reply-to: Robot101 <phys101@tower.edu>
+    To: Bilbo Baggins <bb@shire.org>
+    Subject: Received Assignment 1 submission
+    <BLANKLINE>
+    --===============...==
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Disposition: inline
@@ -316,6 +400,19 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     Yours,
     phys-101 robot
     <BLANKLINE>
+    --===============...==
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Description: OpenPGP digital signature
+    Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
+    <BLANKLINE>
+    -----BEGIN PGP SIGNATURE-----
+    Version: GnuPG v2.0.19 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
 
     Response to a submission on an unsubmittable assignment:
 
@@ -554,62 +651,70 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
         course=course, stream=stream, mailbox=mailbox, input_=input_,
         output=output, dry_run=dry_run,
         continue_after_invalid_message=continue_after_invalid_message,
+        trust_email_infrastructure=trust_email_infrastructure,
         respond=respond):
         try:
             handler = _get_handler(handlers=handlers, target=target)
+            _LOG.debug('handling {}'.format(target))
             handler(
                 basedir=basedir, course=course, message=message,
                 person=person, subject=subject,
-                max_late=max_late, dry_run=dry_run)
+                max_late=max_late,
+                trust_email_infrastructure=trust_email_infrastructure,
+                dry_run=dry_run)
         except _InvalidMessage as error:
             error.course = course
             error.message = original
-            if person is not None and not hasattr(error, 'person'):
-                error.person = person
-            if subject is not None and not hasattr(error, 'subject'):
-                error.subject = subject
-            if target is not None and not hasattr(error, 'target'):
-                error.target = target
+            for attribute,value in [('person', person),
+                                    ('subject', subject),
+                                    ('target', target)]:
+                if (value is not None and
+                    getattr(error, attribute, None) is None):
+                    setattr(error, attribute, value)
             _LOG.warn('invalid message {}'.format(error.message_id()))
             if not continue_after_invalid_message:
                 raise
+            _LOG.warn('{}'.format(error))
             if respond:
                 response = _get_error_response(error)
                 respond(response)
         except _Response as response:
             if respond:
-                author = course.robot
-                target = person
                 msg = response.message
-                if isinstance(response.message, _MIMEText):
-                    # Manipulate body (based on pgp_mime.append_text)
-                    original_encoding = msg.get_charset().input_charset
-                    original_payload = str(
-                        msg.get_payload(decode=True), original_encoding)
-                    new_payload = (
-                        '{},\n\n'
-                        '{}\n\n'
-                        'Yours,\n'
-                        '{}\n').format(
-                        target.alias(), original_payload, author.alias())
-                    new_encoding = _pgp_mime.guess_encoding(new_payload)
-                    if msg.get('content-transfer-encoding', None):
-                        # clear CTE so set_payload will set it properly
-                        del msg['content-transfer-encoding']
-                    msg.set_payload(new_payload, new_encoding)
-                subject = msg['Subject']
-                del msg['Subject']
-                assert subject is not None, msg
-                msg = _construct_email(
-                    author=author, targets=[person], subject=subject,
-                    message=msg)
-                respond(response.message)
-
+                if not response.complete:
+                    author = course.robot
+                    target = person
+                    msg = response.message
+                    if isinstance(response.message, _MIMEText):
+                        # Manipulate body (based on pgp_mime.append_text)
+                        original_encoding = msg.get_charset().input_charset
+                        original_payload = str(
+                            msg.get_payload(decode=True), original_encoding)
+                        new_payload = (
+                            '{},\n\n'
+                            '{}\n\n'
+                            'Yours,\n'
+                            '{}\n').format(
+                            target.alias(), original_payload, author.alias())
+                        new_encoding = _pgp_mime.guess_encoding(new_payload)
+                        if msg.get('content-transfer-encoding', None):
+                            # clear CTE so set_payload will set it properly
+                            del msg['content-transfer-encoding']
+                        msg.set_payload(new_payload, new_encoding)
+                    subject = msg['Subject']
+                    assert subject is not None, msg
+                    del msg['Subject']
+                    msg = _construct_email(
+                        author=author, targets=[person], subject=subject,
+                        message=msg)
+                respond(msg)
 
 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
-                   continue_after_invalid_message=False, respond=None,
+                   continue_after_invalid_message=False,
+                   trust_email_infrastructure=False, respond=None,
                    dry_run=False):
     if mailbox is None:
+        _LOG.debug('loading message from {}'.format(stream))
         mbox = None
         messages = [(None,_message_from_file(stream))]
         if output is not None:
@@ -621,19 +726,29 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
             ombox = _mailbox.mbox(output, factory=None, create=True)
     elif mailbox == 'maildir':
         mbox = _mailbox.Maildir(input_, factory=None, create=False)
-        messages = mbox.items()
+        messages = []
+        for key,msg in mbox.items():
+            subpath = mbox._lookup(key)
+            if subpath.endswith('.gitignore'):
+                _LOG.debug('skipping non-message {}'.format(subpath))
+                continue
+            messages.append((key, msg))
         if output is not None:
             ombox = _mailbox.Maildir(output, factory=None, create=True)
     else:
         raise ValueError(mailbox)
+    messages.sort(key=_get_message_time)
     for key,msg in messages:
         try:
-            ret = _parse_message(course=course, message=msg)
+            ret = _parse_message(
+                course=course, message=msg,
+                trust_email_infrastructure=trust_email_infrastructure)
         except _InvalidMessage as error:
             error.message = msg
             _LOG.warn('invalid message {}'.format(error.message_id()))
             if not continue_after_invalid_message:
                 raise
+            _LOG.warn('{}'.format(error))
             if respond:
                 response = _get_error_response(error)
                 if response is not None:
@@ -646,7 +761,7 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
                 del mbox[key]
         yield ret
 
-def _parse_message(course, message):
+def _parse_message(course, message, trust_email_infrastructure=False):
     """Parse an incoming email and respond if neccessary.
 
     Return ``(msg, person, assignment, time)`` on successful parsing.
@@ -657,19 +772,25 @@ def _parse_message(course, message):
     try:
         person = _get_message_person(course=course, message=message)
         if person.pgp_key:
-            message = _get_decoded_message(
-                course=course, message=message, person=person)
+            _LOG.debug('verify message is from {}'.format(person))
+            try:
+                message = _get_verified_message(message, person.pgp_key)
+            except _UnsignedMessage as error:
+                if trust_email_infrastructure:
+                    _LOG.warn('{}'.format(error))
+                else:
+                    raise
         subject = _get_message_subject(message=message)
         target = _get_message_target(subject=subject)
     except _InvalidMessage as error:
         error.course = course
         error.message = original
-        if person is not None and not hasattr(error, 'person'):
-            error.person = person
-        if subject is not None and not hasattr(error, 'subject'):
-            error.subject = subject
-        if target is not None and not hasattr(error, 'target'):
-            error.target = target
+        for attribute,value in [('person', person),
+                                ('subject', subject),
+                                ('target', target)]:
+            if (value is not None and
+                getattr(error, attribute, None) is None):
+                setattr(error, attribute, value)
         raise
     return (original, message, person, subject, target)
 
@@ -685,12 +806,6 @@ def _get_message_person(course, message):
         raise AmbiguousAddress(message=message, address=sender, people=people)
     return people[0]
 
-def _get_decoded_message(course, message, person):
-    msg = _get_verified_message(message, person.pgp_key)
-    if msg is None:
-        raise _UnsignedMessage(message=message)
-    return msg
-
 def _get_message_subject(message):
     """
     >>> from email.header import Header
@@ -802,39 +917,44 @@ def _get_verified_message(message, pgp_key):
     >>> our_message.authenticated
     True
 
-    If it is signed, but not by the right key, we get ``None``.
+    If it is signed, but not by the right key, we get an error.
 
     >>> print(_get_verified_message(signed, pgp_key='01234567'))
-    None
+    Traceback (most recent call last):
+      ...
+    pygrader.mailpipe.WrongSignatureMessage: not signed by the expected key
 
-    If it is not signed at all, we get ``None``.
+    If it is not signed at all, we get another error.
 
     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
-    None
+    Traceback (most recent call last):
+      ...
+    pygrader.handler.UnsignedMessage: unsigned message
     """
     mid = message['message-id']
     try:
         decrypted,verified,result = _pgp_mime.verify(message=message)
-    except (ValueError, AssertionError):
-        _LOG.warning('could not verify {} (not signed?)'.format(mid))
-        return None
+    except (ValueError, AssertionError) as error:
+        raise _UnsignedMessage(message=message) from error
     _LOG.debug(str(result, 'utf-8'))
     tree = _etree.fromstring(result.replace(b'\x00', b''))
     match = None
+    fingerprints = []
     for signature in tree.findall('.//signature'):
         for fingerprint in signature.iterchildren('fpr'):
-            if fingerprint.text.endswith(pgp_key):
-                match = signature
-                break
-    if match is None:
-        _LOG.warning('{} is not signed by the expected key'.format(mid))
-        return None
+            fingerprints.append(fingerprint)
+    matches = [f for f in fingerprints if f.text.endswith(pgp_key)]
+    if len(matches) == 0:
+        raise WrongSignatureMessage(
+            message=message, pgp_key=pgp_key, fingerprints=fingerprints,
+            decrypted=decrypted)
+    match = matches[0]
     if not verified:
         sumhex = list(signature.iterchildren('summary'))[0].get('value')
         summary = int(sumhex, 16)
         if summary != 0:
-            _LOG.warning('{} has an unverified signature'.format(mid))
-            return None
+            raise UnverifiedSignatureMessage(
+                message=message, signature=signature, decrypted=decrypted)
         # otherwise, we may have an untrusted key.  We'll count that
         # as verified here, because the caller is explicity looking
         # for signatures by this fingerprint.
@@ -851,7 +971,20 @@ def _get_error_response(error):
     author = error.course.robot
     target = getattr(error, 'person', None)
     subject = str(error)
-    if isinstance(error, InvalidHandlerMessage):
+    if isinstance(error, _InvalidSubmission):
+        subject = 'Received invalid {} submission'.format(
+            error.assignment.name)
+        text = (
+            'We received your submission for {}, but you are not\n'
+            'allowed to submit that assignment via email.'
+            ).format(error.assignment.name)
+    elif isinstance(error, _MissingGradeMessage):
+        subject = 'No grade in {!r}'.format(error.subject)
+        text = (
+            'Your grade submission did not include a text/plain\n'
+            'part containing the new grade and comment.'
+            )
+    elif isinstance(error, InvalidHandlerMessage):
         targets = sorted(error.handlers.keys())
         if not targets:
             hint = (
@@ -866,7 +999,7 @@ def _get_error_response(error):
             '  {!r}\n'
             'which does not match any submittable handler name for\n'
             '{}.\n'
-            '{}').format(repr(error.subject), error.course.name, hint)
+            '{}').format(error.subject, error.course.name, hint)
     elif isinstance(error, SubjectlessMessage):
         subject = 'no subject in {}'.format(error.message['Message-ID'])
         text = 'We received an email message from you without a subject.'
@@ -882,6 +1015,31 @@ def _get_error_response(error):
             'or TA.').format(error.course.name)
     elif isinstance(error, NoReturnPath):
         return
+    elif isinstance(error, _InvalidAssignmentSubject):
+        if error.assignments:
+            hint = (
+                'but it matches several assignments:\n'
+                '  * {}').format('\n  * '.join(
+                    a.name for a in error.assignments))
+        else:
+            # prefer a submittable example assignment
+            assignments = [
+                a for a in error.course.assignments if a.submittable]
+            assignments += course.assignments  # but fall back to any one
+            hint = (
+                'Remember to use the full name for the assignment in the\n'
+                'subject.  For example:\n'
+                '  {} submission').format(assignments[0].name)
+        text = (
+            'We got an email from you with the following subject:\n'
+            '  {!r}\n{}').format(error.subject, hint)
+    elif isinstance(error, _InvalidStudentSubject):
+        text = (
+            'We got an email from you with the following subject:\n'
+            '  {!r}\n'
+            'but it matches several students:\n'
+            '  * {}').format(
+            error.subject, '\n  * '.join(s.name for s in error.students))
     elif isinstance(error, _InvalidSubjectMessage):
         text = (
             'We received an email message from you with an invalid\n'
@@ -890,19 +1048,16 @@ def _get_error_response(error):
         subject = 'unsigned message {}'.format(error.message['Message-ID'])
         text = (
             'We received an email message from you without a valid\n'
-            'PGP signature.')
-    elif isinstance(error, _InvalidAssignment):
-        text = (
-            'We received your submission for {}, but you are not\n'
-            'allowed to submit that assignment via email.'
-            ).format(error.assignment.name)
-    elif isinstance(error, _InvalidStudent):
+            'PGP signature.'
+            )
+    elif isinstance(error, _PermissionViolationMessage):
         text = (
             'We got an email from you with the following subject:\n'
             '  {!r}\n'
-            'but it matches several students:\n'
+            "but you can't do that unless you belong to one of the\n"
+            'following groups:\n'
             '  * {}').format(
-            error.subject, '\n  * '.join(s.name for s in error.students))
+            error.subject, '\n  * '.join(error.allowed_groups))
     elif isinstance(error, _InvalidMessage):
         text = subject
     else:
@@ -919,3 +1074,8 @@ def _get_error_response(error):
             'Yours,\n'
             '{}\n'.format(target.alias(), text, author.alias())),
         original=error.message)
+
+def _get_message_time(key_message):
+    "Key function for sorting mailbox (key,message) tuples."
+    key,message = key_message
+    return _message_time(message)