Reject attempted submissions for unsubmittable assignments in mailpipe.
[pygrader.git] / pygrader / mailpipe.py
1 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of pygrader.
4 #
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
8 # version.
9 #
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.
13 #
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/>.
16
17 from __future__ import absolute_import
18
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
25 import os as _os
26 import os.path as _os_path
27 import sys as _sys
28 import time as _time
29
30 from pgp_mime import verify as _verify
31 from lxml import etree as _etree
32
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
42
43
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
48
49     For example, you can setup your ``.procmailrc`` like this::
50
51       SHELL=/bin/sh
52       DEFAULT=$MAIL
53       MAILDIR=$HOME/mail
54       DEFAULT=$MAILDIR/mbox
55       LOGFILE=$MAILDIR/procmail.log
56       #VERBOSE=yes
57       PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
58
59       # Grab all incoming homeworks emails.  This rule eats matching emails
60       # (i.e. no further procmail processing).
61       :0
62       * ^Subject:.*\[phys160-sub]
63       | "$PYGRADE_MAILPIPE" mailpipe
64
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``.
67
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
74
75     Messages with unrecognized ``Return-Path``\s are silently dropped:
76
77     >>> course = StubCourse()
78     >>> def process(peer, mailfrom, rcpttos, data):
79     ...     mailpipe(
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)
94     >>> loop()
95     >>> course.print_tree()  # doctest: +REPORT_UDIFF
96     course.conf
97
98     If we add a valid ``Return-Path``, we get the expected delivery:
99
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)
105     >>> loop()
106     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
107     Bilbo_Baggins
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
114     course.conf
115     mail
116     mail/cur
117     mail/new
118     mail/new/...
119     mail/tmp
120
121     The last ``Received`` is used to timestamp the message:
122
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)
138     >>> loop()
139     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
140     Bilbo_Baggins
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
149     course.conf
150     mail
151     mail/cur
152     mail/new
153     mail/new/...
154     mail/new/...
155     mail/tmp
156
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).
162
163     Response to a successful submission:
164
165     >>> def respond(message):
166     ...     print('respond with:\\n{}'.format(message.as_string()))
167     >>> def process(peer, mailfrom, rcpttos, data):
168     ...     mailpipe(
169     ...         basedir=course.basedir, course=course.course,
170     ...         stream=StringIO(data), output=course.mailbox,
171     ...         respond=respond)
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
179     respond with:
180     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
181     MIME-Version: 1.0
182     Content-Disposition: inline
183     Date: ...
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
188     <BLANKLINE>
189     --===============...==
190     Content-Type: multipart/mixed; boundary="===============...=="
191     MIME-Version: 1.0
192     <BLANKLINE>
193     --===============...==
194     Content-Type: text/plain; charset="us-ascii"
195     MIME-Version: 1.0
196     Content-Transfer-Encoding: 7bit
197     Content-Disposition: inline
198     <BLANKLINE>
199     Billy,
200     <BLANKLINE>
201     We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
202     <BLANKLINE>
203     Yours,
204     phys-101 robot
205     --===============...==
206     Content-Type: message/rfc822
207     MIME-Version: 1.0
208     <BLANKLINE>
209     Content-Type: text/plain; charset="us-ascii"
210     MIME-Version: 1.0
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>
220     <BLANKLINE>
221     The answer is 42.
222     --===============...==--
223     --===============...==
224     MIME-Version: 1.0
225     Content-Transfer-Encoding: 7bit
226     Content-Description: OpenPGP digital signature
227     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
228     <BLANKLINE>
229     -----BEGIN PGP SIGNATURE-----
230     Version: GnuPG v2.0.17 (GNU/Linux)
231     <BLANKLINE>
232     ...
233     -----END PGP SIGNATURE-----
234     <BLANKLINE>
235     --===============...==--
236
237     Response to a submission on an unsubmittable assignment:
238
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
246     respond with:
247     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
248     MIME-Version: 1.0
249     Content-Disposition: inline
250     Date: ...
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
255     <BLANKLINE>
256     --===============...==
257     Content-Type: multipart/mixed; boundary="===============...=="
258     MIME-Version: 1.0
259     <BLANKLINE>
260     --===============...==
261     Content-Type: text/plain; charset="us-ascii"
262     MIME-Version: 1.0
263     Content-Transfer-Encoding: 7bit
264     Content-Disposition: inline
265     <BLANKLINE>
266     Billy,
267     <BLANKLINE>
268     We received your submission for Attendance 1, but you are not allowed
269     to submit that assignment via email.
270     <BLANKLINE>
271     Yours,
272     phys-101 robot
273     --===============...==
274     Content-Type: message/rfc822
275     MIME-Version: 1.0
276     <BLANKLINE>
277     Content-Type: text/plain; charset="us-ascii"
278     MIME-Version: 1.0
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
288     <BLANKLINE>
289     The answer is 42.
290     --===============...==--
291     --===============...==
292     MIME-Version: 1.0
293     Content-Transfer-Encoding: 7bit
294     Content-Description: OpenPGP digital signature
295     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
296     <BLANKLINE>
297     -----BEGIN PGP SIGNATURE-----
298     Version: GnuPG v2.0.17 (GNU/Linux)
299     <BLANKLINE>
300     ...
301     -----END PGP SIGNATURE-----
302     <BLANKLINE>
303     --===============...==--
304
305     Response to a bad subject:
306
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
314     respond with:
315     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
316     MIME-Version: 1.0
317     Content-Disposition: inline
318     Date: ...
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'
323     <BLANKLINE>
324     --===============...==
325     Content-Type: multipart/mixed; boundary="===============...=="
326     MIME-Version: 1.0
327     <BLANKLINE>
328     --===============...==
329     Content-Type: text/plain; charset="us-ascii"
330     MIME-Version: 1.0
331     Content-Transfer-Encoding: 7bit
332     Content-Disposition: inline
333     <BLANKLINE>
334     Billy,
335     <BLANKLINE>
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
339     phys101.
340     Remember to use the full name for the assignment in the
341     subject.  For example:
342       Assignment 1 submission
343     <BLANKLINE>
344     Yours,
345     phys-101 robot
346     --===============...==
347     Content-Type: message/rfc822
348     MIME-Version: 1.0
349     <BLANKLINE>
350     Content-Type: text/plain; charset="us-ascii"
351     MIME-Version: 1.0
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
361     <BLANKLINE>
362     The answer is 42.
363     --===============...==--
364     --===============...==
365     MIME-Version: 1.0
366     Content-Transfer-Encoding: 7bit
367     Content-Description: OpenPGP digital signature
368     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
369     <BLANKLINE>
370     -----BEGIN PGP SIGNATURE-----
371     Version: GnuPG v2.0.17 (GNU/Linux)
372     <BLANKLINE>
373     ...
374     -----END PGP SIGNATURE-----
375     <BLANKLINE>
376     --===============...==--
377
378     >>> del message['Return-Path']
379     >>> message['Return-Path'] = '<bb@greyhavens.net>'
380
381     >>> course.cleanup()
382     """
383     if stream is None:
384         stream = _sys.stdin
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)
393         _check_late(
394             basedir=basedir, assignment=assignment, person=person, time=time,
395             max_late=max_late, use_color=use_color, dry_run=dry_run)
396
397 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
398                    respond=None, use_color=None, dry_run=False):
399     if mailbox is None:
400         mbox = None
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)
414     else:
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)
419         if ret:
420             if output is not None and dry_run is False:
421                 # move message from input mailbox to output mailbox
422                 ombox.add(msg)
423                 if mbox is not None:
424                     del mbox[key]
425             yield ret
426
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
431     if sender is None:
432         _LOG.debug(_color_string(
433                 string='no Return-Path in {}'.format(mid), color=lowlight))
434         return None
435     sender = sender[1:-1]  # strip wrapping '<' and '>'
436     time = _message_time(message=msg, use_color=use_color)
437
438     if respond:
439         if time:
440             time_str = _formatdate(time)
441         else:
442             time_str = 'unknown time'
443         response_subject = 'received {} at {}'.format(mid, time_str)
444
445     people = list(course.find_people(email=sender))
446     if len(people) == 0:
447         _LOG.warn(_color_string(
448                 string='no person found to match {}'.format(sender),
449                 color=bad))
450         if respond:
451             person = _Person(name=None, emails=[sender])
452             response_text = (
453                 '{},\n\n'
454                 'Your email address is not registered with pygrader for\n'
455                 '{}.  If you feel it should be, contact your professor\n'
456                 'or TA.\n\n'
457                 'Yours,\n{}').format(
458                 sender, course.robot.alias())
459             response_text = 'Address {} is not registered for {}.'.format(
460                 sender, course.name)
461             response = _construct_response(
462                 author=course.robot, targets=[person],
463                 subject=response_subject, text=response_text, original=msg)
464             respond(response)
465         return None
466     if len(people) > 1:
467         _LOG.warn(_color_string(
468                 string='multiple people match {} ({})'.format(
469                     sender, ', '.join(str(p) for p in people)),
470                 color=bad))
471         return None
472     person = people[0]
473
474     if person.pgp_key:
475         msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
476         if msg is None:
477             if respond:
478                 response_text = (
479                     '{},\n\n'
480                     'We received an email message from you without a valid\n'
481                     'PGP signature.\n\n'
482                     'Yours,\n{}').format(
483                     person.alias(), course.robot.alias())
484                 response_text = 'Message not signed by {}.'.format(
485                     person.pgp_key)
486                 response = _construct_response(
487                     author=course.robot, targets=[person],
488                     subject=response_subject, text=response_text, original=msg)
489                 respond(response)
490             return None
491
492     if msg['Subject'] is None:
493         _LOG.warn(_color_string(
494                 string='no subject in {}'.format(mid), color=bad))
495         if respond:
496             response_text = (
497                 '{},\n\n'
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)
504             respond(response)
505         return None
506     parts = _decode_header(msg['Subject'])
507     if len(parts) != 1:
508         _LOG.warn(_color_string(
509                 string='multi-part header {}'.format(parts), color=bad))
510         return None
511     subject,encoding = parts[0]
512     if encoding is None:
513         encoding = 'ascii'
514     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
515
516     subject = subject.lower().replace('#', '')
517     for assignment in course.assignments:
518         if _match_assignment(assignment, subject):
519             break
520     if not _match_assignment(assignment, subject):
521         _LOG.warn(_color_string(
522                 string='no assignment found in {}'.format(repr(subject)),
523                 color=bad))
524         if respond:
525             response_subject = "received '{}'".format(subject)
526             submittable_assignments = [
527                 a for a in course.assignments if a.submittable]
528             if not submittable_assignments:
529                 hint = (
530                     'In fact, there are no submittable assignments for\n'
531                     'this course!\n')
532             else:
533                 hint = (
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)
538             response_text = (
539                 '{},\n\n'
540                 'We got an email from you with the following subject:\n'
541                 '  {}\n'
542                 'which does not match any submittable assignment name for\n'
543                 '{}.\n'
544                 '{}'
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)
551             respond(response)
552         return None
553
554     if not assignment.submittable:
555         response_subject = 'received invalid {} submission'.format(
556             assignment.name)
557         response_text = (
558             '{},\n\n'
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)
566         respond(response)
567         
568     if respond:
569         response_subject = 'received {} submission'.format(assignment.name)
570         response_text = (
571             '{},\n\n'
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)
578         respond(response)
579     return (msg, person, assignment, time)
580
581 def _match_assignment(assignment, subject):
582     return assignment.name.lower() in subject
583
584 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
585                              dry_run=False):
586     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
587     try:
588         _os.makedirs(assignment_path)
589     except OSError:
590         pass
591     mpath = _os_path.join(assignment_path, 'mail')
592     try:
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),
597                 color=bad))
598         mbox = None
599         new_msg = True
600     else:
601         new_msg = True
602         for other_msg in mbox:
603             if other_msg['Message-ID'] == msg['Message-ID']:
604                 new_msg = False
605                 break
606     if new_msg:
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)
612             mdmsg.add_flag('S')
613             mbox.add(mdmsg)
614             mbox.close()
615     else:
616         _LOG.debug(_color_string(
617                 string='already found {} in {}'.format(
618                     msg['Message-ID'], mpath), color=good))
619
620 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
621                 dry_run=False):
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.),
628                 color=bad))
629         if not dry_run:
630             _set_late(basedir=basedir, assignment=assignment, person=person)
631
632 def _get_verified_message(message, pgp_key, use_color=None):
633     """
634
635     >>> from pgp_mime import sign, encodedMIMEText
636
637     The student composes a message...
638
639     >>> message = encodedMIMEText('1.23 joules')
640
641     ... and signs it (with the pgp-mime test key).
642
643     >>> signed = sign(message, signers=['pgp-mime-test'])
644
645     As it is being delivered, the message picks up extra headers.
646
647     >>> signed['Message-ID'] = '<01234567@home.net>'
648     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
649     >>> signed['Received'] = 'from smtp.home.net ...'
650
651     We check that the message is signed, and that it is signed by the
652     appropriate key.
653
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"
657     MIME-Version: 1.0
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 ...
663     <BLANKLINE>
664     1.23 joules
665
666     If it is signed, but not by the right key, we get ``None``.
667
668     >>> print(_get_verified_message(signed, pgp_key='01234567'))
669     None
670
671     If it is not signed at all, we get ``None``.
672
673     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
674     None
675     """
676     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
677     mid = message['message-id']
678     try:
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),
683                 color=bad))
684         return None
685     _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
686     tree = _etree.fromstring(result.replace(b'\x00', b''))
687     match = None
688     for signature in tree.findall('.//signature'):
689         for fingerprint in signature.iterchildren('fpr'):
690             if fingerprint.text.endswith(pgp_key):
691                 match = signature
692                 break
693     if match is None:
694         _LOG.warn(_color_string(
695                 string='{} is not signed by the expected key'.format(mid),
696                 color=bad))
697         return None
698     if not verified:
699         sumhex = list(signature.iterchildren('summary'))[0].get('value')
700         summary = int(sumhex, 16)
701         if summary != 0:
702             _LOG.warn(_color_string(
703                     string='{} has an unverified signature'.format(mid),
704                     color=bad))
705             return None
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',
711                              'mime-version',
712                              'content-disposition',
713                              ]:
714             decrypted[k] = v
715     return decrypted