From: W. Trevor King Date: Tue, 24 Apr 2012 21:00:30 +0000 (-0400) Subject: Initial `respond` implementation in the `mailpipe` module. X-Git-Tag: v0.2~6 X-Git-Url: http://git.tremily.us/?p=pygrader.git;a=commitdiff_plain;h=b1ee5dc75f6f635002c6edb1c4264ebec192b2f5 Initial `respond` implementation in the `mailpipe` module. --- diff --git a/pygrader/mailpipe.py b/pygrader/mailpipe.py index 57ef2ff..05a01fc 100644 --- a/pygrader/mailpipe.py +++ b/pygrader/mailpipe.py @@ -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 ; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)') + ... 'for ; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)') >>> message['From'] = 'Billy B ' - >>> message['To'] = 'S ' + >>> message['To'] = 'phys101 ' >>> 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'] = '' + >>> 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 + Reply-to: Robot101 + To: Bilbo Baggins + Subject: received Assignment 1 submission + + --===============...== + Content-Type: multipart/mixed; boundary="===============...==" + MIME-Version: 1.0 + + --===============...== + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + + Billy, + + We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000. + + Yours, + phys-101 robot + --===============...== + Content-Type: message/rfc822 + MIME-Version: 1.0 + + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + From: Billy B + To: phys101 + Subject: assignment 1 submission + Return-Path: + Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for ; 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 ; Mon, 09 Oct 2011 11:50:46 -0400 (EDT) + Message-ID: + + 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" + + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v2.0.17 (GNU/Linux) + + ... + -----END PGP SIGNATURE----- + + --===============...==-- + + 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 + Reply-to: Robot101 + To: Bilbo Baggins + Subject: received 'need help for the first homework' + + --===============...== + Content-Type: multipart/mixed; boundary="===============...==" + MIME-Version: 1.0 + + --===============...== + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + + Billy, + + 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 + + Yours, + phys-101 robot + --===============...== + Content-Type: message/rfc822 + MIME-Version: 1.0 + + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + From: Billy B + To: phys101 + Return-Path: + Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for ; 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 ; Mon, 09 Oct 2011 11:50:46 -0400 (EDT) + Message-ID: + Subject: need help for the first homework + + 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" + + -----BEGIN PGP SIGNATURE----- + Version: GnuPG v2.0.17 (GNU/Linux) + + ... + -----END PGP SIGNATURE----- + + --===============...==-- + + >>> del message['Return-Path'] + >>> message['Return-Path'] = '' + >>> 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):