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 "Incoming email processing."
19 from __future__ import absolute_import
21 from email import message_from_file as _message_from_file
22 from email.header import decode_header as _decode_header
23 import mailbox as _mailbox
27 from pgp_mime import verify as _verify
28 from lxml import etree as _etree
30 from . import LOG as _LOG
31 from .color import color_string as _color_string
32 from .color import standard_colors as _standard_colors
33 from .model.person import Person as _Person
35 from .handler import respond as _respond
36 from .handler.get import run as _handle_get
37 from .handler.submission import run as _handle_submission
40 _TAG_REGEXP = _re.compile('^.*\[([^]]*)\].*$')
43 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
44 output=None, max_late=0, handlers={
46 'submit': _handle_submission,
47 }, respond=None, use_color=None,
48 dry_run=False, **kwargs):
49 """Run from procmail to sort incomming submissions
51 For example, you can setup your ``.procmailrc`` like this::
57 LOGFILE=$MAILDIR/procmail.log
59 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
61 # Grab all incoming homeworks emails. This rule eats matching emails
62 # (i.e. no further procmail processing).
64 * ^Subject:.*\[phys160:submit]
65 | "$PYGRADE_MAILPIPE" mailpipe
67 If you don't want procmail to eat the message, you can use the
68 ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
70 >>> from asyncore import loop
71 >>> from io import StringIO
72 >>> from pgp_mime.email import encodedMIMEText
73 >>> from pygrader.test.course import StubCourse
74 >>> from pygrader.test.client import MessageSender
75 >>> from pygrader.test.server import SMTPServer
77 Messages with unrecognized ``Return-Path``\s are silently dropped:
79 >>> course = StubCourse()
80 >>> def process(peer, mailfrom, rcpttos, data):
82 ... basedir=course.basedir, course=course.course,
83 ... stream=StringIO(data), output=course.mailbox)
84 >>> message = encodedMIMEText('The answer is 42.')
85 >>> message['Message-ID'] = '<123.456@home.net>'
86 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
87 >>> message['Received'] = (
88 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
89 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
90 ... 'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
91 >>> message['From'] = 'Billy B <bb@greyhavens.net>'
92 >>> message['To'] = 'phys101 <phys101@tower.edu>'
93 >>> message['Subject'] = '[submit] assignment 1'
94 >>> messages = [message]
95 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
97 >>> course.print_tree() # doctest: +REPORT_UDIFF
100 If we add a valid ``Return-Path``, we get the expected delivery:
102 >>> server = SMTPServer(
103 ... ('localhost', 1025), None, process=process, count=1)
104 >>> del message['Return-Path']
105 >>> message['Return-Path'] = '<bb@greyhavens.net>'
106 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
108 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
110 Bilbo_Baggins/Assignment_1
111 Bilbo_Baggins/Assignment_1/mail
112 Bilbo_Baggins/Assignment_1/mail/cur
113 Bilbo_Baggins/Assignment_1/mail/new
114 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
115 Bilbo_Baggins/Assignment_1/mail/tmp
123 The last ``Received`` is used to timestamp the message:
125 >>> server = SMTPServer(
126 ... ('localhost', 1025), None, process=process, count=1)
127 >>> del message['Message-ID']
128 >>> message['Message-ID'] = '<abc.def@home.net>'
129 >>> del message['Received']
130 >>> message['Received'] = (
131 ... 'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
132 ... 'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
133 ... 'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
134 >>> message['Received'] = (
135 ... 'from smtp.home.net (smtp.home.net [123.456.123.456]) '
136 ... 'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
137 ... 'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
138 >>> messages = [message]
139 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
141 >>> course.print_tree() # doctest: +REPORT_UDIFF, +ELLIPSIS
143 Bilbo_Baggins/Assignment_1
144 Bilbo_Baggins/Assignment_1/late
145 Bilbo_Baggins/Assignment_1/mail
146 Bilbo_Baggins/Assignment_1/mail/cur
147 Bilbo_Baggins/Assignment_1/mail/new
148 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
149 Bilbo_Baggins/Assignment_1/mail/new/...:2,S
150 Bilbo_Baggins/Assignment_1/mail/tmp
159 You can send receipts to the acknowledge incoming messages, which
160 includes warnings about dropped messages (except for messages
161 without ``Return-Path`` and messages where the ``Return-Path``
162 email belongs to multiple ``People``. The former should only
163 occur with malicious emails, and the latter with improper pygrader
166 Response to a successful submission:
168 >>> def respond(message):
169 ... print('respond with:\\n{}'.format(message.as_string()))
170 >>> def process(peer, mailfrom, rcpttos, data):
172 ... basedir=course.basedir, course=course.course,
173 ... stream=StringIO(data), output=course.mailbox,
175 >>> server = SMTPServer(
176 ... ('localhost', 1025), None, process=process, count=1)
177 >>> del message['Message-ID']
178 >>> message['Message-ID'] = '<hgi.jlk@home.net>'
179 >>> messages = [message]
180 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
181 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
183 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
185 Content-Disposition: inline
187 From: Robot101 <phys101@tower.edu>
188 Reply-to: Robot101 <phys101@tower.edu>
189 To: Bilbo Baggins <bb@shire.org>
190 Subject: received Assignment 1 submission
192 --===============...==
193 Content-Type: multipart/mixed; boundary="===============...=="
196 --===============...==
197 Content-Type: text/plain; charset="us-ascii"
199 Content-Transfer-Encoding: 7bit
200 Content-Disposition: inline
204 We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
208 --===============...==
209 Content-Type: message/rfc822
212 Content-Type: text/plain; charset="us-ascii"
214 Content-Transfer-Encoding: 7bit
215 Content-Disposition: inline
216 From: Billy B <bb@greyhavens.net>
217 To: phys101 <phys101@tower.edu>
218 Subject: [submit] assignment 1
219 Return-Path: <bb@greyhavens.net>
220 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)
221 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)
222 Message-ID: <hgi.jlk@home.net>
225 --===============...==--
226 --===============...==
228 Content-Transfer-Encoding: 7bit
229 Content-Description: OpenPGP digital signature
230 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
232 -----BEGIN PGP SIGNATURE-----
233 Version: GnuPG v2.0.19 (GNU/Linux)
236 -----END PGP SIGNATURE-----
238 --===============...==--
240 Response to a submission on an unsubmittable assignment:
242 >>> server = SMTPServer(
243 ... ('localhost', 1025), None, process=process, count=1)
244 >>> del message['Subject']
245 >>> message['Subject'] = '[submit] attendance 1'
246 >>> messages = [message]
247 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
248 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
250 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
252 Content-Disposition: inline
254 From: Robot101 <phys101@tower.edu>
255 Reply-to: Robot101 <phys101@tower.edu>
256 To: Bilbo Baggins <bb@shire.org>
257 Subject: received invalid Attendance 1 submission
259 --===============...==
260 Content-Type: multipart/mixed; boundary="===============...=="
263 --===============...==
264 Content-Type: text/plain; charset="us-ascii"
266 Content-Transfer-Encoding: 7bit
267 Content-Disposition: inline
271 We received your submission for Attendance 1, but you are not
272 allowed to submit that assignment via email.
276 --===============...==
277 Content-Type: message/rfc822
280 Content-Type: text/plain; charset="us-ascii"
282 Content-Transfer-Encoding: 7bit
283 Content-Disposition: inline
284 From: Billy B <bb@greyhavens.net>
285 To: phys101 <phys101@tower.edu>
286 Return-Path: <bb@greyhavens.net>
287 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)
288 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)
289 Message-ID: <hgi.jlk@home.net>
290 Subject: [submit] attendance 1
293 --===============...==--
294 --===============...==
296 Content-Transfer-Encoding: 7bit
297 Content-Description: OpenPGP digital signature
298 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
300 -----BEGIN PGP SIGNATURE-----
301 Version: GnuPG v2.0.19 (GNU/Linux)
304 -----END PGP SIGNATURE-----
306 --===============...==--
308 Response to a bad subject:
310 >>> server = SMTPServer(
311 ... ('localhost', 1025), None, process=process, count=1)
312 >>> del message['Subject']
313 >>> message['Subject'] = 'need help for the first homework'
314 >>> messages = [message]
315 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
316 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
318 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
320 Content-Disposition: inline
322 From: Robot101 <phys101@tower.edu>
323 Reply-to: Robot101 <phys101@tower.edu>
324 To: Bilbo Baggins <bb@shire.org>
325 Subject: no tag in 'need help for the first homework'
327 --===============...==
328 Content-Type: multipart/mixed; boundary="===============...=="
331 --===============...==
332 Content-Type: text/plain; charset="us-ascii"
334 Content-Transfer-Encoding: 7bit
335 Content-Disposition: inline
339 We received an email message from you without
344 --===============...==
345 Content-Type: message/rfc822
348 Content-Type: text/plain; charset="us-ascii"
350 Content-Transfer-Encoding: 7bit
351 Content-Disposition: inline
352 From: Billy B <bb@greyhavens.net>
353 To: phys101 <phys101@tower.edu>
354 Return-Path: <bb@greyhavens.net>
355 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)
356 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)
357 Message-ID: <hgi.jlk@home.net>
358 Subject: need help for the first homework
361 --===============...==--
362 --===============...==
364 Content-Transfer-Encoding: 7bit
365 Content-Description: OpenPGP digital signature
366 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
368 -----BEGIN PGP SIGNATURE-----
369 Version: GnuPG v2.0.19 (GNU/Linux)
372 -----END PGP SIGNATURE-----
374 --===============...==--
376 Response to a missing subject:
378 >>> server = SMTPServer(
379 ... ('localhost', 1025), None, process=process, count=1)
380 >>> del message['Subject']
381 >>> messages = [message]
382 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
383 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
385 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
387 Content-Disposition: inline
389 From: Robot101 <phys101@tower.edu>
390 Reply-to: Robot101 <phys101@tower.edu>
391 To: Bilbo Baggins <bb@shire.org>
392 Subject: no subject in <hgi.jlk@home.net>
394 --===============...==
395 Content-Type: multipart/mixed; boundary="===============...=="
398 --===============...==
399 Content-Type: text/plain; charset="us-ascii"
401 Content-Transfer-Encoding: 7bit
402 Content-Disposition: inline
406 We received an email message from you without a subject.
410 --===============...==
411 Content-Type: message/rfc822
414 Content-Type: text/plain; charset="us-ascii"
416 Content-Transfer-Encoding: 7bit
417 Content-Disposition: inline
418 From: Billy B <bb@greyhavens.net>
419 To: phys101 <phys101@tower.edu>
420 Return-Path: <bb@greyhavens.net>
421 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)
422 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)
423 Message-ID: <hgi.jlk@home.net>
426 --===============...==--
427 --===============...==
429 Content-Transfer-Encoding: 7bit
430 Content-Description: OpenPGP digital signature
431 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
433 -----BEGIN PGP SIGNATURE-----
434 Version: GnuPG v2.0.19 (GNU/Linux)
437 -----END PGP SIGNATURE-----
439 --===============...==--
441 Response to an insecure message from a person with a PGP key:
443 >>> student = course.course.person(email='bb@greyhavens.net')
444 >>> student.pgp_key = '4332B6E3'
445 >>> server = SMTPServer(
446 ... ('localhost', 1025), None, process=process, count=1)
447 >>> del message['Subject']
448 >>> messages = [message]
449 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
450 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
452 Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; micalg="pgp-sha1"; boundary="===============...=="
454 Content-Disposition: inline
456 From: Robot101 <phys101@tower.edu>
457 Reply-to: Robot101 <phys101@tower.edu>
458 To: Bilbo Baggins <bb@shire.org>
459 Subject: unsigned message <hgi.jlk@home.net>
461 --===============...==
463 Content-Transfer-Encoding: 7bit
464 Content-Type: application/pgp-encrypted; charset="us-ascii"
468 --===============...==
470 Content-Transfer-Encoding: 7bit
471 Content-Description: OpenPGP encrypted message
472 Content-Type: application/octet-stream; name="encrypted.asc"; charset="us-ascii"
474 -----BEGIN PGP MESSAGE-----
475 Version: GnuPG v2.0.19 (GNU/Linux)
478 -----END PGP MESSAGE-----
480 --===============...==--
482 Response to a message from an unregistered person:
484 >>> server = SMTPServer(
485 ... ('localhost', 1025), None, process=process, count=1)
486 >>> del message['Return-Path']
487 >>> message['Return-Path'] = '<invalid.return.path@home.net>'
488 >>> messages = [message]
489 >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
490 >>> loop() # doctest: +REPORT_UDIFF, +ELLIPSIS
492 Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
494 Content-Disposition: inline
496 From: Robot101 <phys101@tower.edu>
497 Reply-to: Robot101 <phys101@tower.edu>
498 To: "invalid.return.path@home.net" <invalid.return.path@home.net>
499 Subject: unregistered address invalid.return.path@home.net
501 --===============...==
502 Content-Type: multipart/mixed; boundary="===============...=="
505 --===============...==
506 Content-Type: text/plain; charset="us-ascii"
508 Content-Transfer-Encoding: 7bit
509 Content-Disposition: inline
511 invalid.return.path@home.net,
513 Your email address is not registered with pygrader for
514 Physics 101. If you feel it should be, contact your professor
519 --===============...==
520 Content-Type: message/rfc822
523 Content-Type: text/plain; charset="us-ascii"
525 Content-Transfer-Encoding: 7bit
526 Content-Disposition: inline
527 From: Billy B <bb@greyhavens.net>
528 To: phys101 <phys101@tower.edu>
529 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)
530 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)
531 Message-ID: <hgi.jlk@home.net>
532 Return-Path: <invalid.return.path@home.net>
535 --===============...==--
536 --===============...==
538 Content-Transfer-Encoding: 7bit
539 Content-Description: OpenPGP digital signature
540 Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
542 -----BEGIN PGP SIGNATURE-----
543 Version: GnuPG v2.0.19 (GNU/Linux)
546 -----END PGP SIGNATURE-----
548 --===============...==--
552 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
555 for original,message,person,subject,target in _load_messages(
556 course=course, stream=stream, mailbox=mailbox, input_=input_,
557 output=output, respond=respond, use_color=use_color, dry_run=dry_run):
558 handler = _get_handler(
559 course=course, handlers=handlers, message=message, person=person,
560 subject=subject, target=target)
563 basedir=basedir, course=course, original=original,
564 message=message, person=person, subject=subject,
565 max_late=max_late, respond=respond,
566 use_color=use_color, dry_run=dry_run)
567 except ValueError as error:
568 _LOG.warn(_color_string(string=str(error), color=bad))
570 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
571 respond=None, use_color=None, dry_run=False):
574 messages = [(None,_message_from_file(stream))]
575 if output is not None:
576 ombox = _mailbox.Maildir(output, factory=None, create=True)
577 elif mailbox == 'mbox':
578 mbox = _mailbox.mbox(input_, factory=None, create=False)
579 messages = mbox.items()
580 if output is not None:
581 ombox = _mailbox.mbox(output, factory=None, create=True)
582 elif mailbox == 'maildir':
583 mbox = _mailbox.Maildir(input_, factory=None, create=False)
584 messages = mbox.items()
585 if output is not None:
586 ombox = _mailbox.Maildir(output, factory=None, create=True)
588 raise ValueError(mailbox)
589 for key,msg in messages:
590 ret = _parse_message(
591 course=course, message=msg, respond=respond, use_color=use_color)
593 if output is not None and dry_run is False:
594 # move message from input mailbox to output mailbox
600 def _parse_message(course, message, respond=None, use_color=None):
601 """Parse an incoming email and respond if neccessary.
603 Return ``(msg, person, assignment, time)`` on successful parsing.
604 Return ``None`` on failure.
606 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
609 person = _get_message_person(
610 course=course, message=message, original=original,
611 respond=respond, use_color=use_color)
613 message = _get_decoded_message(
614 course=course, message=message, original=original, person=person,
615 respond=respond, use_color=use_color)
616 subject = _get_message_subject(
617 course=course, message=message, original=original, person=person,
618 respond=respond, use_color=use_color)
619 target = _get_message_target(
620 course=course, message=message, original=original, person=person,
621 subject=subject, respond=respond, use_color=use_color)
622 except ValueError as error:
623 _LOG.debug(_color_string(string=str(error), color=bad))
625 return (original, message, person, subject, target)
627 def _get_message_person(course, message, original, respond=None,
629 mid = message['Message-ID']
630 sender = message['Return-Path'] # RFC 822
632 raise ValueError('no Return-Path in {}'.format(mid))
633 sender = sender[1:-1] # strip wrapping '<' and '>'
634 people = list(course.find_people(email=sender))
637 person = _Person(name=sender, emails=[sender])
638 response_subject = 'unregistered address {}'.format(sender)
640 course=course, person=person, original=original,
641 subject=response_subject, text=(
642 'Your email address is not registered with pygrader for\n'
643 '{}. If you feel it should be, contact your professor\n'
644 'or TA.').format(course.name),
646 raise ValueError('no person found to match {}'.format(sender))
648 raise ValueError('multiple people match {} ({})'.format(
649 sender, ', '.join(str(p) for p in people)))
652 def _get_decoded_message(course, message, original, person,
653 respond=None, use_color=None):
654 message = _get_verified_message(
655 message, person.pgp_key, use_color=use_color)
658 mid = original['Message-ID']
659 response_subject = 'unsigned message {}'.format(mid)
661 course=course, person=person, original=original,
662 subject=response_subject, text=(
663 'We received an email message from you without a valid\n'
666 raise ValueError('unsigned message from {}'.format(person.alias()))
669 def _get_message_subject(course, message, original, person,
670 respond=None, use_color=None):
672 >>> from email.header import Header
673 >>> from pgp_mime.email import encodedMIMEText
674 >>> message = encodedMIMEText('The answer is 42.')
675 >>> message['Message-ID'] = 'msg-id'
676 >>> _get_message_subject(
677 ... course=None, message=message, original=message, person=None)
678 Traceback (most recent call last):
680 ValueError: no subject in msg-id
681 >>> del message['Subject']
682 >>> subject = Header('unicode part', 'utf-8')
683 >>> subject.append('-ascii part', 'ascii')
684 >>> message['Subject'] = subject.encode()
685 >>> _get_message_subject(
686 ... course=None, message=message, original=message, person=None)
687 'unicode part-ascii part'
688 >>> del message['Subject']
689 >>> message['Subject'] = 'clean subject'
690 >>> _get_message_subject(
691 ... course=None, message=message, original=message, person=None)
694 if message['Subject'] is None:
695 mid = message['Message-ID']
696 response_subject = 'no subject in {}'.format(mid)
699 course=course, person=person, original=original,
700 subject=response_subject, text=(
701 'We received an email message from you without a subject.'
704 raise ValueError(response_subject)
706 parts = _decode_header(message['Subject'])
708 for string,encoding in parts:
711 if not isinstance(string, str):
712 string = str(string, encoding)
713 part_strings.append(string)
714 subject = ''.join(part_strings)
715 _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
716 return subject.lower().replace('#', '')
718 def _get_message_target(course, message, original, person, subject,
719 respond=None, use_color=None):
721 >>> _get_message_target(course=None, message=None, original=None,
722 ... person=None, subject='no tag')
723 Traceback (most recent call last):
725 ValueError: no tag in 'no tag'
726 >>> _get_message_target(course=None, message=None, original=None,
727 ... person=None, subject='[] empty tag')
728 Traceback (most recent call last):
730 ValueError: empty tag in '[] empty tag'
731 >>> _get_message_target(course=None, message=None, original=None,
732 ... person=None, subject='[abc] empty tag')
734 >>> _get_message_target(course=None, message=None, original=None,
735 ... person=None, subject='[phys160:abc] empty tag')
738 match = _TAG_REGEXP.match(subject)
740 response_subject = 'no tag in {!r}'.format(subject)
743 course=course, person=person, original=original,
744 subject=response_subject, text=(
745 'We received an email message from you without\n'
748 raise ValueError(response_subject)
751 response_subject = 'empty tag in {!r}'.format(subject)
754 course=course, person=person, original=original,
755 subject=response_subject, text=(
756 'We received an email message from you with empty\n'
759 raise ValueError(response_subject)
760 target = tag.rsplit(':', 1)[-1]
761 _LOG.debug('extracted target {} -> {}'.format(subject, target))
764 def _get_handler(course, handlers, message, person, subject, target,
765 respond=None, use_color=None):
767 handler = handlers[target]
769 response_subject = 'no handler for {}'.format(target)
770 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
771 _LOG.debug(_color_string(string=response_subject, color=bad))
773 targets = sorted(handlers.keys())
776 'In fact, there are no available handlers for this\n'
780 'Perhaps you meant to use one of the following:\n'
781 ' {}\n\n').format('\n '.join(targets))
783 course=course, person=person, original=original,
784 subject=response_subject, text=(
785 'We got an email from you with the following subject:\n'
787 'which does not match any submittable handler name for\n'
789 '{}').format(repr(subject), course.name, hint),
794 def _get_verified_message(message, pgp_key, use_color=None):
797 >>> from pgp_mime import sign, encodedMIMEText
799 The student composes a message...
801 >>> message = encodedMIMEText('1.23 joules')
803 ... and signs it (with the pgp-mime test key).
805 >>> signed = sign(message, signers=['pgp-mime-test'])
807 As it is being delivered, the message picks up extra headers.
809 >>> signed['Message-ID'] = '<01234567@home.net>'
810 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
811 >>> signed['Received'] = 'from smtp.home.net ...'
813 We check that the message is signed, and that it is signed by the
816 >>> signed.authenticated
817 Traceback (most recent call last):
819 AttributeError: 'MIMEMultipart' object has no attribute 'authenticated'
820 >>> our_message = _get_verified_message(signed, pgp_key='4332B6E3')
821 >>> print(our_message.as_string()) # doctest: +REPORT_UDIFF
822 Content-Type: text/plain; charset="us-ascii"
824 Content-Transfer-Encoding: 7bit
825 Content-Disposition: inline
826 Message-ID: <01234567@home.net>
827 Received: from smtp.mail.uu.edu ...
828 Received: from smtp.home.net ...
831 >>> our_message.authenticated
834 If it is signed, but not by the right key, we get ``None``.
836 >>> print(_get_verified_message(signed, pgp_key='01234567'))
839 If it is not signed at all, we get ``None``.
841 >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
844 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
845 mid = message['message-id']
847 decrypted,verified,result = _verify(message=message)
848 except (ValueError, AssertionError):
849 _LOG.warn(_color_string(
850 string='could not verify {} (not signed?)'.format(mid),
853 _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
854 tree = _etree.fromstring(result.replace(b'\x00', b''))
856 for signature in tree.findall('.//signature'):
857 for fingerprint in signature.iterchildren('fpr'):
858 if fingerprint.text.endswith(pgp_key):
862 _LOG.warn(_color_string(
863 string='{} is not signed by the expected key'.format(mid),
867 sumhex = list(signature.iterchildren('summary'))[0].get('value')
868 summary = int(sumhex, 16)
870 _LOG.warn(_color_string(
871 string='{} has an unverified signature'.format(mid),
874 # otherwise, we may have an untrusted key. We'll count that
875 # as verified here, because the caller is explicity looking
876 # for signatures by this fingerprint.
877 for k,v in message.items(): # copy over useful headers
878 if k.lower() not in ['content-type',
880 'content-disposition',
883 decrypted.authenticated = True