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``. Both of these cases should
159 only come from problems with pygrader configuration).
161 Response to a successful submission:
163 >>> def respond(message):
164 ... print('respond with:\\n{}'.format(message.as_string()))
165 >>> def process(peer, mailfrom, rcpttos, data):
167 ... basedir=course.basedir, course=course.course,
168 ... stream=StringIO(data), output=course.mailbox,
170 >>> server = SMTPServer(
171 ... ('localhost', 1025), None, process=process, count=1)
172 >>> del message['Message-ID']
173 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
174 >>> messages = [message]
175 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
176 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
178 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
180 Content-Disposition: inline
182 From: Robot101 <phys101@tower.edu>
183 Reply-to: Robot101 <phys101@tower.edu>
184 To: Bilbo Baggins <bb@shire.org>
185 Subject: received Assignment 1 submission
187 --===============...==
188 Content-Type: multipart/mixed; boundary="===============...=="
191 --===============...==
192 Content-Type: text/plain; charset="us-ascii"
194 Content-Transfer-Encoding: 7bit
195 Content-Disposition: inline
199 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
203 --===============...==
204 Content-Type: message/rfc822
207 Content-Type: text/plain; charset="us-ascii"
209 Content-Transfer-Encoding: 7bit
210 Content-Disposition: inline
211 From: Billy B <bb@greyhavens.net>
212 To: phys101 <phys101@tower.edu>
213 Subject: [submit] assignment 1
214 Return-Path: <bb@greyhavens.net>
215 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)
216 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)
217 Message-ID: <hgi.jlk@home.net>
220 --===============...==--
221 --===============...==
223 Content-Transfer-Encoding: 7bit
224 Content-Description: OpenPGP digital signature
225 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
227 -----BEGIN PGP SIGNATURE-----
228 Version: GnuPG v2.0.19 (GNU/Linux)
231 -----END PGP SIGNATURE-----
233 --===============...==--
235 Response to a submission on an unsubmittable assignment:
237 >>> server = SMTPServer(
238 ... ('localhost', 1025), None, process=process, count=1)
239 >>> del message['Subject']
240 >>> message['Subject'] = '[submit] attendance 1'
241 >>> messages = [message]
242 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
243 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
245 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
247 Content-Disposition: inline
249 From: Robot101 <phys101@tower.edu>
250 Reply-to: Robot101 <phys101@tower.edu>
251 To: Bilbo Baggins <bb@shire.org>
252 Subject: received invalid Attendance 1 submission
254 --===============...==
255 Content-Type: multipart/mixed; boundary="===============...=="
258 --===============...==
259 Content-Type: text/plain; charset="us-ascii"
261 Content-Transfer-Encoding: 7bit
262 Content-Disposition: inline
266 We received your submission for Attendance 1, but you are not
267 allowed to submit that assignment via email.
271 --===============...==
272 Content-Type: message/rfc822
275 Content-Type: text/plain; charset="us-ascii"
277 Content-Transfer-Encoding: 7bit
278 Content-Disposition: inline
279 From: Billy B <bb@greyhavens.net>
280 To: phys101 <phys101@tower.edu>
281 Return-Path: <bb@greyhavens.net>
282 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)
283 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)
284 Message-ID: <hgi.jlk@home.net>
285 Subject: [submit] attendance 1
288 --===============...==--
289 --===============...==
291 Content-Transfer-Encoding: 7bit
292 Content-Description: OpenPGP digital signature
293 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
295 -----BEGIN PGP SIGNATURE-----
296 Version: GnuPG v2.0.19 (GNU/Linux)
299 -----END PGP SIGNATURE-----
301 --===============...==--
303 Response to a bad subject:
305 >>> server = SMTPServer(
306 ... ('localhost', 1025), None, process=process, count=1)
307 >>> del message['Subject']
308 >>> message['Subject'] = 'need help for the first homework'
309 >>> messages = [message]
310 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
311 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
313 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
315 Content-Disposition: inline
317 From: Robot101 <phys101@tower.edu>
318 Reply-to: Robot101 <phys101@tower.edu>
319 To: Bilbo Baggins <bb@shire.org>
320 Subject: no tag in 'need help for the first homework'
322 --===============...==
323 Content-Type: multipart/mixed; boundary="===============...=="
326 --===============...==
327 Content-Type: text/plain; charset="us-ascii"
329 Content-Transfer-Encoding: 7bit
330 Content-Disposition: inline
334 We received an email message from you without
339 --===============...==
340 Content-Type: message/rfc822
343 Content-Type: text/plain; charset="us-ascii"
345 Content-Transfer-Encoding: 7bit
346 Content-Disposition: inline
347 From: Billy B <bb@greyhavens.net>
348 To: phys101 <phys101@tower.edu>
349 Return-Path: <bb@greyhavens.net>
350 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)
351 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)
352 Message-ID: <hgi.jlk@home.net>
353 Subject: need help for the first homework
356 --===============...==--
357 --===============...==
359 Content-Transfer-Encoding: 7bit
360 Content-Description: OpenPGP digital signature
361 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
363 -----BEGIN PGP SIGNATURE-----
364 Version: GnuPG v2.0.19 (GNU/Linux)
367 -----END PGP SIGNATURE-----
369 --===============...==--
371 Response to a missing subject:
373 >>> server = SMTPServer(
374 ... ('localhost', 1025), None, process=process, count=1)
375 >>> del message['Subject']
376 >>> messages = [message]
377 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
378 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
380 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
382 Content-Disposition: inline
384 From: Robot101 <phys101@tower.edu>
385 Reply-to: Robot101 <phys101@tower.edu>
386 To: Bilbo Baggins <bb@shire.org>
387 Subject: no subject in <hgi.jlk@home.net>
389 --===============...==
390 Content-Type: multipart/mixed; boundary="===============...=="
393 --===============...==
394 Content-Type: text/plain; charset="us-ascii"
396 Content-Transfer-Encoding: 7bit
397 Content-Disposition: inline
401 We received an email message from you without a subject.
405 --===============...==
406 Content-Type: message/rfc822
409 Content-Type: text/plain; charset="us-ascii"
411 Content-Transfer-Encoding: 7bit
412 Content-Disposition: inline
413 From: Billy B <bb@greyhavens.net>
414 To: phys101 <phys101@tower.edu>
415 Return-Path: <bb@greyhavens.net>
416 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)
417 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)
418 Message-ID: <hgi.jlk@home.net>
421 --===============...==--
422 --===============...==
424 Content-Transfer-Encoding: 7bit
425 Content-Description: OpenPGP digital signature
426 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
428 -----BEGIN PGP SIGNATURE-----
429 Version: GnuPG v2.0.19 (GNU/Linux)
432 -----END PGP SIGNATURE-----
434 --===============...==--
436 Response to an insecure message from a person with a PGP key:
438 >>> student = course.course.person(email='bb@greyhavens.net')
439 >>> student.pgp_key = '4332B6E3'
440 >>> server = SMTPServer(
441 ... ('localhost', 1025), None, process=process, count=1)
442 >>> del message['Subject']
443 >>> messages = [message]
444 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
445 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
447 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
449 Content-Disposition: inline
451 From: Robot101 <phys101@tower.edu>
452 Reply-to: Robot101 <phys101@tower.edu>
453 To: Bilbo Baggins <bb@shire.org>
454 Subject: unsigned message <hgi.jlk@home.net>
456 --===============...==
458 Content-Transfer-Encoding: 7bit
459 Content-Type: application/pgp-encrypted; charset="us-ascii"
463 --===============...==
465 Content-Transfer-Encoding: 7bit
466 Content-Description: OpenPGP encrypted message
467 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
469 -----BEGIN PGP MESSAGE-----
470 Version: GnuPG v2.0.19 (GNU/Linux)
473 -----END PGP MESSAGE-----
475 --===============...==--
477 Response to a message from an unregistered person:
479 >>> server = SMTPServer(
480 ... ('localhost', 1025), None, process=process, count=1)
481 >>> del message['Return-Path']
482 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
483 >>> messages = [message]
484 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
485 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
487 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
489 Content-Disposition: inline
491 From: Robot101 <phys101@tower.edu>
492 Reply-to: Robot101 <phys101@tower.edu>
493 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
494 Subject: unregistered address invalid.return.path@home.net
496 --===============...==
497 Content-Type: multipart/mixed; boundary="===============...=="
500 --===============...==
501 Content-Type: text/plain; charset="us-ascii"
503 Content-Transfer-Encoding: 7bit
504 Content-Disposition: inline
506 invalid.return.path@home.net,
508 Your email address is not registered with pygrader for
509 Physics 101. If you feel it should be, contact your professor
514 --===============...==
515 Content-Type: message/rfc822
518 Content-Type: text/plain; charset="us-ascii"
520 Content-Transfer-Encoding: 7bit
521 Content-Disposition: inline
522 From: Billy B <bb@greyhavens.net>
523 To: phys101 <phys101@tower.edu>
524 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)
525 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)
526 Message-ID: <hgi.jlk@home.net>
527 Return-Path: <invalid.return.path@home.net>
530 --===============...==--
531 --===============...==
533 Content-Transfer-Encoding: 7bit
534 Content-Description: OpenPGP digital signature
535 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
537 -----BEGIN PGP SIGNATURE-----
538 Version: GnuPG v2.0.19 (GNU/Linux)
541 -----END PGP SIGNATURE-----
543 --===============...==--
547 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
550 for original,message,person,subject,target in _load_messages(
551 course=course, stream=stream, mailbox=mailbox, input_=input_,
552 output=output, respond=respond, use_color=use_color, dry_run=dry_run):
553 handler = _get_handler(
554 course=course, handlers=handlers, message=message, person=person,
555 subject=subject, target=target)
558 basedir=basedir, course=course, original=original,
559 message=message, person=person, subject=subject,
560 max_late=max_late, respond=respond,
561 use_color=use_color, dry_run=dry_run)
562 except ValueError as error:
563 _LOG.warn(_color_string(string=str(error), color=bad))
565 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
566 respond=None, use_color=None, dry_run=False):
569 messages = [(None,_message_from_file(stream))]
570 if output is not None:
571 ombox = _mailbox.Maildir(output, factory=None, create=True)
572 elif mailbox == 'mbox':
573 mbox = _mailbox.mbox(input_, factory=None, create=False)
574 messages = mbox.items()
575 if output is not None:
576 ombox = _mailbox.mbox(output, factory=None, create=True)
577 elif mailbox == 'maildir':
578 mbox = _mailbox.Maildir(input_, factory=None, create=False)
579 messages = mbox.items()
580 if output is not None:
581 ombox = _mailbox.Maildir(output, factory=None, create=True)
583 raise ValueError(mailbox)
584 for key,msg in messages:
585 ret = _parse_message(
586 course=course, message=msg, respond=respond, use_color=use_color)
588 if output is not None and dry_run is False:
589 # move message from input mailbox to output mailbox
595 def _parse_message(course, message, respond=None, use_color=None):
596 """Parse an incoming email and respond if neccessary.
598 Return ``(msg, person, assignment, time)`` on successful parsing.
599 Return ``None`` on failure.
601 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
604 person = _get_message_person(
605 course=course, message=message, original=original,
606 respond=respond, use_color=use_color)
608 message = _get_decoded_message(
609 course=course, message=message, original=original, person=person,
610 respond=respond, use_color=use_color)
611 subject = _get_message_subject(
612 course=course, message=message, original=original, person=person,
613 respond=respond, use_color=use_color)
614 target = _get_message_target(
615 course=course, message=message, original=original, person=person,
616 subject=subject, respond=respond, use_color=use_color)
617 except ValueError as error:
618 _LOG.debug(_color_string(string=str(error), color=bad))
620 return (original, message, person, subject, target)
622 def _get_message_person(course, message, original, respond=None,
624 mid = message['Message-ID']
625 sender = message['Return-Path'] # RFC 822
627 raise ValueError('no Return-Path in {}'.format(mid))
628 sender = sender[1:-1] # strip wrapping '<' and '>'
629 people = list(course.find_people(email=sender))
632 person = _Person(name=sender, emails=[sender])
633 response_subject = 'unregistered address {}'.format(sender)
635 course=course, person=person, original=original,
636 subject=response_subject, text=(
637 'Your email address is not registered with pygrader for\n'
638 '{}. If you feel it should be, contact your professor\n'
639 'or TA.').format(course.name),
641 raise ValueError('no person found to match {}'.format(sender))
643 raise ValueError('multiple people match {} ({})'.format(
644 sender, ', '.join(str(p) for p in people)))
647 def _get_decoded_message(course, message, original, person,
648 respond=None, use_color=None):
649 message = _get_verified_message(
650 message, person.pgp_key, use_color=use_color)
653 mid = original['Message-ID']
654 response_subject = 'unsigned message {}'.format(mid)
656 course=course, person=person, original=original,
657 subject=response_subject, text=(
658 'We received an email message from you without a valid\n'
661 raise ValueError('unsigned message from {}'.format(person.alias()))
664 def _get_message_subject(course, message, original, person,
665 respond=None, use_color=None):
667 >>> from email.header import Header
668 >>> from pgp_mime.email import encodedMIMEText
669 >>> message = encodedMIMEText('The answer is 42.')
670 >>> message['Message-ID'] = 'msg-id'
671 >>> _get_message_subject(
672 ... course=None, message=message, original=message, person=None)
673 Traceback (most recent call last):
675 ValueError: no subject in msg-id
676 >>> del message['Subject']
677 >>> subject = Header('unicode part', 'utf-8')
678 >>> subject.append('ascii part', 'ascii')
679 >>> message['Subject'] = subject.encode()
680 >>> _get_message_subject(
681 ... course=None, message=message, original=message, person=None)
682 Traceback (most recent call last):
684 ValueError: multi-part header [(b'unicode part', 'utf-8'), (b'ascii part', None)]
685 >>> del message['Subject']
686 >>> message['Subject'] = 'clean subject'
687 >>> _get_message_subject(
688 ... course=None, message=message, original=message, person=None)
691 if message['Subject'] is None:
692 mid = message['Message-ID']
693 response_subject = 'no subject in {}'.format(mid)
696 course=course, person=person, original=original,
697 subject=response_subject, text=(
698 'We received an email message from you without a subject.'
701 raise ValueError(response_subject)
703 parts = _decode_header(message['Subject'])
705 raise ValueError('multi-part header {}'.format(parts))
706 subject,encoding = parts[0]
709 if not isinstance(subject, str):
710 subject = str(subject, encoding)
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 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
813 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
814 Content-Type: text/plain; charset="us-ascii"
816 Content-Transfer-Encoding: 7bit
817 Content-Disposition: inline
818 Message-ID: <01234567@home.net>
819 Received: from smtp.mail.uu.edu ...
820 Received: from smtp.home.net ...
824 If it is signed, but not by the right key, we get ``None``.
826 >>> print(_get_verified_message(signed, pgp_key='01234567'))
829 If it is not signed at all, we get ``None``.
831 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
834 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
835 mid = message['message-id']
837 decrypted,verified,result = _verify(message=message)
838 except (ValueError, AssertionError):
839 _LOG.warn(_color_string(
840 string='could not verify {} (not signed?)'.format(mid),
843 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
844 tree = _etree.fromstring(result.replace(b'\x00', b''))
846 for signature in tree.findall('.//signature'):
847 for fingerprint in signature.iterchildren('fpr'):
848 if fingerprint.text.endswith(pgp_key):
852 _LOG.warn(_color_string(
853 string='{} is not signed by the expected key'.format(mid),
857 sumhex = list(signature.iterchildren('summary'))[0].get('value')
858 summary = int(sumhex, 16)
860 _LOG.warn(_color_string(
861 string='{} has an unverified signature'.format(mid),
864 # otherwise, we may have an untrusted key. We'll count that
865 # as verified here, because the caller is explicity looking
866 # for signatures by this fingerprint.
867 for k,v in message.items(): # copy over useful headers
868 if k.lower() not in ['content-type',
870 'content-disposition',