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 import mailbox as _mailbox
25 from pgp_mime import verify as _verify
26 from lxml import etree as _etree
28 from . import LOG as _LOG
29 from .color import color_string as _color_string
30 from .color import standard_colors as _standard_colors
31 from .model.person import Person as _Person
33 from .handler import respond as _respond
34 from .handler.submission import run as _handle_submission
37 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
40 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
41 output=None, max_late=0, handlers={
42 'submit': _handle_submission,
43 }, respond=None, use_color=None,
44 dry_run=False, **kwargs):
45 """Run from procmail to sort incomming submissions
47 For example, you can setup your ``.procmailrc`` like this::
53 LOGFILE=$MAILDIR/procmail.log
55 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
57 # Grab all incoming homeworks emails. This rule eats matching emails
58 # (i.e. no further procmail processing).
60 * ^Subject:.*\[phys160:submit]
61 | "$PYGRADE_MAILPIPE" mailpipe
63 If you don't want procmail to eat the message, you can use the
64 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
66 >>> from asyncore import loop
67 >>> from io import StringIO
68 >>> from pgp_mime.email import encodedMIMEText
69 >>> from pygrader.test.course import StubCourse
70 >>> from pygrader.test.client import MessageSender
71 >>> from pygrader.test.server import SMTPServer
73 Messages with unrecognized ``Return-Path``\s are silently dropped:
75 >>> course = StubCourse()
76 >>> def process(peer, mailfrom, rcpttos, data):
78 ... basedir=course.basedir, course=course.course,
79 ... stream=StringIO(data), output=course.mailbox)
80 >>> message = encodedMIMEText('The answer is 42.')
81 >>> message['Message-ID'] = '<123.456@home.net>'
82 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
83 >>> message['Received'] = (
84 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
85 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
86 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
87 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
88 >>> message['To'] = 'phys101 <phys101@tower.edu>'
89 >>> message['Subject'] = '[submit] assignment 1'
90 >>> messages = [message]
91 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
93 >>> course.print_tree() # doctest: +REPORT_UDIFF
96 If we add a valid ``Return-Path``, we get the expected delivery:
98 >>> server = SMTPServer(
99 ... ('localhost', 1025), None, process=process, count=1)
100 >>> del message['Return-Path']
101 >>> message['Return-Path'] = '<bb@greyhavens.net>'
102 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
104 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
106 Bilbo_Baggins/Assignment_1
107 Bilbo_Baggins/Assignment_1/mail
108 Bilbo_Baggins/Assignment_1/mail/cur
109 Bilbo_Baggins/Assignment_1/mail/new
110 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
111 Bilbo_Baggins/Assignment_1/mail/tmp
119 The last ``Received`` is used to timestamp the message:
121 >>> server = SMTPServer(
122 ... ('localhost', 1025), None, process=process, count=1)
123 >>> del message['Message-ID']
124 >>> message['Message-ID'] = '<abc.def@home.net>'
125 >>> del message['Received']
126 >>> message['Received'] = (
127 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
128 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
129 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
130 >>> message['Received'] = (
131 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
132 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
133 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
134 >>> messages = [message]
135 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
137 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
139 Bilbo_Baggins/Assignment_1
140 Bilbo_Baggins/Assignment_1/late
141 Bilbo_Baggins/Assignment_1/mail
142 Bilbo_Baggins/Assignment_1/mail/cur
143 Bilbo_Baggins/Assignment_1/mail/new
144 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
145 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
146 Bilbo_Baggins/Assignment_1/mail/tmp
155 You can send receipts to the acknowledge incoming messages, which
156 includes warnings about dropped messages (except for messages
157 without ``Return-Path`` and messages where the ``Return-Path``
158 email belongs to multiple ``People``. The former should only
159 occur with malicious emails, and the latter with improper pygrader
162 Response to a successful submission:
164 >>> def respond(message):
165 ... print('respond with:\\n{}'.format(message.as_string()))
166 >>> def process(peer, mailfrom, rcpttos, data):
168 ... basedir=course.basedir, course=course.course,
169 ... stream=StringIO(data), output=course.mailbox,
171 >>> server = SMTPServer(
172 ... ('localhost', 1025), None, process=process, count=1)
173 >>> del message['Message-ID']
174 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
175 >>> messages = [message]
176 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
177 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
179 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
181 Content-Disposition: inline
183 From: Robot101 <phys101@tower.edu>
184 Reply-to: Robot101 <phys101@tower.edu>
185 To: Bilbo Baggins <bb@shire.org>
186 Subject: received Assignment 1 submission
188 --===============...==
189 Content-Type: multipart/mixed; boundary="===============...=="
192 --===============...==
193 Content-Type: text/plain; charset="us-ascii"
195 Content-Transfer-Encoding: 7bit
196 Content-Disposition: inline
200 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
204 --===============...==
205 Content-Type: message/rfc822
208 Content-Type: text/plain; charset="us-ascii"
210 Content-Transfer-Encoding: 7bit
211 Content-Disposition: inline
212 From: Billy B <bb@greyhavens.net>
213 To: phys101 <phys101@tower.edu>
214 Subject: [submit] assignment 1
215 Return-Path: <bb@greyhavens.net>
216 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)
217 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)
218 Message-ID: <hgi.jlk@home.net>
221 --===============...==--
222 --===============...==
224 Content-Transfer-Encoding: 7bit
225 Content-Description: OpenPGP digital signature
226 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
228 -----BEGIN PGP SIGNATURE-----
229 Version: GnuPG v2.0.19 (GNU/Linux)
232 -----END PGP SIGNATURE-----
234 --===============...==--
236 Response to a submission on an unsubmittable assignment:
238 >>> server = SMTPServer(
239 ... ('localhost', 1025), None, process=process, count=1)
240 >>> del message['Subject']
241 >>> message['Subject'] = '[submit] attendance 1'
242 >>> messages = [message]
243 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
244 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
246 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
248 Content-Disposition: inline
250 From: Robot101 <phys101@tower.edu>
251 Reply-to: Robot101 <phys101@tower.edu>
252 To: Bilbo Baggins <bb@shire.org>
253 Subject: received invalid Attendance 1 submission
255 --===============...==
256 Content-Type: multipart/mixed; boundary="===============...=="
259 --===============...==
260 Content-Type: text/plain; charset="us-ascii"
262 Content-Transfer-Encoding: 7bit
263 Content-Disposition: inline
267 We received your submission for Attendance 1, but you are not
268 allowed to submit that assignment via email.
272 --===============...==
273 Content-Type: message/rfc822
276 Content-Type: text/plain; charset="us-ascii"
278 Content-Transfer-Encoding: 7bit
279 Content-Disposition: inline
280 From: Billy B <bb@greyhavens.net>
281 To: phys101 <phys101@tower.edu>
282 Return-Path: <bb@greyhavens.net>
283 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)
284 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)
285 Message-ID: <hgi.jlk@home.net>
286 Subject: [submit] attendance 1
289 --===============...==--
290 --===============...==
292 Content-Transfer-Encoding: 7bit
293 Content-Description: OpenPGP digital signature
294 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
296 -----BEGIN PGP SIGNATURE-----
297 Version: GnuPG v2.0.19 (GNU/Linux)
300 -----END PGP SIGNATURE-----
302 --===============...==--
304 Response to a bad subject:
306 >>> server = SMTPServer(
307 ... ('localhost', 1025), None, process=process, count=1)
308 >>> del message['Subject']
309 >>> message['Subject'] = 'need help for the first homework'
310 >>> messages = [message]
311 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
312 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
314 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
316 Content-Disposition: inline
318 From: Robot101 <phys101@tower.edu>
319 Reply-to: Robot101 <phys101@tower.edu>
320 To: Bilbo Baggins <bb@shire.org>
321 Subject: no tag in 'need help for the first homework'
323 --===============...==
324 Content-Type: multipart/mixed; boundary="===============...=="
327 --===============...==
328 Content-Type: text/plain; charset="us-ascii"
330 Content-Transfer-Encoding: 7bit
331 Content-Disposition: inline
335 We received an email message from you without
340 --===============...==
341 Content-Type: message/rfc822
344 Content-Type: text/plain; charset="us-ascii"
346 Content-Transfer-Encoding: 7bit
347 Content-Disposition: inline
348 From: Billy B <bb@greyhavens.net>
349 To: phys101 <phys101@tower.edu>
350 Return-Path: <bb@greyhavens.net>
351 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)
352 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)
353 Message-ID: <hgi.jlk@home.net>
354 Subject: need help for the first homework
357 --===============...==--
358 --===============...==
360 Content-Transfer-Encoding: 7bit
361 Content-Description: OpenPGP digital signature
362 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
364 -----BEGIN PGP SIGNATURE-----
365 Version: GnuPG v2.0.19 (GNU/Linux)
368 -----END PGP SIGNATURE-----
370 --===============...==--
372 Response to a missing subject:
374 >>> server = SMTPServer(
375 ... ('localhost', 1025), None, process=process, count=1)
376 >>> del message['Subject']
377 >>> messages = [message]
378 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
379 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
381 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
383 Content-Disposition: inline
385 From: Robot101 <phys101@tower.edu>
386 Reply-to: Robot101 <phys101@tower.edu>
387 To: Bilbo Baggins <bb@shire.org>
388 Subject: no subject in <hgi.jlk@home.net>
390 --===============...==
391 Content-Type: multipart/mixed; boundary="===============...=="
394 --===============...==
395 Content-Type: text/plain; charset="us-ascii"
397 Content-Transfer-Encoding: 7bit
398 Content-Disposition: inline
402 We received an email message from you without a subject.
406 --===============...==
407 Content-Type: message/rfc822
410 Content-Type: text/plain; charset="us-ascii"
412 Content-Transfer-Encoding: 7bit
413 Content-Disposition: inline
414 From: Billy B <bb@greyhavens.net>
415 To: phys101 <phys101@tower.edu>
416 Return-Path: <bb@greyhavens.net>
417 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)
418 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)
419 Message-ID: <hgi.jlk@home.net>
422 --===============...==--
423 --===============...==
425 Content-Transfer-Encoding: 7bit
426 Content-Description: OpenPGP digital signature
427 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
429 -----BEGIN PGP SIGNATURE-----
430 Version: GnuPG v2.0.19 (GNU/Linux)
433 -----END PGP SIGNATURE-----
435 --===============...==--
437 Response to an insecure message from a person with a PGP key:
439 >>> student = course.course.person(email='bb@greyhavens.net')
440 >>> student.pgp_key = '4332B6E3'
441 >>> server = SMTPServer(
442 ... ('localhost', 1025), None, process=process, count=1)
443 >>> del message['Subject']
444 >>> messages = [message]
445 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
446 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
448 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
450 Content-Disposition: inline
452 From: Robot101 <phys101@tower.edu>
453 Reply-to: Robot101 <phys101@tower.edu>
454 To: Bilbo Baggins <bb@shire.org>
455 Subject: unsigned message <hgi.jlk@home.net>
457 --===============...==
459 Content-Transfer-Encoding: 7bit
460 Content-Type: application/pgp-encrypted; charset="us-ascii"
464 --===============...==
466 Content-Transfer-Encoding: 7bit
467 Content-Description: OpenPGP encrypted message
468 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
470 -----BEGIN PGP MESSAGE-----
471 Version: GnuPG v2.0.19 (GNU/Linux)
474 -----END PGP MESSAGE-----
476 --===============...==--
478 Response to a message from an unregistered person:
480 >>> server = SMTPServer(
481 ... ('localhost', 1025), None, process=process, count=1)
482 >>> del message['Return-Path']
483 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
484 >>> messages = [message]
485 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
486 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
488 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
490 Content-Disposition: inline
492 From: Robot101 <phys101@tower.edu>
493 Reply-to: Robot101 <phys101@tower.edu>
494 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
495 Subject: unregistered address invalid.return.path@home.net
497 --===============...==
498 Content-Type: multipart/mixed; boundary="===============...=="
501 --===============...==
502 Content-Type: text/plain; charset="us-ascii"
504 Content-Transfer-Encoding: 7bit
505 Content-Disposition: inline
507 invalid.return.path@home.net,
509 Your email address is not registered with pygrader for
510 Physics 101. If you feel it should be, contact your professor
515 --===============...==
516 Content-Type: message/rfc822
519 Content-Type: text/plain; charset="us-ascii"
521 Content-Transfer-Encoding: 7bit
522 Content-Disposition: inline
523 From: Billy B <bb@greyhavens.net>
524 To: phys101 <phys101@tower.edu>
525 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)
526 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)
527 Message-ID: <hgi.jlk@home.net>
528 Return-Path: <invalid.return.path@home.net>
531 --===============...==--
532 --===============...==
534 Content-Transfer-Encoding: 7bit
535 Content-Description: OpenPGP digital signature
536 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
538 -----BEGIN PGP SIGNATURE-----
539 Version: GnuPG v2.0.19 (GNU/Linux)
542 -----END PGP SIGNATURE-----
544 --===============...==--
548 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
551 for original,message,person,subject,target in _load_messages(
552 course=course, stream=stream, mailbox=mailbox, input_=input_,
553 output=output, respond=respond, use_color=use_color, dry_run=dry_run):
554 handler = _get_handler(
555 course=course, handlers=handlers, message=message, person=person,
556 subject=subject, target=target)
559 basedir=basedir, course=course, original=original,
560 message=message, person=person, subject=subject,
561 max_late=max_late, respond=respond,
562 use_color=use_color, dry_run=dry_run)
563 except ValueError as error:
564 _LOG.warn(_color_string(string=str(error), color=bad))
566 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
567 respond=None, use_color=None, dry_run=False):
570 messages = [(None,_message_from_file(stream))]
571 if output is not None:
572 ombox = _mailbox.Maildir(output, factory=None, create=True)
573 elif mailbox == 'mbox':
574 mbox = _mailbox.mbox(input_, factory=None, create=False)
575 messages = mbox.items()
576 if output is not None:
577 ombox = _mailbox.mbox(output, factory=None, create=True)
578 elif mailbox == 'maildir':
579 mbox = _mailbox.Maildir(input_, factory=None, create=False)
580 messages = mbox.items()
581 if output is not None:
582 ombox = _mailbox.Maildir(output, factory=None, create=True)
584 raise ValueError(mailbox)
585 for key,msg in messages:
586 ret = _parse_message(
587 course=course, message=msg, respond=respond, use_color=use_color)
589 if output is not None and dry_run is False:
590 # move message from input mailbox to output mailbox
596 def _parse_message(course, message, respond=None, use_color=None):
597 """Parse an incoming email and respond if neccessary.
599 Return ``(msg, person, assignment, time)`` on successful parsing.
600 Return ``None`` on failure.
602 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
605 person = _get_message_person(
606 course=course, message=message, original=original,
607 respond=respond, use_color=use_color)
609 message = _get_decoded_message(
610 course=course, message=message, original=original, person=person,
611 respond=respond, use_color=use_color)
612 subject = _get_message_subject(
613 course=course, message=message, original=original, person=person,
614 respond=respond, use_color=use_color)
615 target = _get_message_target(
616 course=course, message=message, original=original, person=person,
617 subject=subject, respond=respond, use_color=use_color)
618 except ValueError as error:
619 _LOG.debug(_color_string(string=str(error), color=bad))
621 return (original, message, person, subject, target)
623 def _get_message_person(course, message, original, respond=None,
625 mid = message['Message-ID']
626 sender = message['Return-Path'] # RFC 822
628 raise ValueError('no Return-Path in {}'.format(mid))
629 sender = sender[1:-1] # strip wrapping '<' and '>'
630 people = list(course.find_people(email=sender))
633 person = _Person(name=sender, emails=[sender])
634 response_subject = 'unregistered address {}'.format(sender)
636 course=course, person=person, original=original,
637 subject=response_subject, text=(
638 'Your email address is not registered with pygrader for\n'
639 '{}. If you feel it should be, contact your professor\n'
640 'or TA.').format(course.name),
642 raise ValueError('no person found to match {}'.format(sender))
644 raise ValueError('multiple people match {} ({})'.format(
645 sender, ', '.join(str(p) for p in people)))
648 def _get_decoded_message(course, message, original, person,
649 respond=None, use_color=None):
650 message = _get_verified_message(
651 message, person.pgp_key, use_color=use_color)
654 mid = original['Message-ID']
655 response_subject = 'unsigned message {}'.format(mid)
657 course=course, person=person, original=original,
658 subject=response_subject, text=(
659 'We received an email message from you without a valid\n'
662 raise ValueError('unsigned message from {}'.format(person.alias()))
665 def _get_message_subject(course, message, original, person,
666 respond=None, use_color=None):
668 >>> from email.header import Header
669 >>> from pgp_mime.email import encodedMIMEText
670 >>> message = encodedMIMEText('The answer is 42.')
671 >>> message['Message-ID'] = 'msg-id'
672 >>> _get_message_subject(
673 ... course=None, message=message, original=message, person=None)
674 Traceback (most recent call last):
676 ValueError: no subject in msg-id
677 >>> del message['Subject']
678 >>> subject = Header('unicode part', 'utf-8')
679 >>> subject.append('-ascii part', 'ascii')
680 >>> message['Subject'] = subject.encode()
681 >>> _get_message_subject(
682 ... course=None, message=message, original=message, person=None)
683 'unicode part-ascii part'
684 >>> del message['Subject']
685 >>> message['Subject'] = 'clean subject'
686 >>> _get_message_subject(
687 ... course=None, message=message, original=message, person=None)
690 if message['Subject'] is None:
691 mid = message['Message-ID']
692 response_subject = 'no subject in {}'.format(mid)
695 course=course, person=person, original=original,
696 subject=response_subject, text=(
697 'We received an email message from you without a subject.'
700 raise ValueError(response_subject)
702 parts = _decode_header(message['Subject'])
704 for string,encoding in parts:
707 if not isinstance(string, str):
708 string = str(string, encoding)
709 part_strings.append(string)
710 subject = ''.join(part_strings)
711 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
712 return subject.lower().replace('#', '')
714 def _get_message_target(course, message, original, person, subject,
715 respond=None, use_color=None):
717 >>> _get_message_target(course=None, message=None, original=None,
718 ... person=None, subject='no tag')
719 Traceback (most recent call last):
721 ValueError: no tag in 'no tag'
722 >>> _get_message_target(course=None, message=None, original=None,
723 ... person=None, subject='[] empty tag')
724 Traceback (most recent call last):
726 ValueError: empty tag in '[] empty tag'
727 >>> _get_message_target(course=None, message=None, original=None,
728 ... person=None, subject='[abc] empty tag')
730 >>> _get_message_target(course=None, message=None, original=None,
731 ... person=None, subject='[phys160:abc] empty tag')
734 match = _TAG_REGEXP.match(subject)
736 response_subject = 'no tag in {!r}'.format(subject)
739 course=course, person=person, original=original,
740 subject=response_subject, text=(
741 'We received an email message from you without\n'
744 raise ValueError(response_subject)
747 response_subject = 'empty tag in {!r}'.format(subject)
750 course=course, person=person, original=original,
751 subject=response_subject, text=(
752 'We received an email message from you with empty\n'
755 raise ValueError(response_subject)
756 target = tag.rsplit(':', 1)[-1]
757 _LOG.debug('extracted target {} -> {}'.format(subject, target))
760 def _get_handler(course, handlers, message, person, subject, target,
761 respond=None, use_color=None):
763 handler = handlers[target]
765 response_subject = 'no handler for {}'.format(target)
766 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
767 _LOG.debug(_color_string(string=response_subject, color=bad))
769 targets = sorted(handlers.keys())
772 'In fact, there are no available handlers for this\n'
776 'Perhaps you meant to use one of the following:\n'
777 ' {}\n\n').format('\n '.join(targets))
779 course=course, person=person, original=original,
780 subject=response_subject, text=(
781 'We got an email from you with the following subject:\n'
783 'which does not match any submittable handler name for\n'
785 '{}').format(repr(subject), course.name, hint),
790 def _get_verified_message(message, pgp_key, use_color=None):
793 >>> from pgp_mime import sign, encodedMIMEText
795 The student composes a message...
797 >>> message = encodedMIMEText('1.23 joules')
799 ... and signs it (with the pgp-mime test key).
801 >>> signed = sign(message, signers=['pgp-mime-test'])
803 As it is being delivered, the message picks up extra headers.
805 >>> signed['Message-ID'] = '<01234567@home.net>'
806 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
807 >>> signed['Received'] = 'from smtp.home.net ...'
809 We check that the message is signed, and that it is signed by the
812 >>> signed.authenticated
813 Traceback (most recent call last):
815 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
816 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
817 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
818 Content-Type: text/plain; charset="us-ascii"
820 Content-Transfer-Encoding: 7bit
821 Content-Disposition: inline
822 Message-ID: <01234567@home.net>
823 Received: from smtp.mail.uu.edu ...
824 Received: from smtp.home.net ...
827 >>> our_message.authenticated
830 If it is signed, but not by the right key, we get ``None``.
832 >>> print(_get_verified_message(signed, pgp_key='01234567'))
835 If it is not signed at all, we get ``None``.
837 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
840 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
841 mid = message['message-id']
843 decrypted,verified,result = _verify(message=message)
844 except (ValueError, AssertionError):
845 _LOG.warn(_color_string(
846 string='could not verify {} (not signed?)'.format(mid),
849 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
850 tree = _etree.fromstring(result.replace(b'\x00', b''))
852 for signature in tree.findall('.//signature'):
853 for fingerprint in signature.iterchildren('fpr'):
854 if fingerprint.text.endswith(pgp_key):
858 _LOG.warn(_color_string(
859 string='{} is not signed by the expected key'.format(mid),
863 sumhex = list(signature.iterchildren('summary'))[0].get('value')
864 summary = int(sumhex, 16)
866 _LOG.warn(_color_string(
867 string='{} has an unverified signature'.format(mid),
870 # otherwise, we may have an untrusted key. We'll count that
871 # as verified here, because the caller is explicity looking
872 # for signatures by this fingerprint.
873 for k,v in message.items(): # copy over useful headers
874 if k.lower() not in ['content-type',
876 'content-disposition',
879 decrypted.authenticated = True