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.19 (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.19 (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.19 (GNU/Linux)
374 -----END PGP SIGNATURE-----
376 --===============...==--
378 Response to a missing subject:
380 >>> server = SMTPServer(
381 ... ('localhost', 1025), None, process=process, count=1)
382 >>> del message['Subject']
383 >>> messages = [message]
384 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
385 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
387 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
389 Content-Disposition: inline
391 From: Robot101 <phys101@tower.edu>
392 Reply-to: Robot101 <phys101@tower.edu>
393 To: Bilbo Baggins <bb@shire.org>
394 Subject: no subject in <hgi.jlk@home.net>
396 --===============...==
397 Content-Type: multipart/mixed; boundary="===============...=="
400 --===============...==
401 Content-Type: text/plain; charset="us-ascii"
403 Content-Transfer-Encoding: 7bit
404 Content-Disposition: inline
408 We received an email message from you without a subject.
412 --===============...==
413 Content-Type: message/rfc822
416 Content-Type: text/plain; charset="us-ascii"
418 Content-Transfer-Encoding: 7bit
419 Content-Disposition: inline
420 From: Billy B <bb@greyhavens.net>
421 To: phys101 <phys101@tower.edu>
422 Return-Path: <bb@greyhavens.net>
423 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)
424 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)
425 Message-ID: <hgi.jlk@home.net>
428 --===============...==--
429 --===============...==
431 Content-Transfer-Encoding: 7bit
432 Content-Description: OpenPGP digital signature
433 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
435 -----BEGIN PGP SIGNATURE-----
436 Version: GnuPG v2.0.19 (GNU/Linux)
439 -----END PGP SIGNATURE-----
441 --===============...==--
443 Response to an insecure message from a person with a PGP key:
445 >>> student = course.course.person(email='bb@greyhavens.net')
446 >>> student.pgp_key = '4332B6E3'
447 >>> server = SMTPServer(
448 ... ('localhost', 1025), None, process=process, count=1)
449 >>> del message['Subject']
450 >>> messages = [message]
451 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
452 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
454 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
456 Content-Disposition: inline
458 From: Robot101 <phys101@tower.edu>
459 Reply-to: Robot101 <phys101@tower.edu>
460 To: Bilbo Baggins <bb@shire.org>
461 Subject: unsigned message <hgi.jlk@home.net>
463 --===============...==
465 Content-Transfer-Encoding: 7bit
466 Content-Type: application/pgp-encrypted; charset="us-ascii"
470 --===============...==
472 Content-Transfer-Encoding: 7bit
473 Content-Description: OpenPGP encrypted message
474 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
476 -----BEGIN PGP MESSAGE-----
477 Version: GnuPG v2.0.19 (GNU/Linux)
480 -----END PGP MESSAGE-----
482 --===============...==--
484 Response to a message from an unregistered person:
486 >>> server = SMTPServer(
487 ... ('localhost', 1025), None, process=process, count=1)
488 >>> del message['Return-Path']
489 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
490 >>> messages = [message]
491 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
492 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
494 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
496 Content-Disposition: inline
498 From: Robot101 <phys101@tower.edu>
499 Reply-to: Robot101 <phys101@tower.edu>
500 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
501 Subject: unregistered address invalid.return.path@home.net
503 --===============...==
504 Content-Type: multipart/mixed; boundary="===============...=="
507 --===============...==
508 Content-Type: text/plain; charset="us-ascii"
510 Content-Transfer-Encoding: 7bit
511 Content-Disposition: inline
513 invalid.return.path@home.net,
515 Your email address is not registered with pygrader for
516 Physics 101. If you feel it should be, contact your professor
521 --===============...==
522 Content-Type: message/rfc822
525 Content-Type: text/plain; charset="us-ascii"
527 Content-Transfer-Encoding: 7bit
528 Content-Disposition: inline
529 From: Billy B <bb@greyhavens.net>
530 To: phys101 <phys101@tower.edu>
531 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)
532 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)
533 Message-ID: <hgi.jlk@home.net>
534 Return-Path: <invalid.return.path@home.net>
537 --===============...==--
538 --===============...==
540 Content-Transfer-Encoding: 7bit
541 Content-Description: OpenPGP digital signature
542 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
544 -----BEGIN PGP SIGNATURE-----
545 Version: GnuPG v2.0.19 (GNU/Linux)
548 -----END PGP SIGNATURE-----
550 --===============...==--
556 for msg,person,assignment,time in _load_messages(
557 course=course, stream=stream, mailbox=mailbox, input_=input_,
558 output=output, respond=respond, use_color=use_color, dry_run=dry_run):
559 assignment_path = _assignment_path(basedir, assignment, person)
560 _save_local_message_copy(
561 msg=msg, person=person, assignment_path=assignment_path,
562 use_color=use_color, dry_run=dry_run)
563 _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
565 basedir=basedir, assignment=assignment, person=person, time=time,
566 max_late=max_late, use_color=use_color, dry_run=dry_run)
568 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
569 respond=None, use_color=None, dry_run=False):
572 messages = [(None,_message_from_file(stream))]
573 if output is not None:
574 ombox = _mailbox.Maildir(output, factory=None, create=True)
575 elif mailbox == 'mbox':
576 mbox = _mailbox.mbox(input_, factory=None, create=False)
577 messages = mbox.items()
578 if output is not None:
579 ombox = _mailbox.mbox(output, factory=None, create=True)
580 elif mailbox == 'maildir':
581 mbox = _mailbox.Maildir(input_, factory=None, create=False)
582 messages = mbox.items()
583 if output is not None:
584 ombox = _mailbox.Maildir(output, factory=None, create=True)
586 raise ValueError(mailbox)
587 for key,msg in messages:
588 ret = _parse_message(
589 course=course, msg=msg, respond=respond, use_color=use_color)
591 if output is not None and dry_run is False:
592 # move message from input mailbox to output mailbox
598 def _parse_message(course, msg, respond=None, use_color=None):
599 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
600 mid = msg['Message-ID']
601 sender = msg['Return-Path'] # RFC 822
603 _LOG.debug(_color_string(
604 string='no Return-Path in {}'.format(mid), color=lowlight))
606 sender = sender[1:-1] # strip wrapping '<' and '>'
608 people = list(course.find_people(email=sender))
610 _LOG.warn(_color_string(
611 string='no person found to match {}'.format(sender),
614 person = _Person(name=sender, emails=[sender])
615 response_subject = 'unregistered address {}'.format(sender)
618 'Your email address is not registered with pygrader for\n'
619 '{}. If you feel it should be, contact your professor\n'
621 'Yours,\n{}').format(
622 sender, course.name, course.robot.alias())
623 response = _construct_response(
624 author=course.robot, targets=[person],
625 subject=response_subject, text=response_text, original=msg)
629 _LOG.warn(_color_string(
630 string='multiple people match {} ({})'.format(
631 sender, ', '.join(str(p) for p in people)),
638 msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
641 response_subject = 'unsigned message {}'.format(mid)
644 'We received an email message from you without a valid\n'
646 'Yours,\n{}').format(
647 person.alias(), course.robot.alias())
648 response = _construct_response(
649 author=course.robot, targets=[person],
650 subject=response_subject, text=response_text,
655 if msg['Subject'] is None:
656 _LOG.warn(_color_string(
657 string='no subject in {}'.format(mid), color=bad))
659 response_subject = 'no subject in {}'.format(mid)
662 'We received an email message from you without a subject.\n\n'
663 'Yours,\n{}').format(
664 person.alias(), course.robot.alias())
665 response = _construct_response(
666 author=course.robot, targets=[person],
667 subject=response_subject, text=response_text, original=msg)
670 parts = _decode_header(msg['Subject'])
672 _LOG.warn(_color_string(
673 string='multi-part header {}'.format(parts), color=bad))
675 subject,encoding = parts[0]
678 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
680 subject = subject.lower().replace('#', '')
681 for assignment in course.assignments:
682 if _match_assignment(assignment, subject):
684 if not _match_assignment(assignment, subject):
685 _LOG.warn(_color_string(
686 string='no assignment found in {}'.format(repr(subject)),
689 response_subject = "received '{}'".format(subject)
690 submittable_assignments = [
691 a for a in course.assignments if a.submittable]
692 if not submittable_assignments:
694 'In fact, there are no submittable assignments for\n'
698 'Remember to use the full name for the assignment in the\n'
699 'subject. For example:\n'
700 ' {} submission\n\n').format(
701 submittable_assignments[0].name)
704 'We got an email from you with the following subject:\n'
706 'which does not match any submittable assignment name for\n'
709 'Yours,\n{}').format(
710 person.alias(), repr(subject), course.name, hint,
711 course.robot.alias())
712 response = _construct_response(
713 author=course.robot, targets=[person],
714 subject=response_subject, text=response_text, original=msg)
718 if not assignment.submittable:
719 response_subject = 'received invalid {} submission'.format(
723 'We received your submission for {}, but you are not allowed\n'
724 'to submit that assignment via email.\n\n'
725 'Yours,\n{}').format(
726 person.alias(), assignment.name, course.robot.alias())
727 response = _construct_response(
728 author=course.robot, targets=[person],
729 subject=response_subject, text=response_text, original=msg)
732 time = _message_time(message=msg, use_color=use_color)
735 response_subject = 'received {} submission'.format(assignment.name)
737 time_str = 'on {}'.format(_formatdate(time))
739 time_str = 'at an unknown time'
742 'We received your submission for {} {}.\n\n'
743 'Yours,\n{}').format(
744 person.alias(), assignment.name, time_str, course.robot.alias())
745 response = _construct_response(
746 author=course.robot, targets=[person],
747 subject=response_subject, text=response_text, original=msg)
749 return (msg, person, assignment, time)
751 def _match_assignment(assignment, subject):
752 return assignment.name.lower() in subject
754 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
756 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
758 _os.makedirs(assignment_path)
761 mpath = _os_path.join(assignment_path, 'mail')
763 mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
764 except _mailbox.NoSuchMailboxError as e:
765 _LOG.debug(_color_string(
766 string='could not open mailbox at {}'.format(mpath),
772 for other_msg in mbox:
773 if other_msg['Message-ID'] == msg['Message-ID']:
777 _LOG.debug(_color_string(
778 string='saving email from {} to {}'.format(
779 person, assignment_path), color=good))
780 if mbox is not None and not dry_run:
781 mdmsg = _mailbox.MaildirMessage(msg)
786 _LOG.debug(_color_string(
787 string='already found {} in {}'.format(
788 msg['Message-ID'], mpath), color=good))
790 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
792 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
793 if time > assignment.due + max_late:
794 dt = time - assignment.due
795 _LOG.warn(_color_string(
796 string='{} {} late by {} seconds ({} hours)'.format(
797 person.name, assignment.name, dt, dt/3600.),
800 _set_late(basedir=basedir, assignment=assignment, person=person)
802 def _get_verified_message(message, pgp_key, use_color=None):
805 >>> from pgp_mime import sign, encodedMIMEText
807 The student composes a message...
809 >>> message = encodedMIMEText('1.23 joules')
811 ... and signs it (with the pgp-mime test key).
813 >>> signed = sign(message, signers=['pgp-mime-test'])
815 As it is being delivered, the message picks up extra headers.
817 >>> signed['Message-ID'] = '<01234567@home.net>'
818 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
819 >>> signed['Received'] = 'from smtp.home.net ...'
821 We check that the message is signed, and that it is signed by the
824 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
825 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
826 Content-Type: text/plain; charset="us-ascii"
828 Content-Transfer-Encoding: 7bit
829 Content-Disposition: inline
830 Message-ID: <01234567@home.net>
831 Received: from smtp.mail.uu.edu ...
832 Received: from smtp.home.net ...
836 If it is signed, but not by the right key, we get ``None``.
838 >>> print(_get_verified_message(signed, pgp_key='01234567'))
841 If it is not signed at all, we get ``None``.
843 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
846 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
847 mid = message['message-id']
849 decrypted,verified,result = _verify(message=message)
850 except (ValueError, AssertionError):
851 _LOG.warn(_color_string(
852 string='could not verify {} (not signed?)'.format(mid),
855 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
856 tree = _etree.fromstring(result.replace(b'\x00', b''))
858 for signature in tree.findall('.//signature'):
859 for fingerprint in signature.iterchildren('fpr'):
860 if fingerprint.text.endswith(pgp_key):
864 _LOG.warn(_color_string(
865 string='{} is not signed by the expected key'.format(mid),
869 sumhex = list(signature.iterchildren('summary'))[0].get('value')
870 summary = int(sumhex, 16)
872 _LOG.warn(_color_string(
873 string='{} has an unverified signature'.format(mid),
876 # otherwise, we may have an untrusted key. We'll count that
877 # as verified here, because the caller is explicity looking
878 # for signatures by this fingerprint.
879 for k,v in message.items(): # copy over useful headers
880 if k.lower() not in ['content-type',
882 'content-disposition',