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 bad subject:
239 >>> server = SMTPServer(
240 ... ('localhost', 1025), None, process=process, count=1)
241 >>> del message['Subject']
242 >>> message['Subject'] = 'need help for the first homework'
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 'need help for the first homework'
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 got an email from you with the following subject:
269 'need help for the first homework'
270 which does not match any submittable assignment name for
272 Remember to use the full name for the assignment in the
273 subject. For example:
274 Assignment 1 submission
278 --===============...==
279 Content-Type: message/rfc822
282 Content-Type: text/plain; charset="us-ascii"
284 Content-Transfer-Encoding: 7bit
285 Content-Disposition: inline
286 From: Billy B <bb@greyhavens.net>
287 To: phys101 <phys101@tower.edu>
288 Return-Path: <bb@greyhavens.net>
289 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)
290 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)
291 Message-ID: <hgi.jlk@home.net>
292 Subject: need help for the first homework
295 --===============...==--
296 --===============...==
298 Content-Transfer-Encoding: 7bit
299 Content-Description: OpenPGP digital signature
300 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
302 -----BEGIN PGP SIGNATURE-----
303 Version: GnuPG v2.0.17 (GNU/Linux)
306 -----END PGP SIGNATURE-----
308 --===============...==--
310 >>> del message['Return-Path']
311 >>> message['Return-Path'] = '<bb@greyhavens.net>'
317 for msg,person,assignment,time in _load_messages(
318 course=course, stream=stream, mailbox=mailbox, input_=input_,
319 output=output, respond=respond, use_color=use_color, dry_run=dry_run):
320 assignment_path = _assignment_path(basedir, assignment, person)
321 _save_local_message_copy(
322 msg=msg, person=person, assignment_path=assignment_path,
323 use_color=use_color, dry_run=dry_run)
324 _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
326 basedir=basedir, assignment=assignment, person=person, time=time,
327 max_late=max_late, use_color=use_color, dry_run=dry_run)
329 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
330 respond=None, use_color=None, dry_run=False):
333 messages = [(None,_message_from_file(stream))]
334 if output is not None:
335 ombox = _mailbox.Maildir(output, factory=None, create=True)
336 elif mailbox == 'mbox':
337 mbox = _mailbox.mbox(input_, factory=None, create=False)
338 messages = mbox.items()
339 if output is not None:
340 ombox = _mailbox.mbox(output, factory=None, create=True)
341 elif mailbox == 'maildir':
342 mbox = _mailbox.Maildir(input_, factory=None, create=False)
343 messages = mbox.items()
344 if output is not None:
345 ombox = _mailbox.Maildir(output, factory=None, create=True)
347 raise ValueError(mailbox)
348 for key,msg in messages:
349 ret = _parse_message(
350 course=course, msg=msg, respond=respond, use_color=use_color)
352 if output is not None and dry_run is False:
353 # move message from input mailbox to output mailbox
359 def _parse_message(course, msg, respond=None, use_color=None):
360 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
361 mid = msg['Message-ID']
362 sender = msg['Return-Path'] # RFC 822
364 _LOG.debug(_color_string(
365 string='no Return-Path in {}'.format(mid), color=lowlight))
367 sender = sender[1:-1] # strip wrapping '<' and '>'
368 time = _message_time(message=msg, use_color=use_color)
372 time_str = _formatdate(time)
374 time_str = 'unknown time'
375 response_subject = 'received {} at {}'.format(mid, time_str)
377 people = list(course.find_people(email=sender))
379 _LOG.warn(_color_string(
380 string='no person found to match {}'.format(sender),
383 person = _Person(name=None, emails=[sender])
386 'Your email address is not registered with pygrader for\n'
387 '{}. If you feel it should be, contact your professor\n'
389 'Yours,\n{}').format(
390 sender, course.robot.alias())
391 response_text = 'Address {} is not registered for {}.'.format(
393 response = _construct_response(
394 author=course.robot, targets=[person],
395 subject=response_subject, text=response_text, original=msg)
399 _LOG.warn(_color_string(
400 string='multiple people match {} ({})'.format(
401 sender, ', '.join(str(p) for p in people)),
407 msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
412 'We received an email message from you without a valid\n'
414 'Yours,\n{}').format(
415 person.alias(), course.robot.alias())
416 response_text = 'Message not signed by {}.'.format(
418 response = _construct_response(
419 author=course.robot, targets=[person],
420 subject=response_subject, text=response_text, original=msg)
424 if msg['Subject'] is None:
425 _LOG.warn(_color_string(
426 string='no subject in {}'.format(mid), color=bad))
430 'We received an email message from you without a subject.\n\n'
431 'Yours,\n{}').format(
432 person.alias(), course.robot.alias())
433 response = _construct_response(
434 author=course.robot, targets=[person],
435 subject=response_subject, text=response_text, original=msg)
438 parts = _decode_header(msg['Subject'])
440 _LOG.warn(_color_string(
441 string='multi-part header {}'.format(parts), color=bad))
443 subject,encoding = parts[0]
446 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
448 subject = subject.lower().replace('#', '')
449 for assignment in course.assignments:
450 if _match_assignment(assignment, subject):
452 if not _match_assignment(assignment, subject):
453 _LOG.warn(_color_string(
454 string='no assignment found in {}'.format(repr(subject)),
457 response_subject = "received '{}'".format(subject)
458 submittable_assignments = [
459 a for a in course.assignments if a.submittable]
460 if not submittable_assignments:
462 'In fact, there are no submittable assignments for\n'
466 'Remember to use the full name for the assignment in the\n'
467 'subject. For example:\n'
468 ' {} submission\n\n').format(
469 submittable_assignments[0].name)
472 'We got an email from you with the following subject:\n'
474 'which does not match any submittable assignment name for\n'
477 'Yours,\n{}').format(
478 person.alias(), repr(subject), course.name, hint,
479 course.robot.alias())
480 response = _construct_response(
481 author=course.robot, targets=[person],
482 subject=response_subject, text=response_text, original=msg)
487 response_subject = 'received {} submission'.format(assignment.name)
490 'We received your submission for {} on {}.\n\n'
491 'Yours,\n{}').format(
492 person.alias(), assignment.name, time_str, course.robot.alias())
493 response = _construct_response(
494 author=course.robot, targets=[person],
495 subject=response_subject, text=response_text, original=msg)
497 return (msg, person, assignment, time)
499 def _match_assignment(assignment, subject):
500 return assignment.name.lower() in subject
502 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
504 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
506 _os.makedirs(assignment_path)
509 mpath = _os_path.join(assignment_path, 'mail')
511 mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
512 except _mailbox.NoSuchMailboxError as e:
513 _LOG.debug(_color_string(
514 string='could not open mailbox at {}'.format(mpath),
520 for other_msg in mbox:
521 if other_msg['Message-ID'] == msg['Message-ID']:
525 _LOG.debug(_color_string(
526 string='saving email from {} to {}'.format(
527 person, assignment_path), color=good))
528 if mbox is not None and not dry_run:
529 mdmsg = _mailbox.MaildirMessage(msg)
534 _LOG.debug(_color_string(
535 string='already found {} in {}'.format(
536 msg['Message-ID'], mpath), color=good))
538 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
540 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
541 if time > assignment.due + max_late:
542 dt = time - assignment.due
543 _LOG.warn(_color_string(
544 string='{} {} late by {} seconds ({} hours)'.format(
545 person.name, assignment.name, dt, dt/3600.),
548 _set_late(basedir=basedir, assignment=assignment, person=person)
550 def _get_verified_message(message, pgp_key, use_color=None):
553 >>> from pgp_mime import sign, encodedMIMEText
555 The student composes a message...
557 >>> message = encodedMIMEText('1.23 joules')
559 ... and signs it (with the pgp-mime test key).
561 >>> signed = sign(message, signers=['pgp-mime-test'])
563 As it is being delivered, the message picks up extra headers.
565 >>> signed['Message-ID'] = '<01234567@home.net>'
566 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
567 >>> signed['Received'] = 'from smtp.home.net ...'
569 We check that the message is signed, and that it is signed by the
572 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
573 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
574 Content-Type: text/plain; charset="us-ascii"
576 Content-Transfer-Encoding: 7bit
577 Content-Disposition: inline
578 Message-ID: <01234567@home.net>
579 Received: from smtp.mail.uu.edu ...
580 Received: from smtp.home.net ...
584 If it is signed, but not by the right key, we get ``None``.
586 >>> print(_get_verified_message(signed, pgp_key='01234567'))
589 If it is not signed at all, we get ``None``.
591 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
594 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
595 mid = message['message-id']
597 decrypted,verified,result = _verify(message=message)
598 except (ValueError, AssertionError):
599 _LOG.warn(_color_string(
600 string='could not verify {} (not signed?)'.format(mid),
603 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
604 tree = _etree.fromstring(result.replace(b'\x00', b''))
606 for signature in tree.findall('.//signature'):
607 for fingerprint in signature.iterchildren('fpr'):
608 if fingerprint.text.endswith(pgp_key):
612 _LOG.warn(_color_string(
613 string='{} is not signed by the expected key'.format(mid),
617 sumhex = list(signature.iterchildren('summary'))[0].get('value')
618 summary = int(sumhex, 16)
620 _LOG.warn(_color_string(
621 string='{} has an unverified signature'.format(mid),
624 # otherwise, we may have an untrusted key. We'll count that
625 # as verified here, because the caller is explicity looking
626 # for signatures by this fingerprint.
627 for k,v in message.items(): # copy over useful headers
628 if k.lower() not in ['content-type',
630 'content-disposition',