Initial `respond` implementation in the `mailpipe` module.
authorW. Trevor King <wking@tremily.us>
Tue, 24 Apr 2012 21:00:30 +0000 (17:00 -0400)
committerW. Trevor King <wking@tremily.us>
Tue, 24 Apr 2012 21:09:26 +0000 (17:09 -0400)
pygrader/mailpipe.py

index 57ef2fff70cd87ecb6bc71e37008ed3b3a0444c0..05a01fc54a0447923bcab3cce1b04e53f38d31b7 100644 (file)
@@ -18,6 +18,7 @@ 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.utils import formatdate as _formatdate
 import hashlib as _hashlib
 import locale as _locale
 import mailbox as _mailbox
@@ -32,14 +33,17 @@ from lxml import etree as _etree
 from . import LOG as _LOG
 from .color import standard_colors as _standard_colors
 from .color import color_string as _color_string
+from .email import construct_response as _construct_response
 from .extract_mime import extract_mime as _extract_mime
 from .extract_mime import message_time as _message_time
+from .model.person import Person as _Person
 from .storage import assignment_path as _assignment_path
 from .storage import set_late as _set_late
 
 
 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
-             output=None, max_late=0, use_color=None, dry_run=False, **kwargs):
+             output=None, max_late=0, 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::
@@ -81,9 +85,9 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     >>> message['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)')
+    ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
     >>> message['From'] = 'Billy B <bb@greyhavens.net>'
-    >>> message['To'] = 'S <eye@tower.edu>'
+    >>> message['To'] = 'phys101 <phys101@tower.edu>'
     >>> message['Subject'] = 'assignment 1 submission'
     >>> messages = [message]
     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
@@ -150,13 +154,169 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
     mail/new/...
     mail/tmp
 
+    You can send receipts to the acknowledge incoming messages, which
+    includes warnings about dropped messages (except for messages
+    without ``Return-Path`` and messages where the ``Return-Path``
+    email belongs to multiple ``People``.  Both of these cases should
+    only come from problems with pygrader configuration).
+
+    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
+    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,
+    <BLANKLINE>
+    We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
+    <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: assignment 1 submission
+    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.17 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
+
+    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
+    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 'need help for the first homework'
+    <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,
+    <BLANKLINE>
+    We got an email from you with the following subject:
+      'need help for the first homework'
+    which does not match any submittable assignment name for
+    phys101.
+    Remember to use the full name for the assignment in the
+    subject.  For example:
+      Assignment 1 submission
+    <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>
+    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>
+    Subject: need help for the first homework
+    <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.17 (GNU/Linux)
+    <BLANKLINE>
+    ...
+    -----END PGP SIGNATURE-----
+    <BLANKLINE>
+    --===============...==--
+
+    >>> del message['Return-Path']
+    >>> message['Return-Path'] = '<bb@greyhavens.net>'
+
     >>> course.cleanup()
     """
     if stream is None:
         stream = _sys.stdin
     for msg,person,assignment,time in _load_messages(
         course=course, stream=stream, mailbox=mailbox, input_=input_,
-        output=output, use_color=use_color, dry_run=dry_run):
+        output=output, respond=respond, use_color=use_color, dry_run=dry_run):
         assignment_path = _assignment_path(basedir, assignment, person)
         _save_local_message_copy(
             msg=msg, person=person, assignment_path=assignment_path,
@@ -167,7 +327,7 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
             max_late=max_late, use_color=use_color, dry_run=dry_run)
 
 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
-                   use_color=None, dry_run=False):
+                   respond=None, use_color=None, dry_run=False):
     if mailbox is None:
         mbox = None
         messages = [(None,_message_from_file(stream))]
@@ -187,7 +347,7 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
         raise ValueError(mailbox)
     for key,msg in messages:
         ret = _parse_message(
-            course=course, msg=msg, use_color=use_color)
+            course=course, msg=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
@@ -196,7 +356,7 @@ def _load_messages(course, stream, mailbox=None, input_=None, output=None,
                     del mbox[key]
             yield ret
 
-def _parse_message(course, msg, use_color=None):
+def _parse_message(course, msg, respond=None, use_color=None):
     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
     mid = msg['Message-ID']
     sender = msg['Return-Path']  # RFC 822
@@ -205,12 +365,35 @@ def _parse_message(course, msg, use_color=None):
                 string='no Return-Path in {}'.format(mid), color=lowlight))
         return None
     sender = sender[1:-1]  # strip wrapping '<' and '>'
+    time = _message_time(message=msg, use_color=use_color)
+
+    if respond:
+        if time:
+            time_str = _formatdate(time)
+        else:
+            time_str = 'unknown time'
+        response_subject = 'received {} at {}'.format(mid, time_str)
 
     people = list(course.find_people(email=sender))
     if len(people) == 0:
         _LOG.warn(_color_string(
                 string='no person found to match {}'.format(sender),
                 color=bad))
+        if respond:
+            person = _Person(name=None, emails=[sender])
+            response_text = (
+                '{},\n\n'
+                'Your email address is not registered with pygrader for\n'
+                '{}.  If you feel it should be, contact your professor\n'
+                'or TA.\n\n'
+                'Yours,\n{}').format(
+                sender, course.robot.alias())
+            response_text = 'Address {} is not registered for {}.'.format(
+                sender, course.name)
+            response = _construct_response(
+                author=course.robot, targets=[person],
+                subject=response_subject, text=response_text, original=msg)
+            respond(response)
         return None
     if len(people) > 1:
         _LOG.warn(_color_string(
@@ -223,11 +406,34 @@ def _parse_message(course, msg, use_color=None):
     if person.pgp_key:
         msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
         if msg is None:
+            if respond:
+                response_text = (
+                    '{},\n\n'
+                    'We received an email message from you without a valid\n'
+                    'PGP signature.\n\n'
+                    'Yours,\n{}').format(
+                    person.alias(), course.robot.alias())
+                response_text = 'Message not signed by {}.'.format(
+                    person.pgp_key)
+                response = _construct_response(
+                    author=course.robot, targets=[person],
+                    subject=response_subject, text=response_text, original=msg)
+                respond(response)
             return None
 
     if msg['Subject'] is None:
         _LOG.warn(_color_string(
                 string='no subject in {}'.format(mid), color=bad))
+        if respond:
+            response_text = (
+                '{},\n\n'
+                'We received an email message from you without a subject.\n\n'
+                'Yours,\n{}').format(
+                person.alias(), course.robot.alias())
+            response = _construct_response(
+                author=course.robot, targets=[person],
+                subject=response_subject, text=response_text, original=msg)
+            respond(response)
         return None
     parts = _decode_header(msg['Subject'])
     if len(parts) != 1:
@@ -238,6 +444,7 @@ def _parse_message(course, msg, use_color=None):
     if encoding is None:
         encoding = 'ascii'
     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
+
     subject = subject.lower().replace('#', '')
     for assignment in course.assignments:
         if _match_assignment(assignment, subject):
@@ -246,9 +453,47 @@ def _parse_message(course, msg, use_color=None):
         _LOG.warn(_color_string(
                 string='no assignment found in {}'.format(repr(subject)),
                 color=bad))
+        if respond:
+            response_subject = "received '{}'".format(subject)
+            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!\n')
+            else:
+                hint = (
+                    'Remember to use the full name for the assignment in the\n'
+                    'subject.  For example:\n'
+                    '  {} submission\n\n').format(
+                    submittable_assignments[0].name)
+            response_text = (
+                '{},\n\n'
+                'We got an email from you with the following subject:\n'
+                '  {}\n'
+                'which does not match any submittable assignment name for\n'
+                '{}.\n'
+                '{}'
+                'Yours,\n{}').format(
+                person.alias(), repr(subject), course.name, hint,
+                course.robot.alias())
+            response = _construct_response(
+                author=course.robot, targets=[person],
+                subject=response_subject, text=response_text, original=msg)
+            respond(response)
         return None
 
-    time = _message_time(message=msg, use_color=use_color)
+    if respond:
+        response_subject = 'received {} submission'.format(assignment.name)
+        response_text = (
+            '{},\n\n'
+            'We received your submission for {} on {}.\n\n'
+            'Yours,\n{}').format(
+            person.alias(), assignment.name, time_str, course.robot.alias())
+        response = _construct_response(
+            author=course.robot, targets=[person],
+            subject=response_subject, text=response_text, original=msg)
+        respond(response)
     return (msg, person, assignment, time)
 
 def _match_assignment(assignment, subject):