mailpipe: replace `respond` callback with exceptions.
authorW. Trevor King <wking@tremily.us>
Sat, 1 Sep 2012 19:25:58 +0000 (15:25 -0400)
committerW. Trevor King <wking@tremily.us>
Sat, 1 Sep 2012 19:25:58 +0000 (15:25 -0400)
Now mailpipe sub-functions will raise Response or InvalidMessage when
they want to respond to the incoming email.  This takes advantage of
Python's exception handling to avoid passing the `respond` callback
all over the place.  It also allows us consolidate error message
construction in `_get_error_response`, which will lead to both simpler
program logic and more consistent response messages.

Other changes to make this cleaner:

* Renamed functions in pygrader.email:
    _construct_email -> construct_email
    construct_email -> construct_text_email
* Pulled _get_assignment out of submission.run.

pygrader/email.py
pygrader/handler/__init__.py
pygrader/handler/get.py
pygrader/handler/submission.py
pygrader/mailpipe.py
pygrader/template.py

index aeb4da8e742a353a1f5f00cd31f74047971c8a72..3a463660eeb7d38b8332bfe8704899bb1a8278cf 100644 (file)
@@ -164,7 +164,7 @@ def get_address(person, header=False):
         return _email_utils.formataddr((name, person.emails[0]))
     return _email_utils.formataddr((person.name, person.emails[0]))
 
-def _construct_email(author, targets, subject, message, cc=None):
+def construct_email(author, targets, subject, message, cc=None):
     if author.pgp_key:
         signers = [author.pgp_key]
     else:
@@ -210,14 +210,14 @@ def _construct_email(author, targets, subject, message, cc=None):
 
     return message
 
-def construct_email(author, targets, subject, text, cc=None):
+def construct_text_email(author, targets, subject, text, cc=None):
     r"""Build a text/plain email using `Person` instances
 
     >>> from pygrader.model.person import Person as Person
     >>> author = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
     >>> targets = [Person(name='Jill', emails=['c@d.net'])]
     >>> cc = [Person(name='H.D.', emails=['hd@wall.net'])]
-    >>> msg = construct_email(author, targets, cc=cc,
+    >>> msg = construct_text_email(author, targets, cc=cc,
     ...     subject='Once upon a time', text='Bla bla bla...')
     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
     Content-Type: text/plain; charset="us-ascii"
@@ -235,7 +235,7 @@ def construct_email(author, targets, subject, text, cc=None):
 
     With unicode text:
 
-    >>> msg = construct_email(author, targets, cc=cc,
+    >>> msg = construct_text_email(author, targets, cc=cc,
     ...     subject='Once upon a time', text='Funky ✉.')
     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
     Content-Type: text/plain; charset="utf-8"
@@ -253,7 +253,7 @@ def construct_email(author, targets, subject, text, cc=None):
     <BLANKLINE>
     """
     message = _pgp_mime.encodedMIMEText(text)
-    return _construct_email(
+    return construct_email(
         author=author, targets=targets, subject=subject, message=message,
         cc=cc)
 
@@ -264,7 +264,7 @@ def construct_response(author, targets, subject, text, original, cc=None):
     >>> student = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
     >>> assistant = Person(name='Jill', emails=['c@d.net'])
     >>> cc = [assistant]
-    >>> msg = construct_email(author=student, targets=[assistant],
+    >>> msg = construct_text_email(author=student, targets=[assistant],
     ...     subject='Assignment 1 submission', text='Bla bla bla...')
     >>> rsp = construct_response(author=assistant, targets=[student],
     ...     subject='Received assignment 1 submission', text='3 hours late',
@@ -305,6 +305,6 @@ def construct_response(author, targets, subject, text, original, cc=None):
     message = _MIMEMultipart('mixed')
     message.attach(_pgp_mime.encodedMIMEText(text))
     message.attach(_MIMEMessage(original))
-    return _construct_email(
+    return construct_email(
         author=author, targets=targets, subject=subject, message=message,
         cc=cc)
index 698338a27d8de4c80205d844cbc96d4a1526e6e0..1837721320975337701c6657d1d376409c855196 100644 (file)
@@ -2,18 +2,41 @@
 
 "Define assorted handlers for use in :py:mod:`~pygrader.mailpipe`."
 
-from ..email import construct_response as _construct_response
-
-
-def respond(course, person, original, subject, text, respond):
-    "Helper for composing consistent response messages."
-    response_text = (
-        '{},\n\n'
-        '{}\n\n'
-        'Yours,\n{}').format(
-        person.alias(), text, course.robot.alias())
-    response = _construct_response(
-        author=course.robot, targets=[person],
-        subject=subject, text=response_text,
-        original=original)
-    respond(response)
+import pgp_mime as _pgp_mime
+
+
+class InvalidMessage (ValueError):
+    def __init__(self, message=None, error=None):
+        super(InvalidMessage, self).__init__(error)
+        self.message = message
+        self.error = error
+
+
+class UnsignedMessage (InvalidMessage):
+    def __init__(self, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'unsigned message'
+        super(UnsignedMessage, self).__init__(**kwargs)
+
+
+class InvalidSubjectMessage (InvalidMessage):
+    def __init__(self, subject=None, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'invalid subject {!r}'.format(subject)
+        try:
+            super(InvalidSubjectMessage, self).__init__(**kwargs)
+        except TypeError:
+            raise ValueError(kwargs)
+        self.subject = subject
+
+
+class Response (Exception):
+    """Exception to bubble out email responses.
+
+    Rather than sending email responses themselves, handlers should
+    raise this exception.  The caller can catch it and mail the email
+    (or take other appropriate action).
+    """
+    def __init__(self, message=None):
+        super(Response, self).__init__()
+        self.message = message
index 5e58deec654ffd15f2ea3c153af6163c6ad76f17..c5797fa95dca2cc53bc0f449042fa9d989bd8f50 100644 (file)
@@ -16,38 +16,55 @@ import pgp_mime as _pgp_mime
 from .. import LOG as _LOG
 from ..color import color_string as _color_string
 from ..color import standard_colors as _standard_colors
+from ..email import construct_text_email as _construct_text_email
 from ..email import construct_email as _construct_email
-from ..email import _construct_email as _raw_construct_email
 from ..storage import assignment_path as _assignment_path
 from ..tabulate import tabulate as _tabulate
 from ..template import _student_email as _student_email
-from . import respond as _respond
+from . import InvalidMessage as _InvalidMessage
+from . import InvalidSubjectMessage as _InvalidSubjectMessage
+from . import Response as _Response
+from . import UnsignedMessage as _UnsignedMessage
+
+
+class InvalidStudent (_InvalidSubjectMessage):
+    def __init__(self, students=None, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'Subject matches multiple students'
+        super(InvalidStudent, self).__init__(kwargs)
+        self.students = students
 
 
 def run(basedir, course, original, message, person, subject,
-        trust_email_infrastructure=False, respond=None,
+        trust_email_infrastructure=False,
         use_color=None, dry_run=False, **kwargs):
     """
     >>> from pgp_mime.email import encodedMIMEText
-    >>> from pygrader.model.grade import Grade
-    >>> from pygrader.test.course import StubCourse
+    >>> from ..model.grade import Grade
+    >>> from ..test.course import StubCourse
+    >>> from . import InvalidMessage, Response
     >>> course = StubCourse()
     >>> person = list(
     ...     course.course.find_people(email='bb@greyhavens.net'))[0]
     >>> message = encodedMIMEText('This text is not important.')
     >>> message['Message-ID'] = '<123.456@home.net>'
-    >>> def respond(message):
-    ...     print('respond with:\\n{}'.format(
-    ...             message.as_string().replace('\\t', '  ')))
+    >>> def process(**kwargs):
+    ...     try:
+    ...         run(**kwargs)
+    ...     except Response as response:
+    ...         print('respond with:')
+    ...         print(response.message.as_string().replace('\\t', '  '))
+    ...     except InvalidMessage as error:
+    ...         print('{} error:'.format(type(error).__name__))
+    ...         print(error)
 
     Unauthenticated messages are refused by default.
 
-    >>> run(basedir=course.basedir, course=course.course, original=message,
-    ...     message=message, person=person, subject='[get]',
-    ...     max_late=0, respond=respond)
-    Traceback (most recent call last):
-      ...
-    ValueError: must request information in a signed email
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
+    ...     message=message, person=person, subject='[get]', max_late=0)
+    UnsignedMessage error:
+    unsigned message
 
     Although you can process them by setting the
     ``trust_email_infrastructure`` option.  This might not be too
@@ -59,77 +76,35 @@ def run(basedir, course, original, message, person, subject,
     sysadmins is considered unacceptable, you've can only email users
     who have registered PGP keys.
 
-    >>> run(basedir=course.basedir, course=course.course, original=message,
-    ...     message=message, person=person, subject='[get]',
-    ...     max_late=0, trust_email_infrastructure=True, respond=respond)
-    ... # doctest: +ELLIPSIS, +REPORT_UDIFF
-    Traceback (most recent call last):
-      ...
-    ValueError: no grades for <Person Bilbo Baggins>
-
     Students without grades get a reasonable response.
 
-    >>> message.authenticated = True
-    >>> try:
-    ...     run(basedir=course.basedir, course=course.course, original=message,
-    ...         message=message, person=person, subject='[get]',
-    ...         max_late=0, respond=respond)
-    ... except ValueError as error:
-    ...     print('\\ngot error: {}'.format(error))
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
+    ...     message=message, person=person, subject='[get]', max_late=0,
+    ...     trust_email_infrastructure=True)
     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
     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: no grades for Billy
-    <BLANKLINE>
-    --===============...==
-    Content-Type: multipart/mixed; boundary="===============...=="
-    MIME-Version: 1.0
-    <BLANKLINE>
-    --===============...==
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Transfer-Encoding: 7bit
     Content-Disposition: inline
-    <BLANKLINE>
-    Billy,
+    Subject: No grades for Billy
     <BLANKLINE>
     We don't have any of your grades on file for this course.
-    <BLANKLINE>
-    Yours,
-    phys-101 robot
-    --===============...==
-    Content-Type: message/rfc822
-    MIME-Version: 1.0
-    <BLANKLINE>
+
+    >>> message.authenticated = True
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
+    ...     message=message, person=person, subject='[get]', max_late=0)
+    ... # doctest: +ELLIPSIS, +REPORT_UDIFF
+    respond with:
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Transfer-Encoding: 7bit
     Content-Disposition: inline
-    Message-ID: <123.456@home.net>
-    <BLANKLINE>
-    This text is not important.
-    --===============...==--
-    --===============...==
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: 7bit
-    Content-Description: OpenPGP digital signature
-    Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
+    Subject: No grades for Billy
     <BLANKLINE>
-    -----BEGIN PGP SIGNATURE-----
-    Version: GnuPG v2.0.19 (GNU/Linux)
-    <BLANKLINE>
-    ...
-    -----END PGP SIGNATURE-----
-    <BLANKLINE>
-    --===============...==--
-    <BLANKLINE>
-    got error: no grades for <Person Bilbo Baggins>
+    We don't have any of your grades on file for this course.
 
     Once we add a grade, they get details on all their grades for the
     course.
@@ -149,9 +124,10 @@ def run(basedir, course, original, message, person, subject,
     ...     assignment=course.course.assignment('Assignment 1'),
     ...     points=10, comment='Looks good.')
     >>> course.course.grades.append(grade)
-    >>> run(basedir=course.basedir, course=course.course, original=message,
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
     ...     message=message, person=person, subject='[get]',
-    ...     max_late=0, respond=respond)
+    ...     max_late=0)
     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
@@ -204,9 +180,10 @@ def run(basedir, course, original, message, person, subject,
     >>> person = list(
     ...     course.course.find_people(email='eye@tower.edu'))[0]
     >>> person.pgp_key = None
-    >>> run(basedir=course.basedir, course=course.course, original=message,
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
     ...     message=message, person=person, subject='[get]',
-    ...     max_late=0, respond=respond)
+    ...     max_late=0)
     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
@@ -246,10 +223,11 @@ def run(basedir, course, original, message, person, subject,
 
     They can also request grades for a particular student.
 
-    >>> run(basedir=course.basedir, course=course.course, original=message,
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
     ...     message=message, person=person,
     ...     subject='[get] {}'.format(student.name),
-    ...     max_late=0, respond=respond)
+    ...     max_late=0)
     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
@@ -307,17 +285,21 @@ def run(basedir, course, original, message, person, subject,
     ...     '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)')
-    >>> _handle_submission(
-    ...     basedir=course.basedir, course=course.course, original=submission,
-    ...     message=submission, person=student,
-    ...     subject='[submit] Assignment 1')
+    >>> try:
+    ...     _handle_submission(
+    ...         basedir=course.basedir, course=course.course,
+    ...         original=submission, message=submission, person=student,
+    ...         subject='[submit] Assignment 1')
+    ... except _Response:
+    ...     pass
 
     Now lets request the submissions.
 
-    >>> run(basedir=course.basedir, course=course.course, original=message,
+    >>> process(
+    ...     basedir=course.basedir, course=course.course, original=message,
     ...     message=message, person=person,
     ...     subject='[get] {}, {}'.format(student.name, 'Assignment 1'),
-    ...     max_late=0, respond=respond)
+    ...     max_late=0)
     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
@@ -386,43 +368,22 @@ def run(basedir, course, original, message, person, subject,
     else:
         authenticated = hasattr(message, 'authenticated') and message.authenticated
     if not authenticated:
-        response_subject = 'must request information in a signed email'
-        if respond:
-            if person.pgp_key:
-                hint = (
-                    'Please resubmit your request in an OpenPGP-signed email\n'
-                    'using your PGP key {}.').format(persion.pgp_key)
-            else:
-                hint = (
-                    "We don't even have a PGP key on file for you.  Please talk\n"
-                    'to your professor or TA about getting one set up.')
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'We got an email from you with the following subject:\n'
-                    '  {!r}\n'
-                    'but we cannot provide the information unless we know it\n'
-                    'really was you who asked for it.\n\n'
-                    '{}').format(subject, hint),
-                respond=respond)
-        raise ValueError(response_subject)
+        raise _UnsignedMessage()
     if 'assistants' in person.groups or 'professors' in person.groups:
         email = _get_admin_email(
             basedir=basedir, course=course, original=original,
-            person=person, subject=subject, respond=respond,
-            use_color=None)
+            person=person, subject=subject, use_color=use_color)
     elif 'students' in person.groups:
         email = _get_student_email(
             basedir=basedir, course=course, original=original,
-            person=person, respond=respond, use_color=None)
+            person=person, use_color=use_color)
     else:
         raise NotImplementedError(
             'strange groups {} for {}'.format(person.groups, person))
-    if respond:
-        respond(email)
+    raise _Response(message=email)
 
 def _get_student_email(basedir, course, original, person, student=None,
-                       respond=None, use_color=None):
+                       use_color=None):
     if student is None:
         student = person
         targets = None
@@ -432,19 +393,17 @@ def _get_student_email(basedir, course, original, person, student=None,
         basedir=basedir, author=course.robot, course=course,
         student=student, targets=targets, old=True))
     if len(emails) == 0:
-        if respond:
-            if targets:
-                text = (
-                    "We don't have any grades for {} on file for this course."
-                    ).format(student.name)
-            else:
-                text = (
-                    "We don't have any of your grades on file for this course.")
-            _respond(
-                course=course, person=person, original=original,
-                subject='no grades for {}'.format(student.alias()), text=text,
-                respond=respond)
-        raise ValueError('no grades for {}'.format(student))
+        if targets is None:
+            text = (
+                "We don't have any of your grades on file for this course."
+                )
+        else:
+            text = (
+                "We don't have any grades for {} on file for this course."
+                ).format(student.name)
+        message = _pgp_mime.encodedMIMEText(text)
+        message['Subject'] = 'No grades for {}'.format(student.alias())
+        raise _Response(message=message)
     elif len(emails) > 1:
         raise NotImplementedError(emails)
     email,callback = emails[0]
@@ -452,8 +411,7 @@ def _get_student_email(basedir, course, original, person, student=None,
     return email
 
 def _get_student_submission_email(
-    basedir, course, original, person, assignments, student,
-    respond=None, use_color=None):
+    basedir, course, original, person, assignments, student, use_color=None):
     subject = '{} assignment submissions for {}'.format(
         course.name, student.name)
     text = '{}:\n  * {}\n'.format(
@@ -475,10 +433,11 @@ def _get_student_submission_email(
         else:
             for msg in mbox:
                 message.attach(_MIMEMessage(msg))
-    return _raw_construct_email(
-        author=course.robot, targets=[person], subject=subject, message=message)
+    return _construct_email(
+        author=course.robot, targets=[person], subject=subject,
+        message=message)
 
-def _get_admin_email(basedir, course, original, person, subject, respond=None,
+def _get_admin_email(basedir, course, original, person, subject,
                      use_color=None):
     lsubject = subject.lower()
     students = [p for p in course.find_people()
@@ -487,7 +446,7 @@ def _get_admin_email(basedir, course, original, person, subject, respond=None,
         stream = _io.StringIO()
         _tabulate(course=course, statistics=True, stream=stream)
         text = stream.getvalue()
-        email = _construct_email(
+        email = _construct_text_email(
             author=course.robot, targets=[person],
             subject='All grades for {}'.format(course.name),
             text=text)
@@ -498,26 +457,12 @@ def _get_admin_email(basedir, course, original, person, subject, respond=None,
         if len(assignments) == 0:
             email = _get_student_email(
                 basedir=basedir, course=course, original=original,
-                person=person, student=student, respond=respond,
-                use_color=None)
+                person=person, student=student, use_color=use_color)
         else:
             email = _get_student_submission_email(
                 basedir=basedir, course=course, original=original,
                 person=person, student=student, assignments=assignments,
-                use_color=None)
+                use_color=use_color)
     else:
-        if respond:
-            _respond(
-                course=course, person=person, original=original,
-                subject='subject matches multiple students',
-                text=(
-                    'We got an email from you with the following subject:\n'
-                    '  {!r}\n'
-                    'but it matches several students:\n'
-                    '  * {}').format(
-                    subject, '\n  * '.join(s.name for s in students)),
-                respond=respond)
-        raise ValueError(
-            'subject {!r} matches multiple students {}'.format(
-            subject, students))
+        raise InvalidStudent(students=students)
     return email
index 7d1656812e9f232ab7be2e745e743748172232ac..9021da2cd41ebdc26647fd3444541c49c5aa6843 100644 (file)
@@ -11,6 +11,8 @@ import mailbox as _mailbox
 import os as _os
 import os.path as _os_path
 
+import pgp_mime as _pgp_mime
+
 from .. import LOG as _LOG
 from ..color import color_string as _color_string
 from ..color import standard_colors as _standard_colors
@@ -18,15 +20,25 @@ from ..extract_mime import extract_mime as _extract_mime
 from ..extract_mime import message_time as _message_time
 from ..storage import assignment_path as _assignment_path
 from ..storage import set_late as _set_late
-from . import respond as _respond
+from . import InvalidMessage as _InvalidMessage
+from . import Response as _Response
+
+
+class InvalidAssignment (_InvalidMessage):
+    def __init__(self, assignment, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'Received invalid {} submission'.format(
+                assignment.name)
+        super(InvalidAssignment, self).__init__(**kwargs)
+        self.assignment = assignment
 
 
 def run(basedir, course, original, message, person, subject,
-        max_late=0, respond=None, use_color=None,
-        dry_run=None, **kwargs):
+        max_late=0, use_color=None, dry_run=None, **kwargs):
     """
     >>> from pgp_mime.email import encodedMIMEText
-    >>> from pygrader.test.course import StubCourse
+    >>> from ..test.course import StubCourse
+    >>> from . import Response
     >>> course = StubCourse()
     >>> person = list(
     ...     course.course.find_people(email='bb@greyhavens.net'))[0]
@@ -37,123 +49,28 @@ def run(basedir, course, original, message, person, subject,
     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
     >>> subject = '[submit] assignment 1'
-    >>> def respond(message):
-    ...     print('respond with:\\n{}'.format(message.as_string()))
-    >>> run(basedir=course.basedir, course=course.course, original=message,
-    ...     message=message, person=person, subject=subject,
-    ...     max_late=0, respond=respond)
+    >>> try:
+    ...     run(basedir=course.basedir, course=course.course, original=message,
+    ...         message=message, person=person, subject=subject,
+    ...         max_late=0)
+    ... except Response as e:
+    ...     print('respond with:')
+    ...     print(e.message.as_string())
     ... # doctest: +ELLIPSIS, +REPORT_UDIFF
     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: multipart/mixed; boundary="===============...=="
-    MIME-Version: 1.0
-    <BLANKLINE>
-    --===============...==
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Transfer-Encoding: 7bit
     Content-Disposition: inline
-    <BLANKLINE>
-    Billy,
+    Subject: Received Assignment 1 submission
     <BLANKLINE>
     We received your submission for Assignment 1 on ....
-    <BLANKLINE>
-    Yours,
-    phys-101 robot
-    --===============...==
-    Content-Type: message/rfc822
-    MIME-Version: 1.0
-    <BLANKLINE>
-    Content-Type: text/plain; charset="us-ascii"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: 7bit
-    Content-Disposition: inline
-    Message-ID: <123.456@home.net>
-    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)
-    <BLANKLINE>
-    The answer is 42.
-    --===============...==--
-    --===============...==
-    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.cleanup()
     """
     time = _message_time(message=message, use_color=use_color)
-
-    for assignment in course.assignments:
-        if _match_assignment(assignment, subject):
-            break
-    if not _match_assignment(assignment, subject):
-        response_subject = 'no assignment found in {!r}'.format(subject)
-        if respond:
-            submittable_assignments = [
-                a for a in course.assignments if a.submittable]
-            if not submittable_assignments:
-                hint = (
-                    'In fact, there are no submittable assignments for\n'
-                    'this course!')
-            else:
-                hint = (
-                    'Remember to use the full name for the assignment in the\n'
-                    'subject.  For example:\n'
-                    '  {} submission').format(
-                    submittable_assignments[0].name)
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'We got an email from you with the following subject:\n'
-                    '  {!r}\n'
-                    'which does not match any submittable assignment name\n'
-                    'for {}.\n'
-                    '{}').format(subject, course.name, hint),
-                respond=respond)
-        raise ValueError(response_subject)
-
-    if not assignment.submittable:
-        response_subject = 'received invalid {} submission'.format(
-            assignment.name)
-        if respond:
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'We received your submission for {}, but you are not\n'
-                    'allowed to submit that assignment via email.'
-                    ).format(assignment.name),
-                respond=respond)
-        raise ValueError(response_subject)
-
-    if respond:
-        response_subject = 'received {} submission'.format(assignment.name)
-        if time:
-            time_str = 'on {}'.format(_formatdate(time))
-        else:
-            time_str = 'at an unknown time'
-        _respond(
-            course=course, person=person, original=original,
-            subject=response_subject, text=(
-                'We received your submission for {} {}.'
-                ).format(assignment.name, time_str),
-            respond=respond)
-
+    assignment = _get_assignment(
+        course=course, subject=subject, use_color=use_color)
     assignment_path = _assignment_path(basedir, assignment, person)
     _save_local_message_copy(
         msg=message, person=person, assignment_path=assignment_path,
@@ -162,10 +79,63 @@ def run(basedir, course, original, message, person, subject,
     _check_late(
         basedir=basedir, assignment=assignment, person=person, time=time,
         max_late=max_late, use_color=use_color, dry_run=dry_run)
+    if time:
+        time_str = 'on {}'.format(_formatdate(time))
+    else:
+        time_str = 'at an unknown time'
+    message = _pgp_mime.encodedMIMEText((
+            'We received your submission for {} {}.'
+            ).format(
+            assignment.name, time_str))
+    message['Subject'] = 'Received {} submission'.format(assignment.name)
+    raise _Response(message=message)
 
 def _match_assignment(assignment, subject):
     return assignment.name.lower() in subject.lower()
 
+def _get_assignment(course, subject, use_color):
+    assignments = [a for a in course.assignments
+                   if _match_assignment(a, subject)]
+    if len(assignments) != 1:
+        if len(assignments) == 0:
+            response_subject = 'no assignment found in {!r}'.format(subject)
+            error = (
+                'does not match any submittable assignment name\n'
+                'for {}.\n').format(course.name)
+        else:
+            response_subject = 'several assignments found in {!r}'.format(
+                subject)
+            error = (
+                'matches several submittable assignment names\n'
+                'for {}:  * {}\n').format(
+                course.name,
+                '\n  * '.join(a.name for a in assignments))
+        submittable_assignments = [
+            a for a in course.assignments if a.submittable]
+        if not submittable_assignments:
+            hint = (
+                'In fact, there are no submittable assignments for\n'
+                'this course!')
+        else:
+            hint = (
+                'Remember to use the full name for the assignment in the\n'
+                'subject.  For example:\n'
+                '  {} submission').format(
+                submittable_assignments[0].name)
+        message = _pgp_mime.encodedMIMEText((
+                'We got an email from you with the following subject:\n'
+                '  {!r}\n'
+                'which {}.\n\n'
+                '{}\n').format(subject, course.name, hint))
+        message['Subject'] = response_subject
+        raise _Response(
+            message=message, exception=ValueError(response_subject))
+    assignment = assignments[0]
+
+    if not assignment.submittable:
+        raise InvalidAssignment(assignment)
+    return assignments[0]
+
 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
                              dry_run=False):
     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
index a01c69637d14348c89f7ff8e4b8f5617bcd55853..053bf413f97c837cac742b44dc2ad124e857c2d2 100644 (file)
@@ -20,32 +20,81 @@ from __future__ import absolute_import
 
 from email import message_from_file as _message_from_file
 from email.header import decode_header as _decode_header
+from email.mime.text import MIMEText as _MIMEText
 import mailbox as _mailbox
 import re as _re
 import sys as _sys
 
-from pgp_mime import verify as _verify
+import pgp_mime as _pgp_mime
 from lxml import etree as _etree
 
 from . import LOG as _LOG
 from .color import color_string as _color_string
 from .color import standard_colors as _standard_colors
+from .email import construct_email as _construct_email
+from .email import construct_response as _construct_response
 from .model.person import Person as _Person
 
-from .handler import respond as _respond
+from .handler import InvalidMessage as _InvalidMessage
+from .handler import InvalidSubjectMessage as _InvalidSubjectMessage
+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.submission import run as _handle_submission
 
 
 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
 
 
+class NoReturnPath (_InvalidMessage):
+    def __init__(self, address, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'no Return-Path'
+        super(NoReturnPath, self).__init__(**kwargs)
+
+
+class UnregisteredAddress (_InvalidMessage):
+    def __init__(self, address, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'unregistered address {}'.format(address)
+        super(UnregisteredAddress, self).__init__(**kwargs)
+        self.address = address
+
+
+class AmbiguousAddress (_InvalidMessage):
+    def __init__(self, address, people, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'ambiguous address {}'.format(address)
+        super(AmbiguousAddress, self).__init__(**kwargs)
+        self.address = address
+        self.people = people
+
+
+class SubjectlessMessage (_InvalidSubjectMessage):
+    def __init__(self, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'no subject'
+        super(SubjectlessMessage, self).__init__(**kwargs)
+
+
+class InvalidHandlerMessage (_InvalidSubjectMessage):
+    def __init__(self, target=None, handlers=None, **kwargs):
+        if 'error' not in kwargs:
+            kwargs['error'] = 'no handler for {!r}'.format(
+                kwargs.get('target', None))
+        super(InvalidHandlerMessage, self).__init__(**kwargs)
+        self.target = target
+        self.handlers = handlers
+
+
 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
-             output=None, max_late=0, handlers={
+             output=None, continue_after_invalid_message=False, max_late=0,
+             handlers={
         'get': _handle_get,
         'submit': _handle_submission,
-        }, respond=None, use_color=None,
-             dry_run=False, **kwargs):
+        }, respond=None, use_color=None, dry_run=False, **kwargs):
     """Run from procmail to sort incomming submissions
 
     For example, you can setup your ``.procmailrc`` like this::
@@ -67,23 +116,23 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     If you don't want procmail to eat the message, you can use the
     ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
 
-    >>> from asyncore import loop
     >>> from io import StringIO
     >>> from pgp_mime.email import encodedMIMEText
-    >>> from pygrader.test.course import StubCourse
-    >>> from pygrader.test.client import MessageSender
-    >>> from pygrader.test.server import SMTPServer
-
-    Messages with unrecognized ``Return-Path``\s are silently dropped:
+    >>> from .handler import InvalidMessage, Response
+    >>> from .test.course import StubCourse
 
     >>> course = StubCourse()
-    >>> def process(peer, mailfrom, rcpttos, data):
+    >>> def respond(message):
+    ...     print('respond with:\\n{}'.format(message.as_string()))
+    >>> def process(message):
     ...     mailpipe(
     ...         basedir=course.basedir, course=course.course,
-    ...         stream=StringIO(data), output=course.mailbox)
+    ...         stream=StringIO(message.as_string()),
+    ...         output=course.mailbox,
+    ...         continue_after_invalid_message=True,
+    ...         respond=respond)
     >>> message = encodedMIMEText('The answer is 42.')
     >>> message['Message-ID'] = '<123.456@home.net>'
-    >>> message['Return-Path'] = '<invalid.return.path@home.net>'
     >>> message['Received'] = (
     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
@@ -91,20 +140,100 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     >>> message['From'] = 'Billy B <bb@greyhavens.net>'
     >>> message['To'] = 'phys101 <phys101@tower.edu>'
     >>> message['Subject'] = '[submit] assignment 1'
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()
-    >>> course.print_tree()  # doctest: +REPORT_UDIFF
+
+    Messages with unrecognized ``Return-Path``\s are silently dropped:
+
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
     course.conf
+    mail
+    mail/cur
+    mail/new
+    mail/tmp
+
+    Response to a message from an unregistered person:
+
+    >>> message['Return-Path'] = '<invalid.return.path@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: "invalid.return.path@home.net" <invalid.return.path@home.net>
+    Subject: unregistered address invalid.return.path@home.net
+    <BLANKLINE>
+    --===============...==
+    Content-Type: multipart/mixed; boundary="===============...=="
+    MIME-Version: 1.0
+    <BLANKLINE>
+    --===============...==
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Disposition: inline
+    <BLANKLINE>
+    invalid.return.path@home.net,
+    <BLANKLINE>
+    Your email address is not registered with pygrader for
+    Physics 101.  If you feel it should be, contact your professor
+    or TA.
+    <BLANKLINE>
+    Yours,
+    phys-101 robot
+    <BLANKLINE>
+    --===============...==
+    Content-Type: message/rfc822
+    MIME-Version: 1.0
+    <BLANKLINE>
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Transfer-Encoding: 7bit
+    Content-Disposition: inline
+    Message-ID: <123.456@home.net>
+    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)
+    From: Billy B <bb@greyhavens.net>
+    To: phys101 <phys101@tower.edu>
+    Subject: [submit] assignment 1
+    Return-Path: <invalid.return.path@home.net>
+    <BLANKLINE>
+    The answer is 42.
+    --===============...==--
+    --===============...==
+    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>
+    --===============...==--
 
     If we add a valid ``Return-Path``, we get the expected delivery:
 
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Return-Path']
     >>> message['Return-Path'] = '<bb@greyhavens.net>'
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    respond with:
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Content-Transfer-Encoding: 7bit
+    <BLANKLINE>
+    Billy,
+    <BLANKLINE>
+    We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000.
+    <BLANKLINE>
+    Yours,
+    phys-101 robot
+    <BLANKLINE>
+
     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
     Bilbo_Baggins
     Bilbo_Baggins/Assignment_1
@@ -122,8 +251,6 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
 
     The last ``Received`` is used to timestamp the message:
 
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Message-ID']
     >>> message['Message-ID'] = '<abc.def@home.net>'
     >>> del message['Received']
@@ -135,9 +262,20 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     ...     '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)')
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    respond with:
+    Content-Type: text/plain; charset="us-ascii"
+    MIME-Version: 1.0
+    Content-Disposition: inline
+    Content-Transfer-Encoding: 7bit
+    <BLANKLINE>
+    Billy,
+    <BLANKLINE>
+    We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
+    <BLANKLINE>
+    Yours,
+    phys-101 robot
+    <BLANKLINE>
     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
     Bilbo_Baggins
     Bilbo_Baggins/Assignment_1
@@ -165,39 +303,14 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
 
     Response to a successful submission:
 
-    >>> def respond(message):
-    ...     print('respond with:\\n{}'.format(message.as_string()))
-    >>> def process(peer, mailfrom, rcpttos, data):
-    ...     mailpipe(
-    ...         basedir=course.basedir, course=course.course,
-    ...         stream=StringIO(data), output=course.mailbox,
-    ...         respond=respond)
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Message-ID']
     >>> message['Message-ID'] = '<hgi.jlk@home.net>'
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    >>> 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: multipart/mixed; boundary="===============...=="
-    MIME-Version: 1.0
-    <BLANKLINE>
-    --===============...==
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
-    Content-Transfer-Encoding: 7bit
     Content-Disposition: inline
+    Content-Transfer-Encoding: 7bit
     <BLANKLINE>
     Billy,
     <BLANKLINE>
@@ -205,47 +318,13 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     <BLANKLINE>
     Yours,
     phys-101 robot
-    --===============...==
-    Content-Type: message/rfc822
-    MIME-Version: 1.0
     <BLANKLINE>
-    Content-Type: text/plain; charset="us-ascii"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: 7bit
-    Content-Disposition: inline
-    From: Billy B <bb@greyhavens.net>
-    To: phys101 <phys101@tower.edu>
-    Subject: [submit] assignment 1
-    Return-Path: <bb@greyhavens.net>
-    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)
-    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)
-    Message-ID: <hgi.jlk@home.net>
-    <BLANKLINE>
-    The answer is 42.
-    --===============...==--
-    --===============...==
-    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:
 
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Subject']
     >>> message['Subject'] = '[submit] attendance 1'
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
     MIME-Version: 1.0
@@ -254,7 +333,7 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     From: Robot101 <phys101@tower.edu>
     Reply-to: Robot101 <phys101@tower.edu>
     To: Bilbo Baggins <bb@shire.org>
-    Subject: received invalid Attendance 1 submission
+    Subject: Received invalid Attendance 1 submission
     <BLANKLINE>
     --===============...==
     Content-Type: multipart/mixed; boundary="===============...=="
@@ -273,6 +352,7 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     <BLANKLINE>
     Yours,
     phys-101 robot
+    <BLANKLINE>
     --===============...==
     Content-Type: message/rfc822
     MIME-Version: 1.0
@@ -307,13 +387,9 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
 
     Response to a bad subject:
 
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Subject']
     >>> message['Subject'] = 'need help for the first homework'
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
     MIME-Version: 1.0
@@ -336,11 +412,12 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     <BLANKLINE>
     Billy,
     <BLANKLINE>
-    We received an email message from you without
-    subject tags.
+    We received an email message from you with an invalid
+    subject.
     <BLANKLINE>
     Yours,
     phys-101 robot
+    <BLANKLINE>
     --===============...==
     Content-Type: message/rfc822
     MIME-Version: 1.0
@@ -375,12 +452,8 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
 
     Response to a missing subject:
 
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Subject']
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
     MIME-Version: 1.0
@@ -407,6 +480,7 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     <BLANKLINE>
     Yours,
     phys-101 robot
+    <BLANKLINE>
     --===============...==
     Content-Type: message/rfc822
     MIME-Version: 1.0
@@ -442,12 +516,8 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
 
     >>> student = course.course.person(email='bb@greyhavens.net')
     >>> student.pgp_key = '4332B6E3'
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
     >>> del message['Subject']
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
+    >>> process(message)  # doctest: +REPORT_UDIFF, +ELLIPSIS
     respond with:
     Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
     MIME-Version: 1.0
@@ -479,74 +549,6 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     <BLANKLINE>
     --===============...==--
 
-    Response to a message from an unregistered person:
-
-    >>> server = SMTPServer(
-    ...     ('localhost', 1025), None, process=process, count=1)
-    >>> del message['Return-Path']
-    >>> message['Return-Path'] = '<invalid.return.path@home.net>'
-    >>> messages = [message]
-    >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
-    >>> loop()  # 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: "invalid.return.path@home.net" <invalid.return.path@home.net>
-    Subject: unregistered address invalid.return.path@home.net
-    <BLANKLINE>
-    --===============...==
-    Content-Type: multipart/mixed; boundary="===============...=="
-    MIME-Version: 1.0
-    <BLANKLINE>
-    --===============...==
-    Content-Type: text/plain; charset="us-ascii"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: 7bit
-    Content-Disposition: inline
-    <BLANKLINE>
-    invalid.return.path@home.net,
-    <BLANKLINE>
-    Your email address is not registered with pygrader for
-    Physics 101.  If you feel it should be, contact your professor
-    or TA.
-    <BLANKLINE>
-    Yours,
-    phys-101 robot
-    --===============...==
-    Content-Type: message/rfc822
-    MIME-Version: 1.0
-    <BLANKLINE>
-    Content-Type: text/plain; charset="us-ascii"
-    MIME-Version: 1.0
-    Content-Transfer-Encoding: 7bit
-    Content-Disposition: inline
-    From: Billy B <bb@greyhavens.net>
-    To: phys101 <phys101@tower.edu>
-    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)
-    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)
-    Message-ID: <hgi.jlk@home.net>
-    Return-Path: <invalid.return.path@home.net>
-    <BLANKLINE>
-    The answer is 42.
-    --===============...==--
-    --===============...==
-    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.cleanup()
     """
     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
@@ -554,21 +556,62 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
         stream = _sys.stdin
     for original,message,person,subject,target in _load_messages(
         course=course, stream=stream, mailbox=mailbox, input_=input_,
-        output=output, respond=respond, use_color=use_color, dry_run=dry_run):
-        handler = _get_handler(
-            course=course, handlers=handlers, message=message, person=person,
-            subject=subject, target=target)
+        output=output, use_color=use_color, dry_run=dry_run,
+        continue_after_invalid_message=continue_after_invalid_message,
+        respond=respond):
         try:
+            handler = _get_handler(handlers=handlers, target=target)
             handler(
                 basedir=basedir, course=course, original=original,
                 message=message, person=person, subject=subject,
-                max_late=max_late, respond=respond,
-                use_color=use_color, dry_run=dry_run)
-        except ValueError as error:
-            _LOG.warn(_color_string(string=str(error), color=bad))
+                max_late=max_late, use_color=use_color, dry_run=dry_run)
+        except _InvalidMessage as error:
+            if not continue_after_invalid_message:
+                raise
+            if respond:
+                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
+                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)
+
 
 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
-                   respond=None, use_color=None, dry_run=False):
+                   continue_after_invalid_message=False, respond=None,
+                   use_color=None, dry_run=False):
     if mailbox is None:
         mbox = None
         messages = [(None,_message_from_file(stream))]
@@ -587,121 +630,95 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
     else:
         raise ValueError(mailbox)
     for key,msg in messages:
-        ret = _parse_message(
-            course=course, message=msg, respond=respond, use_color=use_color)
-        if ret:
-            if output is not None and dry_run is False:
-                # move message from input mailbox to output mailbox
-                ombox.add(msg)
-                if mbox is not None:
-                    del mbox[key]
-            yield ret
-
-def _parse_message(course, message, respond=None, use_color=None):
+        try:
+            ret = _parse_message(
+                course=course, message=msg, use_color=use_color)
+        except _InvalidMessage as error:
+            if not continue_after_invalid_message:
+                raise
+            if respond:
+                response = _get_error_response(error)
+                if response is not None:
+                    respond(response)
+            continue
+        if output is not None and dry_run is False:
+            # move message from input mailbox to output mailbox
+            ombox.add(msg)
+            if mbox is not None:
+                del mbox[key]
+        yield ret
+
+def _parse_message(course, message, use_color=None):
     """Parse an incoming email and respond if neccessary.
 
     Return ``(msg, person, assignment, time)`` on successful parsing.
     Return ``None`` on failure.
     """
-    highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
     original = message
+    person = subject = target = None
     try:
         person = _get_message_person(
-            course=course, message=message, original=original,
-            respond=respond, use_color=use_color)
+            course=course, message=message, use_color=use_color)
         if person.pgp_key:
             message = _get_decoded_message(
-                course=course, message=message, original=original, person=person,
-                respond=respond, use_color=use_color)
-        subject = _get_message_subject(
-            course=course, message=message, original=original, person=person,
-            respond=respond, use_color=use_color)
-        target = _get_message_target(
-            course=course, message=message, original=original, person=person,
-            subject=subject, respond=respond, use_color=use_color)
-    except ValueError as error:
-        _LOG.debug(_color_string(string=str(error), color=bad))
-        return None
+                course=course, message=message, person=person,
+                use_color=use_color)
+        subject = _get_message_subject(message=message, use_color=use_color)
+        target = _get_message_target(subject=subject, use_color=use_color)
+    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
+        raise
     return (original, message, person, subject, target)
 
-def _get_message_person(course, message, original, respond=None,
-                        use_color=None):
-    mid = message['Message-ID']
+def _get_message_person(course, message, use_color=None):
     sender = message['Return-Path']  # RFC 822
     if sender is None:
-        raise ValueError('no Return-Path in {}'.format(mid))
+        raise NoReturnPath(message)
     sender = sender[1:-1]  # strip wrapping '<' and '>'
     people = list(course.find_people(email=sender))
     if len(people) == 0:
-        if respond:
-            person = _Person(name=sender, emails=[sender])
-            response_subject = 'unregistered address {}'.format(sender)
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'Your email address is not registered with pygrader for\n'
-                    '{}.  If you feel it should be, contact your professor\n'
-                    'or TA.').format(course.name),
-                respond=respond)
-        raise ValueError('no person found to match {}'.format(sender))
+        raise UnregisteredAddress(message=message, address=sender)
     if len(people) > 1:
-        raise ValueError('multiple people match {} ({})'.format(
-                sender, ', '.join(str(p) for p in people)))
+        raise AmbiguousAddress(message=message, address=sender, people=people)
     return people[0]
 
-def _get_decoded_message(course, message, original, person,
-                         respond=None, use_color=None):
-    message = _get_verified_message(
+def _get_decoded_message(course, message, person, use_color=None):
+    msg = _get_verified_message(
         message, person.pgp_key, use_color=use_color)
-    if message is None:
-        if respond:
-            mid = original['Message-ID']
-            response_subject = 'unsigned message {}'.format(mid)
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'We received an email message from you without a valid\n'
-                    'PGP signature.'),
-                respond=respond)
-        raise ValueError('unsigned message from {}'.format(person.alias()))
-    return message
-
-def _get_message_subject(course, message, original, person,
-                         respond=None, use_color=None):
+    if msg is None:
+        raise _UnsignedMessage(message=message)
+    return msg
+
+def _get_message_subject(message, use_color=None):
     """
     >>> from email.header import Header
     >>> from pgp_mime.email import encodedMIMEText
     >>> message = encodedMIMEText('The answer is 42.')
     >>> message['Message-ID'] = 'msg-id'
-    >>> _get_message_subject(
-    ...     course=None, message=message, original=message, person=None)
+    >>> _get_message_subject(message=message)
     Traceback (most recent call last):
       ...
-    ValueError: no subject in msg-id
+    pygrader.mailpipe.SubjectlessMessage: no subject
     >>> del message['Subject']
     >>> subject = Header('unicode part', 'utf-8')
     >>> subject.append('-ascii part', 'ascii')
     >>> message['Subject'] = subject.encode()
-    >>> _get_message_subject(
-    ...     course=None, message=message, original=message, person=None)
+    >>> _get_message_subject(message=message)
     'unicode part-ascii part'
     >>> del message['Subject']
     >>> message['Subject'] = 'clean subject'
-    >>> _get_message_subject(
-    ...     course=None, message=message, original=message, person=None)
+    >>> _get_message_subject(message=message)
     'clean subject'
     """
     if message['Subject'] is None:
-        mid = message['Message-ID']
-        response_subject = 'no subject in {}'.format(mid)
-        if respond:
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'We received an email message from you without a subject.'
-                    ),
-                respond=respond)
-        raise ValueError(response_subject)
+        raise SubjectlessMessage(subject=None, message=message)
 
     parts = _decode_header(message['Subject'])
     part_strings = []
@@ -715,80 +732,41 @@ def _get_message_subject(course, message, original, person,
     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
     return subject.lower().replace('#', '')
 
-def _get_message_target(course, message, original, person, subject,
-                        respond=None, use_color=None):
+def _get_message_target(subject, use_color=None):
     """
-    >>> _get_message_target(course=None, message=None, original=None,
-    ...     person=None, subject='no tag')
+    >>> _get_message_target(subject='no tag')
     Traceback (most recent call last):
       ...
-    ValueError: no tag in 'no tag'
-    >>> _get_message_target(course=None, message=None, original=None,
-    ...     person=None, subject='[] empty tag')
+    pygrader.handler.InvalidSubjectMessage: no tag in 'no tag'
+    >>> _get_message_target(subject='[] empty tag')
     Traceback (most recent call last):
       ...
-    ValueError: empty tag in '[] empty tag'
-    >>> _get_message_target(course=None, message=None, original=None,
-    ...     person=None, subject='[abc] empty tag')
+    pygrader.handler.InvalidSubjectMessage: empty tag in '[] empty tag'
+    >>> _get_message_target(subject='[abc] empty tag')
     'abc'
-    >>> _get_message_target(course=None, message=None, original=None,
-    ...     person=None, subject='[phys160:abc] empty tag')
+    >>> _get_message_target(subject='[phys160:abc] empty tag')
     'abc'
     """
     match = _TAG_REGEXP.match(subject)
     if match is None:
-        response_subject = 'no tag in {!r}'.format(subject)
-        if respond:
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                        'We received an email message from you without\n'
-                        'subject tags.'),
-                respond=respond)
-        raise ValueError(response_subject)
+        raise _InvalidSubjectMessage(
+            subject=subject, error='no tag in {!r}'.format(subject))
     tag = match.group(1)
     if tag == '':
-        response_subject = 'empty tag in {!r}'.format(subject)
-        if respond:
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                        'We received an email message from you with empty\n'
-                        'subject tags.'),
-                respond=respond)
-        raise ValueError(response_subject)    
+        raise _InvalidSubjectMessage(
+            subject=subject, error='empty tag in {!r}'.format(subject))
     target = tag.rsplit(':', 1)[-1]
     _LOG.debug('extracted target {} -> {}'.format(subject, target))
     return target
 
-def _get_handler(course, handlers, message, person, subject, target,
-                 respond=None, use_color=None):
+def _get_handler(handlers, target, use_color=None):
     try:
         handler = handlers[target]
-    except KeyError: 
+    except KeyError:
         response_subject = 'no handler for {}'.format(target)
         highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
         _LOG.debug(_color_string(string=response_subject, color=bad))
-        if respond:
-            targets = sorted(handlers.keys())
-            if not targets:
-                hint = (
-                    'In fact, there are no available handlers for this\n'
-                    'course!\n')
-            else:
-                hint = (
-                    'Perhaps you meant to use one of the following:\n'
-                    '  {}\n\n').format('\n  '.join(targets))
-            _respond(
-                course=course, person=person, original=original,
-                subject=response_subject, text=(
-                    'We got an email from you with the following subject:\n'
-                    '  {!r}\n'
-                    'which does not match any submittable handler name for\n'
-                    '{}.\n'
-                    '{}').format(repr(subject), course.name, hint),
-                respond=respond)
-        return None
+        raise InvalidHandlerMessage(target=target, handlers=handlers)
     return handler
 
 def _get_verified_message(message, pgp_key, use_color=None):
@@ -844,7 +822,7 @@ def _get_verified_message(message, pgp_key, use_color=None):
     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
     mid = message['message-id']
     try:
-        decrypted,verified,result = _verify(message=message)
+        decrypted,verified,result = _pgp_mime.verify(message=message)
     except (ValueError, AssertionError):
         _LOG.warn(_color_string(
                 string='could not verify {} (not signed?)'.format(mid),
@@ -882,3 +860,76 @@ def _get_verified_message(message, pgp_key, use_color=None):
             decrypted[k] = v
     decrypted.authenticated = True
     return decrypted
+
+def _get_error_response(error):
+    author = error.course.robot
+    target = getattr(error, 'person', None)
+    subject = str(error)
+    if isinstance(error, InvalidHandlerMessage):
+        targets = sorted(error.handlers.keys())
+        if not targets:
+            hint = (
+                'In fact, there are no available handlers for this\n'
+                'course!')
+        else:
+            hint = (
+                'Perhaps you meant to use one of the following:\n'
+                '  {}').format('\n  '.join(targets))
+        text = (
+            'We got an email from you with the following subject:\n'
+            '  {!r}\n'
+            'which does not match any submittable handler name for\n'
+            '{}.\n'
+            '{}').format(repr(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.'
+    elif isinstance(error, AmbiguousAddress):
+        text = (
+            'Multiple people match {} ({})'.format(
+                error.address, ', '.join(p.name for p in error.people)))
+    elif isinstance(error, UnregisteredAddress):
+        target = _Person(name=error.address, emails=[error.address])
+        text = (
+            'Your email address is not registered with pygrader for\n'
+            '{}.  If you feel it should be, contact your professor\n'
+            'or TA.').format(error.course.name)
+    elif isinstance(error, NoReturnPath):
+        return
+    elif isinstance(error, _InvalidSubjectMessage):
+        text = (
+            'We received an email message from you with an invalid\n'
+            'subject.')
+    elif isinstance(error, _UnsignedMessage):
+        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):
+        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, _InvalidMessage):
+        text = subject
+    else:
+        raise NotImplementedError((type(error), error))
+    if target is None:
+        raise NotImplementedError((type(error), error))
+    return _construct_response(
+        author=author,
+        targets=[target],
+        subject=subject,
+        text=(
+            '{},\n\n'
+            '{}\n\n'
+            'Yours,\n'
+            '{}\n'.format(target.alias(), text, author.alias())),
+        original=error.message)
index 66bd60603dd0d33be43406a4357aed47fe66cb83..fb307a8a68b111668c1d70a197a5ec2032d90f6a 100644 (file)
@@ -19,7 +19,7 @@ import io as _io
 from jinja2 import Template
 
 from . import LOG as _LOG
-from .email import construct_email as _construct_email
+from .email import construct_text_email as _construct_text_email
 from .email import send_emails as _send_emails
 from .storage import set_notified as _set_notified
 from .tabulate import tabulate as _tabulate
@@ -188,7 +188,7 @@ def construct_assignment_email(author, grade, cc=None):
     Yours,
     Jack
     """
-    return _construct_email(
+    return _construct_text_email(
         author=author, targets=[grade.student], cc=cc,
         subject='Your {} grade'.format(grade.assignment.name),
         text=ASSIGNMENT_TEMPLATE.render(author=author, grade=grade))
@@ -372,7 +372,7 @@ def construct_student_email(author, course, grades, targets=None, cc=None):
     else:
         subject += ' for {}'.format(student.name)
     target = join_with_and([t.alias() for t in targets])
-    return _construct_email(
+    return _construct_text_email(
         author=author, targets=targets, cc=cc, subject=subject,
         text=STUDENT_TEMPLATE.render(
             author=author, target=target, grades=sorted(grades)))
@@ -451,7 +451,7 @@ def construct_course_email(author, course, targets, cc=None):
     target = join_with_and([t.alias() for t in targets])
     table = _io.StringIO()
     _tabulate(course=course, statistics=True, stream=table)
-    return _construct_email(
+    return _construct_text_email(
         author=author, targets=targets, cc=cc,
         subject='Course grades',
         text=COURSE_TEMPLATE.render(