1 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
3 # This file is part of pygrader.
5 # pygrader is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
10 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License along with
15 # pygrader. If not, see <http://www.gnu.org/licenses/>.
17 from __future__ import absolute_import
19 from email import message_from_file as _message_from_file
20 from email.header import decode_header as _decode_header
21 from email.utils import formatdate as _formatdate
22 import hashlib as _hashlib
23 import locale as _locale
24 import mailbox as _mailbox
26 import os.path as _os_path
30 from pgp_mime import verify as _verify
31 from lxml import etree as _etree
33 from . import LOG as _LOG
34 from .color import standard_colors as _standard_colors
35 from .color import color_string as _color_string
36 from .email import construct_response as _construct_response
37 from .extract_mime import extract_mime as _extract_mime
38 from .extract_mime import message_time as _message_time
39 from .model.person import Person as _Person
40 from .storage import assignment_path as _assignment_path
41 from .storage import set_late as _set_late
44 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
45 output=None, max_late=0, respond=None, use_color=None,
46 dry_run=False, **kwargs):
47 """Run from procmail to sort incomming submissions
49 For example, you can setup your ``.procmailrc`` like this::
55 LOGFILE=$MAILDIR/procmail.log
57 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
59 # Grab all incoming homeworks emails. This rule eats matching emails
60 # (i.e. no further procmail processing).
62 * ^Subject:.*\[phys160-sub]
63 | "$PYGRADE_MAILPIPE" mailpipe
65 If you don't want procmail to eat the message, you can use the
66 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
68 >>> from asyncore import loop
69 >>> from io import StringIO
70 >>> from pgp_mime.email import encodedMIMEText
71 >>> from pygrader.test.course import StubCourse
72 >>> from pygrader.test.client import MessageSender
73 >>> from pygrader.test.server import SMTPServer
75 Messages with unrecognized ``Return-Path``\s are silently dropped:
77 >>> course = StubCourse()
78 >>> def process(peer, mailfrom, rcpttos, data):
80 ... basedir=course.basedir, course=course.course,
81 ... stream=StringIO(data), output=course.mailbox)
82 >>> message = encodedMIMEText('The answer is 42.')
83 >>> message['Message-ID'] = '<123.456@home.net>'
84 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
85 >>> message['Received'] = (
86 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
87 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
88 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
89 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
90 >>> message['To'] = 'phys101 <phys101@tower.edu>'
91 >>> message['Subject'] = 'assignment 1 submission'
92 >>> messages = [message]
93 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
95 >>> course.print_tree() # doctest: +REPORT_UDIFF
98 If we add a valid ``Return-Path``, we get the expected delivery:
100 >>> server = SMTPServer(
101 ... ('localhost', 1025), None, process=process, count=1)
102 >>> del message['Return-Path']
103 >>> message['Return-Path'] = '<bb@greyhavens.net>'
104 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
106 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
108 Bilbo_Baggins/Assignment_1
109 Bilbo_Baggins/Assignment_1/mail
110 Bilbo_Baggins/Assignment_1/mail/cur
111 Bilbo_Baggins/Assignment_1/mail/new
112 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
113 Bilbo_Baggins/Assignment_1/mail/tmp
121 The last ``Received`` is used to timestamp the message:
123 >>> server = SMTPServer(
124 ... ('localhost', 1025), None, process=process, count=1)
125 >>> del message['Message-ID']
126 >>> message['Message-ID'] = '<abc.def@home.net>'
127 >>> del message['Received']
128 >>> message['Received'] = (
129 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
130 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
131 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
132 >>> message['Received'] = (
133 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
134 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
135 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
136 >>> messages = [message]
137 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
139 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
141 Bilbo_Baggins/Assignment_1
142 Bilbo_Baggins/Assignment_1/late
143 Bilbo_Baggins/Assignment_1/mail
144 Bilbo_Baggins/Assignment_1/mail/cur
145 Bilbo_Baggins/Assignment_1/mail/new
146 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
147 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
148 Bilbo_Baggins/Assignment_1/mail/tmp
157 You can send receipts to the acknowledge incoming messages, which
158 includes warnings about dropped messages (except for messages
159 without ``Return-Path`` and messages where the ``Return-Path``
160 email belongs to multiple ``People``. Both of these cases should
161 only come from problems with pygrader configuration).
163 Response to a successful submission:
165 >>> def respond(message):
166 ... print('respond with:\\n{}'.format(message.as_string()))
167 >>> def process(peer, mailfrom, rcpttos, data):
169 ... basedir=course.basedir, course=course.course,
170 ... stream=StringIO(data), output=course.mailbox,
172 >>> server = SMTPServer(
173 ... ('localhost', 1025), None, process=process, count=1)
174 >>> del message['Message-ID']
175 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
176 >>> messages = [message]
177 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
178 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
180 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
182 Content-Disposition: inline
184 From: Robot101 <phys101@tower.edu>
185 Reply-to: Robot101 <phys101@tower.edu>
186 To: Bilbo Baggins <bb@shire.org>
187 Subject: received Assignment 1 submission
189 --===============...==
190 Content-Type: multipart/mixed; boundary="===============...=="
193 --===============...==
194 Content-Type: text/plain; charset="us-ascii"
196 Content-Transfer-Encoding: 7bit
197 Content-Disposition: inline
201 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
205 --===============...==
206 Content-Type: message/rfc822
209 Content-Type: text/plain; charset="us-ascii"
211 Content-Transfer-Encoding: 7bit
212 Content-Disposition: inline
213 From: Billy B <bb@greyhavens.net>
214 To: phys101 <phys101@tower.edu>
215 Subject: assignment 1 submission
216 Return-Path: <bb@greyhavens.net>
217 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)
218 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)
219 Message-ID: <hgi.jlk@home.net>
222 --===============...==--
223 --===============...==
225 Content-Transfer-Encoding: 7bit
226 Content-Description: OpenPGP digital signature
227 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
229 -----BEGIN PGP SIGNATURE-----
230 Version: GnuPG v2.0.17 (GNU/Linux)
233 -----END PGP SIGNATURE-----
235 --===============...==--
237 Response to a submission on an unsubmittable assignment:
239 >>> server = SMTPServer(
240 ... ('localhost', 1025), None, process=process, count=1)
241 >>> del message['Subject']
242 >>> message['Subject'] = 'attendance 1 submission'
243 >>> messages = [message]
244 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
245 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
247 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
249 Content-Disposition: inline
251 From: Robot101 <phys101@tower.edu>
252 Reply-to: Robot101 <phys101@tower.edu>
253 To: Bilbo Baggins <bb@shire.org>
254 Subject: received invalid Attendance 1 submission
256 --===============...==
257 Content-Type: multipart/mixed; boundary="===============...=="
260 --===============...==
261 Content-Type: text/plain; charset="us-ascii"
263 Content-Transfer-Encoding: 7bit
264 Content-Disposition: inline
268 We received your submission for Attendance 1, but you are not allowed
269 to submit that assignment via email.
273 --===============...==
274 Content-Type: message/rfc822
277 Content-Type: text/plain; charset="us-ascii"
279 Content-Transfer-Encoding: 7bit
280 Content-Disposition: inline
281 From: Billy B <bb@greyhavens.net>
282 To: phys101 <phys101@tower.edu>
283 Return-Path: <bb@greyhavens.net>
284 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)
285 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)
286 Message-ID: <hgi.jlk@home.net>
287 Subject: attendance 1 submission
290 --===============...==--
291 --===============...==
293 Content-Transfer-Encoding: 7bit
294 Content-Description: OpenPGP digital signature
295 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
297 -----BEGIN PGP SIGNATURE-----
298 Version: GnuPG v2.0.17 (GNU/Linux)
301 -----END PGP SIGNATURE-----
303 --===============...==--
305 Response to a bad subject:
307 >>> server = SMTPServer(
308 ... ('localhost', 1025), None, process=process, count=1)
309 >>> del message['Subject']
310 >>> message['Subject'] = 'need help for the first homework'
311 >>> messages = [message]
312 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
313 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
315 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
317 Content-Disposition: inline
319 From: Robot101 <phys101@tower.edu>
320 Reply-to: Robot101 <phys101@tower.edu>
321 To: Bilbo Baggins <bb@shire.org>
322 Subject: received 'need help for the first homework'
324 --===============...==
325 Content-Type: multipart/mixed; boundary="===============...=="
328 --===============...==
329 Content-Type: text/plain; charset="us-ascii"
331 Content-Transfer-Encoding: 7bit
332 Content-Disposition: inline
336 We got an email from you with the following subject:
337 'need help for the first homework'
338 which does not match any submittable assignment name for
340 Remember to use the full name for the assignment in the
341 subject. For example:
342 Assignment 1 submission
346 --===============...==
347 Content-Type: message/rfc822
350 Content-Type: text/plain; charset="us-ascii"
352 Content-Transfer-Encoding: 7bit
353 Content-Disposition: inline
354 From: Billy B <bb@greyhavens.net>
355 To: phys101 <phys101@tower.edu>
356 Return-Path: <bb@greyhavens.net>
357 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)
358 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)
359 Message-ID: <hgi.jlk@home.net>
360 Subject: need help for the first homework
363 --===============...==--
364 --===============...==
366 Content-Transfer-Encoding: 7bit
367 Content-Description: OpenPGP digital signature
368 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
370 -----BEGIN PGP SIGNATURE-----
371 Version: GnuPG v2.0.17 (GNU/Linux)
374 -----END PGP SIGNATURE-----
376 --===============...==--
378 >>> del message['Return-Path']
379 >>> message['Return-Path'] = '<bb@greyhavens.net>'
385 for msg,person,assignment,time in _load_messages(
386 course=course, stream=stream, mailbox=mailbox, input_=input_,
387 output=output, respond=respond, use_color=use_color, dry_run=dry_run):
388 assignment_path = _assignment_path(basedir, assignment, person)
389 _save_local_message_copy(
390 msg=msg, person=person, assignment_path=assignment_path,
391 use_color=use_color, dry_run=dry_run)
392 _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
394 basedir=basedir, assignment=assignment, person=person, time=time,
395 max_late=max_late, use_color=use_color, dry_run=dry_run)
397 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
398 respond=None, use_color=None, dry_run=False):
401 messages = [(None,_message_from_file(stream))]
402 if output is not None:
403 ombox = _mailbox.Maildir(output, factory=None, create=True)
404 elif mailbox == 'mbox':
405 mbox = _mailbox.mbox(input_, factory=None, create=False)
406 messages = mbox.items()
407 if output is not None:
408 ombox = _mailbox.mbox(output, factory=None, create=True)
409 elif mailbox == 'maildir':
410 mbox = _mailbox.Maildir(input_, factory=None, create=False)
411 messages = mbox.items()
412 if output is not None:
413 ombox = _mailbox.Maildir(output, factory=None, create=True)
415 raise ValueError(mailbox)
416 for key,msg in messages:
417 ret = _parse_message(
418 course=course, msg=msg, respond=respond, use_color=use_color)
420 if output is not None and dry_run is False:
421 # move message from input mailbox to output mailbox
427 def _parse_message(course, msg, respond=None, use_color=None):
428 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
429 mid = msg['Message-ID']
430 sender = msg['Return-Path'] # RFC 822
432 _LOG.debug(_color_string(
433 string='no Return-Path in {}'.format(mid), color=lowlight))
435 sender = sender[1:-1] # strip wrapping '<' and '>'
436 time = _message_time(message=msg, use_color=use_color)
440 time_str = _formatdate(time)
442 time_str = 'unknown time'
443 response_subject = 'received {} at {}'.format(mid, time_str)
445 people = list(course.find_people(email=sender))
447 _LOG.warn(_color_string(
448 string='no person found to match {}'.format(sender),
451 person = _Person(name=None, emails=[sender])
454 'Your email address is not registered with pygrader for\n'
455 '{}. If you feel it should be, contact your professor\n'
457 'Yours,\n{}').format(
458 sender, course.robot.alias())
459 response_text = 'Address {} is not registered for {}.'.format(
461 response = _construct_response(
462 author=course.robot, targets=[person],
463 subject=response_subject, text=response_text, original=msg)
467 _LOG.warn(_color_string(
468 string='multiple people match {} ({})'.format(
469 sender, ', '.join(str(p) for p in people)),
475 msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
480 'We received an email message from you without a valid\n'
482 'Yours,\n{}').format(
483 person.alias(), course.robot.alias())
484 response_text = 'Message not signed by {}.'.format(
486 response = _construct_response(
487 author=course.robot, targets=[person],
488 subject=response_subject, text=response_text, original=msg)
492 if msg['Subject'] is None:
493 _LOG.warn(_color_string(
494 string='no subject in {}'.format(mid), color=bad))
498 'We received an email message from you without a subject.\n\n'
499 'Yours,\n{}').format(
500 person.alias(), course.robot.alias())
501 response = _construct_response(
502 author=course.robot, targets=[person],
503 subject=response_subject, text=response_text, original=msg)
506 parts = _decode_header(msg['Subject'])
508 _LOG.warn(_color_string(
509 string='multi-part header {}'.format(parts), color=bad))
511 subject,encoding = parts[0]
514 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
516 subject = subject.lower().replace('#', '')
517 for assignment in course.assignments:
518 if _match_assignment(assignment, subject):
520 if not _match_assignment(assignment, subject):
521 _LOG.warn(_color_string(
522 string='no assignment found in {}'.format(repr(subject)),
525 response_subject = "received '{}'".format(subject)
526 submittable_assignments = [
527 a for a in course.assignments if a.submittable]
528 if not submittable_assignments:
530 'In fact, there are no submittable assignments for\n'
534 'Remember to use the full name for the assignment in the\n'
535 'subject. For example:\n'
536 ' {} submission\n\n').format(
537 submittable_assignments[0].name)
540 'We got an email from you with the following subject:\n'
542 'which does not match any submittable assignment name for\n'
545 'Yours,\n{}').format(
546 person.alias(), repr(subject), course.name, hint,
547 course.robot.alias())
548 response = _construct_response(
549 author=course.robot, targets=[person],
550 subject=response_subject, text=response_text, original=msg)
554 if not assignment.submittable:
555 response_subject = 'received invalid {} submission'.format(
559 'We received your submission for {}, but you are not allowed\n'
560 'to submit that assignment via email.\n\n'
561 'Yours,\n{}').format(
562 person.alias(), assignment.name, course.robot.alias())
563 response = _construct_response(
564 author=course.robot, targets=[person],
565 subject=response_subject, text=response_text, original=msg)
569 response_subject = 'received {} submission'.format(assignment.name)
572 'We received your submission for {} on {}.\n\n'
573 'Yours,\n{}').format(
574 person.alias(), assignment.name, time_str, course.robot.alias())
575 response = _construct_response(
576 author=course.robot, targets=[person],
577 subject=response_subject, text=response_text, original=msg)
579 return (msg, person, assignment, time)
581 def _match_assignment(assignment, subject):
582 return assignment.name.lower() in subject
584 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
586 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
588 _os.makedirs(assignment_path)
591 mpath = _os_path.join(assignment_path, 'mail')
593 mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
594 except _mailbox.NoSuchMailboxError as e:
595 _LOG.debug(_color_string(
596 string='could not open mailbox at {}'.format(mpath),
602 for other_msg in mbox:
603 if other_msg['Message-ID'] == msg['Message-ID']:
607 _LOG.debug(_color_string(
608 string='saving email from {} to {}'.format(
609 person, assignment_path), color=good))
610 if mbox is not None and not dry_run:
611 mdmsg = _mailbox.MaildirMessage(msg)
616 _LOG.debug(_color_string(
617 string='already found {} in {}'.format(
618 msg['Message-ID'], mpath), color=good))
620 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
622 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
623 if time > assignment.due + max_late:
624 dt = time - assignment.due
625 _LOG.warn(_color_string(
626 string='{} {} late by {} seconds ({} hours)'.format(
627 person.name, assignment.name, dt, dt/3600.),
630 _set_late(basedir=basedir, assignment=assignment, person=person)
632 def _get_verified_message(message, pgp_key, use_color=None):
635 >>> from pgp_mime import sign, encodedMIMEText
637 The student composes a message...
639 >>> message = encodedMIMEText('1.23 joules')
641 ... and signs it (with the pgp-mime test key).
643 >>> signed = sign(message, signers=['pgp-mime-test'])
645 As it is being delivered, the message picks up extra headers.
647 >>> signed['Message-ID'] = '<01234567@home.net>'
648 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
649 >>> signed['Received'] = 'from smtp.home.net ...'
651 We check that the message is signed, and that it is signed by the
654 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
655 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
656 Content-Type: text/plain; charset="us-ascii"
658 Content-Transfer-Encoding: 7bit
659 Content-Disposition: inline
660 Message-ID: <01234567@home.net>
661 Received: from smtp.mail.uu.edu ...
662 Received: from smtp.home.net ...
666 If it is signed, but not by the right key, we get ``None``.
668 >>> print(_get_verified_message(signed, pgp_key='01234567'))
671 If it is not signed at all, we get ``None``.
673 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
676 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
677 mid = message['message-id']
679 decrypted,verified,result = _verify(message=message)
680 except (ValueError, AssertionError):
681 _LOG.warn(_color_string(
682 string='could not verify {} (not signed?)'.format(mid),
685 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
686 tree = _etree.fromstring(result.replace(b'\x00', b''))
688 for signature in tree.findall('.//signature'):
689 for fingerprint in signature.iterchildren('fpr'):
690 if fingerprint.text.endswith(pgp_key):
694 _LOG.warn(_color_string(
695 string='{} is not signed by the expected key'.format(mid),
699 sumhex = list(signature.iterchildren('summary'))[0].get('value')
700 summary = int(sumhex, 16)
702 _LOG.warn(_color_string(
703 string='{} has an unverified signature'.format(mid),
706 # otherwise, we may have an untrusted key. We'll count that
707 # as verified here, because the caller is explicity looking
708 # for signatures by this fingerprint.
709 for k,v in message.items(): # copy over useful headers
710 if k.lower() not in ['content-type',
712 'content-disposition',