Initial `respond` implementation in the `mailpipe` module.
[pygrader.git] / pygrader / mailpipe.py
1 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of pygrader.
4 #
5 # pygrader is free software: you can redistribute it and/or modify it under the
6 # terms of the GNU General Public License as published by the Free Software
7 # Foundation, either version 3 of the License, or (at your option) any later
8 # version.
9 #
10 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
12 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License along with
15 # pygrader.  If not, see <http://www.gnu.org/licenses/>.
16
17 from __future__ import absolute_import
18
19 from email import message_from_file as _message_from_file
20 from email.header import decode_header as _decode_header
21 from email.utils import formatdate as _formatdate
22 import hashlib as _hashlib
23 import locale as _locale
24 import mailbox as _mailbox
25 import os as _os
26 import os.path as _os_path
27 import sys as _sys
28 import time as _time
29
30 from pgp_mime import verify as _verify
31 from lxml import etree as _etree
32
33 from . import LOG as _LOG
34 from .color import standard_colors as _standard_colors
35 from .color import color_string as _color_string
36 from .email import construct_response as _construct_response
37 from .extract_mime import extract_mime as _extract_mime
38 from .extract_mime import message_time as _message_time
39 from .model.person import Person as _Person
40 from .storage import assignment_path as _assignment_path
41 from .storage import set_late as _set_late
42
43
44 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
45              output=None, max_late=0, respond=None, use_color=None,
46              dry_run=False, **kwargs):
47     """Run from procmail to sort incomming submissions
48
49     For example, you can setup your ``.procmailrc`` like this::
50
51       SHELL=/bin/sh
52       DEFAULT=$MAIL
53       MAILDIR=$HOME/mail
54       DEFAULT=$MAILDIR/mbox
55       LOGFILE=$MAILDIR/procmail.log
56       #VERBOSE=yes
57       PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
58
59       # Grab all incoming homeworks emails.  This rule eats matching emails
60       # (i.e. no further procmail processing).
61       :0
62       * ^Subject:.*\[phys160-sub]
63       | "$PYGRADE_MAILPIPE" mailpipe
64
65     If you don't want procmail to eat the message, you can use the
66     ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
67
68     >>> from asyncore import loop
69     >>> from io import StringIO
70     >>> from pgp_mime.email import encodedMIMEText
71     >>> from pygrader.test.course import StubCourse
72     >>> from pygrader.test.client import MessageSender
73     >>> from pygrader.test.server import SMTPServer
74
75     Messages with unrecognized ``Return-Path``\s are silently dropped:
76
77     >>> course = StubCourse()
78     >>> def process(peer, mailfrom, rcpttos, data):
79     ...     mailpipe(
80     ...         basedir=course.basedir, course=course.course,
81     ...         stream=StringIO(data), output=course.mailbox)
82     >>> message = encodedMIMEText('The answer is 42.')
83     >>> message['Message-ID'] = '<123.456@home.net>'
84     >>> message['Return-Path'] = '<invalid.return.path@home.net>'
85     >>> message['Received'] = (
86     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
87     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
88     ...     'for <wking@tremily.us>; Sun, 09 Oct 2011 11:50:46 -0400 (EDT)')
89     >>> message['From'] = 'Billy B <bb@greyhavens.net>'
90     >>> message['To'] = 'phys101 <phys101@tower.edu>'
91     >>> message['Subject'] = 'assignment 1 submission'
92     >>> messages = [message]
93     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
94     >>> loop()
95     >>> course.print_tree()  # doctest: +REPORT_UDIFF
96     course.conf
97
98     If we add a valid ``Return-Path``, we get the expected delivery:
99
100     >>> server = SMTPServer(
101     ...     ('localhost', 1025), None, process=process, count=1)
102     >>> del message['Return-Path']
103     >>> message['Return-Path'] = '<bb@greyhavens.net>'
104     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
105     >>> loop()
106     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
107     Bilbo_Baggins
108     Bilbo_Baggins/Assignment_1
109     Bilbo_Baggins/Assignment_1/mail
110     Bilbo_Baggins/Assignment_1/mail/cur
111     Bilbo_Baggins/Assignment_1/mail/new
112     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
113     Bilbo_Baggins/Assignment_1/mail/tmp
114     course.conf
115     mail
116     mail/cur
117     mail/new
118     mail/new/...
119     mail/tmp
120
121     The last ``Received`` is used to timestamp the message:
122
123     >>> server = SMTPServer(
124     ...     ('localhost', 1025), None, process=process, count=1)
125     >>> del message['Message-ID']
126     >>> message['Message-ID'] = '<abc.def@home.net>'
127     >>> del message['Received']
128     >>> message['Received'] = (
129     ...     'from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) '
130     ...     'by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 '
131     ...     'for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)')
132     >>> message['Received'] = (
133     ...     'from smtp.home.net (smtp.home.net [123.456.123.456]) '
134     ...     'by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF '
135     ...     'for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)')
136     >>> messages = [message]
137     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
138     >>> loop()
139     >>> course.print_tree()  # doctest: +REPORT_UDIFF, +ELLIPSIS
140     Bilbo_Baggins
141     Bilbo_Baggins/Assignment_1
142     Bilbo_Baggins/Assignment_1/late
143     Bilbo_Baggins/Assignment_1/mail
144     Bilbo_Baggins/Assignment_1/mail/cur
145     Bilbo_Baggins/Assignment_1/mail/new
146     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
147     Bilbo_Baggins/Assignment_1/mail/new/...:2,S
148     Bilbo_Baggins/Assignment_1/mail/tmp
149     course.conf
150     mail
151     mail/cur
152     mail/new
153     mail/new/...
154     mail/new/...
155     mail/tmp
156
157     You can send receipts to the acknowledge incoming messages, which
158     includes warnings about dropped messages (except for messages
159     without ``Return-Path`` and messages where the ``Return-Path``
160     email belongs to multiple ``People``.  Both of these cases should
161     only come from problems with pygrader configuration).
162
163     Response to a successful submission:
164
165     >>> def respond(message):
166     ...     print('respond with:\\n{}'.format(message.as_string()))
167     >>> def process(peer, mailfrom, rcpttos, data):
168     ...     mailpipe(
169     ...         basedir=course.basedir, course=course.course,
170     ...         stream=StringIO(data), output=course.mailbox,
171     ...         respond=respond)
172     >>> server = SMTPServer(
173     ...     ('localhost', 1025), None, process=process, count=1)
174     >>> del message['Message-ID']
175     >>> message['Message-ID'] = '<hgi.jlk@home.net>'
176     >>> messages = [message]
177     >>> ms = MessageSender(address=('localhost', 1025), messages=messages)
178     >>> loop()  # doctest: +REPORT_UDIFF, +ELLIPSIS
179     respond with:
180     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
181     MIME-Version: 1.0
182     Content-Disposition: inline
183     Date: ...
184     From: Robot101 <phys101@tower.edu>
185     Reply-to: Robot101 <phys101@tower.edu>
186     To: Bilbo Baggins <bb@shire.org>
187     Subject: received Assignment 1 submission
188     <BLANKLINE>
189     --===============...==
190     Content-Type: multipart/mixed; boundary="===============...=="
191     MIME-Version: 1.0
192     <BLANKLINE>
193     --===============...==
194     Content-Type: text/plain; charset="us-ascii"
195     MIME-Version: 1.0
196     Content-Transfer-Encoding: 7bit
197     Content-Disposition: inline
198     <BLANKLINE>
199     Billy,
200     <BLANKLINE>
201     We received your submission for Assignment 1 on Mon, 10 Oct 2011 16:50:46 -0000.
202     <BLANKLINE>
203     Yours,
204     phys-101 robot
205     --===============...==
206     Content-Type: message/rfc822
207     MIME-Version: 1.0
208     <BLANKLINE>
209     Content-Type: text/plain; charset="us-ascii"
210     MIME-Version: 1.0
211     Content-Transfer-Encoding: 7bit
212     Content-Disposition: inline
213     From: Billy B <bb@greyhavens.net>
214     To: phys101 <phys101@tower.edu>
215     Subject: assignment 1 submission
216     Return-Path: <bb@greyhavens.net>
217     Received: from smtp.mail.uu.edu (localhost.localdomain [127.0.0.1]) by smtp.mail.uu.edu (Postfix) with SMTP id 68CB45C8453 for <wking@tremily.us>; Mon, 10 Oct 2011 12:50:46 -0400 (EDT)
218     Received: from smtp.home.net (smtp.home.net [123.456.123.456]) by smtp.mail.uu.edu (Postfix) with ESMTP id 5BA225C83EF for <wking@tremily.us>; Mon, 09 Oct 2011 11:50:46 -0400 (EDT)
219     Message-ID: <hgi.jlk@home.net>
220     <BLANKLINE>
221     The answer is 42.
222     --===============...==--
223     --===============...==
224     MIME-Version: 1.0
225     Content-Transfer-Encoding: 7bit
226     Content-Description: OpenPGP digital signature
227     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
228     <BLANKLINE>
229     -----BEGIN PGP SIGNATURE-----
230     Version: GnuPG v2.0.17 (GNU/Linux)
231     <BLANKLINE>
232     ...
233     -----END PGP SIGNATURE-----
234     <BLANKLINE>
235     --===============...==--
236
237     Response to a bad subject:
238
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
246     respond with:
247     Content-Type: multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha1"; boundary="===============...=="
248     MIME-Version: 1.0
249     Content-Disposition: inline
250     Date: ...
251     From: Robot101 <phys101@tower.edu>
252     Reply-to: Robot101 <phys101@tower.edu>
253     To: Bilbo Baggins <bb@shire.org>
254     Subject: received 'need help for the first homework'
255     <BLANKLINE>
256     --===============...==
257     Content-Type: multipart/mixed; boundary="===============...=="
258     MIME-Version: 1.0
259     <BLANKLINE>
260     --===============...==
261     Content-Type: text/plain; charset="us-ascii"
262     MIME-Version: 1.0
263     Content-Transfer-Encoding: 7bit
264     Content-Disposition: inline
265     <BLANKLINE>
266     Billy,
267     <BLANKLINE>
268     We 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
271     phys101.
272     Remember to use the full name for the assignment in the
273     subject.  For example:
274       Assignment 1 submission
275     <BLANKLINE>
276     Yours,
277     phys-101 robot
278     --===============...==
279     Content-Type: message/rfc822
280     MIME-Version: 1.0
281     <BLANKLINE>
282     Content-Type: text/plain; charset="us-ascii"
283     MIME-Version: 1.0
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
293     <BLANKLINE>
294     The answer is 42.
295     --===============...==--
296     --===============...==
297     MIME-Version: 1.0
298     Content-Transfer-Encoding: 7bit
299     Content-Description: OpenPGP digital signature
300     Content-Type: application/pgp-signature; name="signature.asc"; charset="us-ascii"
301     <BLANKLINE>
302     -----BEGIN PGP SIGNATURE-----
303     Version: GnuPG v2.0.17 (GNU/Linux)
304     <BLANKLINE>
305     ...
306     -----END PGP SIGNATURE-----
307     <BLANKLINE>
308     --===============...==--
309
310     >>> del message['Return-Path']
311     >>> message['Return-Path'] = '<bb@greyhavens.net>'
312
313     >>> course.cleanup()
314     """
315     if stream is None:
316         stream = _sys.stdin
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)
325         _check_late(
326             basedir=basedir, assignment=assignment, person=person, time=time,
327             max_late=max_late, use_color=use_color, dry_run=dry_run)
328
329 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
330                    respond=None, use_color=None, dry_run=False):
331     if mailbox is None:
332         mbox = None
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)
346     else:
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)
351         if ret:
352             if output is not None and dry_run is False:
353                 # move message from input mailbox to output mailbox
354                 ombox.add(msg)
355                 if mbox is not None:
356                     del mbox[key]
357             yield ret
358
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
363     if sender is None:
364         _LOG.debug(_color_string(
365                 string='no Return-Path in {}'.format(mid), color=lowlight))
366         return None
367     sender = sender[1:-1]  # strip wrapping '<' and '>'
368     time = _message_time(message=msg, use_color=use_color)
369
370     if respond:
371         if time:
372             time_str = _formatdate(time)
373         else:
374             time_str = 'unknown time'
375         response_subject = 'received {} at {}'.format(mid, time_str)
376
377     people = list(course.find_people(email=sender))
378     if len(people) == 0:
379         _LOG.warn(_color_string(
380                 string='no person found to match {}'.format(sender),
381                 color=bad))
382         if respond:
383             person = _Person(name=None, emails=[sender])
384             response_text = (
385                 '{},\n\n'
386                 'Your email address is not registered with pygrader for\n'
387                 '{}.  If you feel it should be, contact your professor\n'
388                 'or TA.\n\n'
389                 'Yours,\n{}').format(
390                 sender, course.robot.alias())
391             response_text = 'Address {} is not registered for {}.'.format(
392                 sender, course.name)
393             response = _construct_response(
394                 author=course.robot, targets=[person],
395                 subject=response_subject, text=response_text, original=msg)
396             respond(response)
397         return None
398     if len(people) > 1:
399         _LOG.warn(_color_string(
400                 string='multiple people match {} ({})'.format(
401                     sender, ', '.join(str(p) for p in people)),
402                 color=bad))
403         return None
404     person = people[0]
405
406     if person.pgp_key:
407         msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
408         if msg is None:
409             if respond:
410                 response_text = (
411                     '{},\n\n'
412                     'We received an email message from you without a valid\n'
413                     'PGP signature.\n\n'
414                     'Yours,\n{}').format(
415                     person.alias(), course.robot.alias())
416                 response_text = 'Message not signed by {}.'.format(
417                     person.pgp_key)
418                 response = _construct_response(
419                     author=course.robot, targets=[person],
420                     subject=response_subject, text=response_text, original=msg)
421                 respond(response)
422             return None
423
424     if msg['Subject'] is None:
425         _LOG.warn(_color_string(
426                 string='no subject in {}'.format(mid), color=bad))
427         if respond:
428             response_text = (
429                 '{},\n\n'
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)
436             respond(response)
437         return None
438     parts = _decode_header(msg['Subject'])
439     if len(parts) != 1:
440         _LOG.warn(_color_string(
441                 string='multi-part header {}'.format(parts), color=bad))
442         return None
443     subject,encoding = parts[0]
444     if encoding is None:
445         encoding = 'ascii'
446     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
447
448     subject = subject.lower().replace('#', '')
449     for assignment in course.assignments:
450         if _match_assignment(assignment, subject):
451             break
452     if not _match_assignment(assignment, subject):
453         _LOG.warn(_color_string(
454                 string='no assignment found in {}'.format(repr(subject)),
455                 color=bad))
456         if respond:
457             response_subject = "received '{}'".format(subject)
458             submittable_assignments = [
459                 a for a in course.assignments if a.submittable]
460             if not submittable_assignments:
461                 hint = (
462                     'In fact, there are no submittable assignments for\n'
463                     'this course!\n')
464             else:
465                 hint = (
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)
470             response_text = (
471                 '{},\n\n'
472                 'We got an email from you with the following subject:\n'
473                 '  {}\n'
474                 'which does not match any submittable assignment name for\n'
475                 '{}.\n'
476                 '{}'
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)
483             respond(response)
484         return None
485
486     if respond:
487         response_subject = 'received {} submission'.format(assignment.name)
488         response_text = (
489             '{},\n\n'
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)
496         respond(response)
497     return (msg, person, assignment, time)
498
499 def _match_assignment(assignment, subject):
500     return assignment.name.lower() in subject
501
502 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
503                              dry_run=False):
504     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
505     try:
506         _os.makedirs(assignment_path)
507     except OSError:
508         pass
509     mpath = _os_path.join(assignment_path, 'mail')
510     try:
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),
515                 color=bad))
516         mbox = None
517         new_msg = True
518     else:
519         new_msg = True
520         for other_msg in mbox:
521             if other_msg['Message-ID'] == msg['Message-ID']:
522                 new_msg = False
523                 break
524     if new_msg:
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)
530             mdmsg.add_flag('S')
531             mbox.add(mdmsg)
532             mbox.close()
533     else:
534         _LOG.debug(_color_string(
535                 string='already found {} in {}'.format(
536                     msg['Message-ID'], mpath), color=good))
537
538 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
539                 dry_run=False):
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.),
546                 color=bad))
547         if not dry_run:
548             _set_late(basedir=basedir, assignment=assignment, person=person)
549
550 def _get_verified_message(message, pgp_key, use_color=None):
551     """
552
553     >>> from pgp_mime import sign, encodedMIMEText
554
555     The student composes a message...
556
557     >>> message = encodedMIMEText('1.23 joules')
558
559     ... and signs it (with the pgp-mime test key).
560
561     >>> signed = sign(message, signers=['pgp-mime-test'])
562
563     As it is being delivered, the message picks up extra headers.
564
565     >>> signed['Message-ID'] = '<01234567@home.net>'
566     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
567     >>> signed['Received'] = 'from smtp.home.net ...'
568
569     We check that the message is signed, and that it is signed by the
570     appropriate key.
571
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"
575     MIME-Version: 1.0
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 ...
581     <BLANKLINE>
582     1.23 joules
583
584     If it is signed, but not by the right key, we get ``None``.
585
586     >>> print(_get_verified_message(signed, pgp_key='01234567'))
587     None
588
589     If it is not signed at all, we get ``None``.
590
591     >>> print(_get_verified_message(message, pgp_key='4332B6E3'))
592     None
593     """
594     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
595     mid = message['message-id']
596     try:
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),
601                 color=bad))
602         return None
603     _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
604     tree = _etree.fromstring(result.replace(b'\x00', b''))
605     match = None
606     for signature in tree.findall('.//signature'):
607         for fingerprint in signature.iterchildren('fpr'):
608             if fingerprint.text.endswith(pgp_key):
609                 match = signature
610                 break
611     if match is None:
612         _LOG.warn(_color_string(
613                 string='{} is not signed by the expected key'.format(mid),
614                 color=bad))
615         return None
616     if not verified:
617         sumhex = list(signature.iterchildren('summary'))[0].get('value')
618         summary = int(sumhex, 16)
619         if summary != 0:
620             _LOG.warn(_color_string(
621                     string='{} has an unverified signature'.format(mid),
622                     color=bad))
623             return None
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',
629                              'mime-version',
630                              'content-disposition',
631                              ]:
632             decrypted[k] = v
633     return decrypted