From cabf93c6a43b43ab2702123ffe332d1c15ce9738 Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Sat, 1 Sep 2012 15:25:58 -0400 Subject: [PATCH] mailpipe: replace `respond` callback with exceptions. 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 | 14 +- pygrader/handler/__init__.py | 53 ++- pygrader/handler/get.py | 233 +++++------ pygrader/handler/submission.py | 190 ++++----- pygrader/mailpipe.py | 681 ++++++++++++++++++--------------- pygrader/template.py | 8 +- 6 files changed, 584 insertions(+), 595 deletions(-) diff --git a/pygrader/email.py b/pygrader/email.py index aeb4da8..3a46366 100644 --- a/pygrader/email.py +++ b/pygrader/email.py @@ -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): """ 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) diff --git a/pygrader/handler/__init__.py b/pygrader/handler/__init__.py index 698338a..1837721 100644 --- a/pygrader/handler/__init__.py +++ b/pygrader/handler/__init__.py @@ -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 diff --git a/pygrader/handler/get.py b/pygrader/handler/get.py index 5e58dee..c5797fa 100644 --- a/pygrader/handler/get.py +++ b/pygrader/handler/get.py @@ -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 - 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 - Reply-to: Robot101 - To: Bilbo Baggins - Subject: no grades for Billy - - --===============...== - 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, + Subject: No grades for Billy We don't have any of your grades on file for this course. - - Yours, - phys-101 robot - --===============...== - Content-Type: message/rfc822 - MIME-Version: 1.0 - + + >>> 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> - - 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 - -----BEGIN PGP SIGNATURE----- - Version: GnuPG v2.0.19 (GNU/Linux) - - ... - -----END PGP SIGNATURE----- - - --===============...==-- - - got error: no grades for + 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 ; 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 diff --git a/pygrader/handler/submission.py b/pygrader/handler/submission.py index 7d16568..9021da2 100644 --- a/pygrader/handler/submission.py +++ b/pygrader/handler/submission.py @@ -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 ; 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 - 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, + Subject: Received Assignment 1 submission We received your submission for Assignment 1 on .... - - 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 - 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 ; Sun, 09 Oct 2011 11:50:46 -0400 (EDT) - - 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.19 (GNU/Linux) - - ... - -----END PGP SIGNATURE----- - - --===============...==-- + >>> 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) diff --git a/pygrader/mailpipe.py b/pygrader/mailpipe.py index a01c696..053bf41 100644 --- a/pygrader/mailpipe.py +++ b/pygrader/mailpipe.py @@ -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'] = '' >>> 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 ' >>> message['To'] = 'phys101 ' >>> 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'] = '' + >>> 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 + Reply-to: Robot101 + To: "invalid.return.path@home.net" + Subject: unregistered address invalid.return.path@home.net + + --===============...== + 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 + + invalid.return.path@home.net, + + Your email address is not registered with pygrader for + Physics 101. If you feel it should be, contact your professor + or TA. + + 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 + 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 ; Sun, 09 Oct 2011 11:50:46 -0400 (EDT) + From: Billy B + To: phys101 + Subject: [submit] assignment 1 + Return-Path: + + 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.19 (GNU/Linux) + + ... + -----END PGP SIGNATURE----- + + --===============...==-- 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'] = '' - >>> 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 + + Billy, + + We received your submission for Assignment 1 on Sun, 09 Oct 2011 15:50:46 -0000. + + Yours, + phys-101 robot + + >>> 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'] = '' >>> 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 ; 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 + + Billy, + + We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000. + + Yours, + phys-101 robot + >>> 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'] = '' - >>> 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 - 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 + Content-Transfer-Encoding: 7bit Billy, @@ -205,47 +318,13 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None, 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: [submit] assignment 1 - 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.19 (GNU/Linux) - - ... - -----END PGP SIGNATURE----- - - --===============...==-- 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 Reply-to: Robot101 To: Bilbo Baggins - Subject: received invalid Attendance 1 submission + Subject: Received invalid Attendance 1 submission --===============...== Content-Type: multipart/mixed; boundary="===============...==" @@ -273,6 +352,7 @@ def mailpipe(basedir, course, stream=None, mailbox=None, input_=None, Yours, phys-101 robot + --===============...== 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, Billy, - We received an email message from you without - subject tags. + We received an email message from you with an invalid + subject. Yours, phys-101 robot + --===============...== 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, Yours, phys-101 robot + --===============...== 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, --===============...==-- - Response to a message from an unregistered person: - - >>> server = SMTPServer( - ... ('localhost', 1025), None, process=process, count=1) - >>> del message['Return-Path'] - >>> message['Return-Path'] = '' - >>> 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: "invalid.return.path@home.net" - Subject: unregistered address invalid.return.path@home.net - - --===============...== - 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 - - invalid.return.path@home.net, - - Your email address is not registered with pygrader for - Physics 101. If you feel it should be, contact your professor - or TA. - - 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 - 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: - Return-Path: - - 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.19 (GNU/Linux) - - ... - -----END PGP SIGNATURE----- - - --===============...==-- - >>> 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) diff --git a/pygrader/template.py b/pygrader/template.py index 66bd606..fb307a8 100644 --- a/pygrader/template.py +++ b/pygrader/template.py @@ -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( -- 2.26.2