mailpipe: replace `respond` callback with exceptions.
[pygrader.git] / pygrader / mailpipe.py
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)