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 """Parse an incoming email and respond if neccessary.
601 Return ``(msg, person, assignment, time)`` on successful parsing.
602 Return ``None`` on failure.
604 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
606 mid = msg['Message-ID']
608 msg,person,subject = _get_message_person_and_subject(
609 course=course, message=msg, original=original, respond=respond,
611 except ValueError as error:
612 _LOG.debug(_color_string(string=str(error), color=bad))
615 for assignment in course.assignments:
616 if _match_assignment(assignment, subject):
618 if not _match_assignment(assignment, subject):
619 _LOG.warn(_color_string(
620 string='no assignment found in {}'.format(repr(subject)),
623 response_subject = "received '{}'".format(subject)
624 submittable_assignments = [
625 a for a in course.assignments if a.submittable]
626 if not submittable_assignments:
628 'In fact, there are no submittable assignments for\n'
632 'Remember to use the full name for the assignment in the\n'
633 'subject. For example:\n'
634 ' {} submission\n\n').format(
635 submittable_assignments[0].name)
638 'We got an email from you with the following subject:\n'
640 'which does not match any submittable assignment name for\n'
643 'Yours,\n{}').format(
644 person.alias(), repr(subject), course.name, hint,
645 course.robot.alias())
646 response = _construct_response(
647 author=course.robot, targets=[person],
648 subject=response_subject, text=response_text, original=msg)
652 if not assignment.submittable:
653 response_subject = 'received invalid {} submission'.format(
657 'We received your submission for {}, but you are not allowed\n'
658 'to submit that assignment via email.\n\n'
659 'Yours,\n{}').format(
660 person.alias(), assignment.name, course.robot.alias())
661 response = _construct_response(
662 author=course.robot, targets=[person],
663 subject=response_subject, text=response_text, original=msg)
666 time = _message_time(message=msg, use_color=use_color)
669 response_subject = 'received {} submission'.format(assignment.name)
671 time_str = 'on {}'.format(_formatdate(time))
673 time_str = 'at an unknown time'
676 'We received your submission for {} {}.\n\n'
677 'Yours,\n{}').format(
678 person.alias(), assignment.name, time_str, course.robot.alias())
679 response = _construct_response(
680 author=course.robot, targets=[person],
681 subject=response_subject, text=response_text, original=msg)
683 return (msg, person, assignment, time)
685 def _get_message_person(course, message, original, respond=None,
687 mid = message['Message-ID']
688 sender = message['Return-Path'] # RFC 822
690 raise ValueError('no Return-Path in {}'.format(mid))
691 sender = sender[1:-1] # strip wrapping '<' and '>'
692 people = list(course.find_people(email=sender))
695 person = _Person(name=sender, emails=[sender])
696 response_subject = 'unregistered address {}'.format(sender)
699 'Your email address is not registered with pygrader for\n'
700 '{}. If you feel it should be, contact your professor\n'
702 'Yours,\n{}').format(
703 sender, course.name, course.robot.alias())
704 response = _construct_response(
705 author=course.robot, targets=[person],
706 subject=response_subject, text=response_text,
709 raise ValueError('no person found to match {}'.format(sender))
711 raise ValueError('multiple people match {} ({})'.format(
712 sender, ', '.join(str(p) for p in people)))
715 def _get_decoded_message(course, message, original, person,
716 respond=None, use_color=None):
717 message = _get_verified_message(
718 message, person.pgp_key, use_color=use_color)
721 mid = original['Message-ID']
722 response_subject = 'unsigned message {}'.format(mid)
725 'We received an email message from you without a valid\n'
727 'Yours,\n{}').format(
728 person.alias(), course.robot.alias())
729 response = _construct_response(
730 author=course.robot, targets=[person],
731 subject=response_subject, text=response_text,
734 raise ValueError('unsigned message from {}'.format(person.alias()))
737 def _get_message_subject(course, message, original, person,
738 respond=None, use_color=None):
739 if message['Subject'] is None:
740 mid = message['Message-ID']
741 response_subject = 'no subject in {}'.format(mid)
745 'We received an email message from you without a subject.\n\n'
746 'Yours,\n{}').format(
747 person.alias(), course.robot.alias())
748 response = _construct_response(
749 author=course.robot, targets=[person],
750 subject=response_subject, text=response_text,
753 raise ValueError(response_subject)
755 parts = _decode_header(message['Subject'])
757 raise ValueError('multi-part header {}'.format(parts))
758 subject,encoding = parts[0]
761 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
762 return subject.lower().replace('#', '')
764 def _get_message_person_and_subject(course, message, original,
765 respond=None, use_color=None):
767 person = _get_message_person(
768 course=course, message=message, original=original,
769 respond=respond, use_color=use_color)
771 message = _get_decoded_message(
772 course=course, message=message, original=original, person=person,
773 respond=respond, use_color=use_color)
774 subject = _get_message_subject(
775 course=course, message=message, original=original, person=person,
776 respond=respond, use_color=use_color)
777 return (message, person, subject)
779 def _match_assignment(assignment, subject):
780 return assignment.name.lower() in subject
782 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
784 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
786 _os.makedirs(assignment_path)
789 mpath = _os_path.join(assignment_path, 'mail')
791 mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
792 except _mailbox.NoSuchMailboxError as e:
793 _LOG.debug(_color_string(
794 string='could not open mailbox at {}'.format(mpath),
800 for other_msg in mbox:
801 if other_msg['Message-ID'] == msg['Message-ID']:
805 _LOG.debug(_color_string(
806 string='saving email from {} to {}'.format(
807 person, assignment_path), color=good))
808 if mbox is not None and not dry_run:
809 mdmsg = _mailbox.MaildirMessage(msg)
814 _LOG.debug(_color_string(
815 string='already found {} in {}'.format(
816 msg['Message-ID'], mpath), color=good))
818 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
820 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
821 if time > assignment.due + max_late:
822 dt = time - assignment.due
823 _LOG.warn(_color_string(
824 string='{} {} late by {} seconds ({} hours)'.format(
825 person.name, assignment.name, dt, dt/3600.),
828 _set_late(basedir=basedir, assignment=assignment, person=person)
830 def _get_verified_message(message, pgp_key, use_color=None):
833 >>> from pgp_mime import sign, encodedMIMEText
835 The student composes a message...
837 >>> message = encodedMIMEText('1.23 joules')
839 ... and signs it (with the pgp-mime test key).
841 >>> signed = sign(message, signers=['pgp-mime-test'])
843 As it is being delivered, the message picks up extra headers.
845 >>> signed['Message-ID'] = '<01234567@home.net>'
846 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
847 >>> signed['Received'] = 'from smtp.home.net ...'
849 We check that the message is signed, and that it is signed by the
852 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
853 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
854 Content-Type: text/plain; charset="us-ascii"
856 Content-Transfer-Encoding: 7bit
857 Content-Disposition: inline
858 Message-ID: <01234567@home.net>
859 Received: from smtp.mail.uu.edu ...
860 Received: from smtp.home.net ...
864 If it is signed, but not by the right key, we get ``None``.
866 >>> print(_get_verified_message(signed, pgp_key='01234567'))
869 If it is not signed at all, we get ``None``.
871 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
874 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
875 mid = message['message-id']
877 decrypted,verified,result = _verify(message=message)
878 except (ValueError, AssertionError):
879 _LOG.warn(_color_string(
880 string='could not verify {} (not signed?)'.format(mid),
883 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
884 tree = _etree.fromstring(result.replace(b'\x00', b''))
886 for signature in tree.findall('.//signature'):
887 for fingerprint in signature.iterchildren('fpr'):
888 if fingerprint.text.endswith(pgp_key):
892 _LOG.warn(_color_string(
893 string='{} is not signed by the expected key'.format(mid),
897 sumhex = list(signature.iterchildren('summary'))[0].get('value')
898 summary = int(sumhex, 16)
900 _LOG.warn(_color_string(
901 string='{} has an unverified signature'.format(mid),
904 # otherwise, we may have an untrusted key. We'll count that
905 # as verified here, because the caller is explicity looking
906 # for signatures by this fingerprint.
907 for k,v in message.items(): # copy over useful headers
908 if k.lower() not in ['content-type',
910 'content-disposition',