Update to use pgp-mime v0.3
[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 import hashlib as _hashlib
22 import locale as _locale
23 import mailbox as _mailbox
24 import os as _os
25 import os.path as _os_path
26 import sys as _sys
27 import time as _time
28
29 from pgp_mime import verify as _verify
30 from lxml import etree as _etree
31
32 from . import LOG as _LOG
33 from .color import standard_colors as _standard_colors
34 from .color import color_string as _color_string
35 from .extract_mime import extract_mime as _extract_mime
36 from .extract_mime import message_time as _message_time
37 from .storage import assignment_path as _assignment_path
38 from .storage import set_late as _set_late
39
40
41 def mailpipe(basedir, course, stream=None, mailbox=None, input_=None,
42              output=None, max_late=0, use_color=None, dry_run=False, **kwargs):
43     """Run from procmail to sort incomming submissions
44
45     For example, you can setup your ``.procmailrc`` like this::
46
47       SHELL=/bin/sh
48       DEFAULT=$MAIL
49       MAILDIR=$HOME/mail
50       DEFAULT=$MAILDIR/mbox
51       LOGFILE=$MAILDIR/procmail.log
52       #VERBOSE=yes
53       PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
54
55       # Grab all incoming homeworks emails.  This rule eats matching emails
56       # (i.e. no further procmail processing).
57       :0
58       * ^Subject:.*\[phys160-sub]
59       | "$PYGRADE_MAILPIPE" mailpipe
60
61     If you don't want procmail to eat the message, you can use the
62     ``c`` flag (carbon copy) by starting your rule off with ``:0 c``.
63     """
64     if stream is None:
65         stream = _sys.stdin
66     for msg,person,assignment,time in _load_messages(
67         course=course, stream=stream, mailbox=mailbox, input_=input_,
68         output=output, use_color=use_color, dry_run=dry_run):
69         assignment_path = _assignment_path(basedir, assignment, person)
70         _save_local_message_copy(
71             msg=msg, person=person, assignment_path=assignment_path,
72             use_color=use_color, dry_run=dry_run)
73         _extract_mime(message=msg, output=assignment_path, dry_run=dry_run)
74         _check_late(
75             basedir=basedir, assignment=assignment, person=person, time=time,
76             max_late=max_late, use_color=use_color, dry_run=dry_run)
77
78 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
79                    use_color=None, dry_run=False):
80     if mailbox is None:
81         mbox = None
82         messages = [(None,_message_from_file(stream))]
83     elif mailbox == 'mbox':
84         mbox = _mailbox.mbox(input_, factory=None, create=False)
85         messages = mbox.items()
86         if output is not None:
87             ombox = _mailbox.mbox(output, factory=None, create=True)
88     elif mailbox == 'maildir':
89         mbox = _mailbox.Maildir(input_, factory=None, create=False)
90         messages = mbox.items()
91         if output is not None:
92             ombox = _mailbox.Maildir(output, factory=None, create=True)
93     else:
94         raise ValueError(mailbox)
95     for key,msg in messages:
96         ret = _parse_message(
97             course=course, msg=msg, use_color=use_color)
98         if ret:
99             if mbox is not None and output is not None and dry_run is False:
100                 # move message from input mailbox to output mailbox
101                 ombox.add(msg)
102                 del mbox[key]
103             yield ret
104
105 def _parse_message(course, msg, use_color=None):
106     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
107     mid = msg['Message-ID']
108     sender = msg['Return-Path']  # RFC 822
109     if sender is None:
110         _LOG.debug(_color_string(
111                 string='no Return-Path in {}'.format(mid), color=lowlight))
112         return None
113     sender = sender[1:-1]  # strip wrapping '<' and '>'
114
115     people = list(course.find_people(email=sender))
116     if len(people) == 0:
117         _LOG.warn(_color_string(
118                 string='no person found to match {}'.format(sender),
119                 color=bad))
120         return None
121     if len(people) > 1:
122         _LOG.warn(_color_string(
123                 string='multiple people match {} ({})'.format(
124                     sender, ', '.join(str(p) for p in people)),
125                 color=bad))
126         return None
127     person = people[0]
128
129     if person.pgp_key:
130         msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
131         if msg is None:
132             return None
133
134     if msg['Subject'] is None:
135         _LOG.warn(_color_string(
136                 string='no subject in {}'.format(mid), color=bad))
137         return None
138     parts = _decode_header(msg['Subject'])
139     if len(parts) != 1:
140         _LOG.warn(_color_string(
141                 string='multi-part header {}'.format(parts), color=bad))
142         return None
143     subject,encoding = parts[0]
144     if encoding is None:
145         encoding = 'ascii'
146     _LOG.debug('decoded header {} -> {}'.format(parts[0], subject))
147     subject = subject.lower().replace('#', '')
148     for assignment in course.assignments:
149         if _match_assignment(assignment, subject):
150             break
151     if not _match_assignment(assignment, subject):
152         _LOG.warn(_color_string(
153                 string='no assignment found in {}'.format(repr(subject)),
154                 color=bad))
155         return None
156
157     time = _message_time(message=msg, use_color=use_color)
158     return (msg, person, assignment, time)
159
160 def _match_assignment(assignment, subject):
161     return assignment.name.lower() in subject
162
163 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
164                              dry_run=False):
165     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
166     try:
167         _os.makedirs(assignment_path)
168     except OSError:
169         pass
170     mpath = _os_path.join(assignment_path, 'mail')
171     try:
172         mbox = _mailbox.Maildir(mpath, factory=None, create=not dry_run)
173     except _mailbox.NoSuchMailboxError as e:
174         _LOG.debug(_color_string(
175                 string='could not open mailbox at {}'.format(mpath),
176                 color=bad))
177         mbox = None
178         new_msg = True
179     else:
180         new_msg = True
181         for other_msg in mbox:
182             if other_msg['Message-ID'] == msg['Message-ID']:
183                 new_msg = False
184                 break
185     if new_msg:
186         _LOG.debug(_color_string(
187                 string='saving email from {} to {}'.format(
188                     person, assignment_path), color=good))
189         if mbox is not None and not dry_run:
190             mdmsg = _mailbox.MaildirMessage(msg)
191             mdmsg.add_flag('S')
192             mbox.add(mdmsg)
193             mbox.close()
194     else:
195         _LOG.debug(_color_string(
196                 string='already found {} in {}'.format(
197                     msg['Message-ID'], mpath), color=good))
198
199 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
200                 dry_run=False):
201     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
202     if time > assignment.due + max_late:
203         dt = time - assignment.due
204         _LOG.warn(_color_string(
205                 string='{} {} late by {} seconds ({} hours)'.format(
206                     person.name, assignment.name, dt, dt/3600.),
207                 color=bad))
208         if not dry_run:
209             _set_late(basedir=basedir, assignment=assignment, person=person)
210
211 def _get_verified_message(message, pgp_key, use_color=None):
212     """
213
214     >>> from copy import deepcopy
215     >>> from pgp_mime import sign, encodedMIMEText
216
217     The student composes a message...
218
219     >>> message = encodedMIMEText('1.23 joules')
220
221     ... and signs it (with the pgp-mime test key).
222
223     >>> signed = sign(message, signers=['pgp-mime-test'])
224
225     As it is being delivered, the message picks up extra headers.
226
227     >>> signed['Message-ID'] = '<01234567@home.net>'
228     >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
229     >>> signed['Received'] = 'from smtp.home.net ...'
230
231     We check that the message is signed, and that it is signed by the
232     appropriate key.
233
234     >>> our_message = _get_verified_message(
235     ...     deepcopy(signed), pgp_key='4332B6E3')
236     >>> print(our_message.as_string())  # doctest: +REPORT_UDIFF
237     Content-Type: text/plain; charset="us-ascii"
238     MIME-Version: 1.0
239     Content-Transfer-Encoding: 7bit
240     Content-Disposition: inline
241     Message-ID: <01234567@home.net>
242     Received: from smtp.mail.uu.edu ...
243     Received: from smtp.home.net ...
244     <BLANKLINE>
245     1.23 joules
246
247     If it is signed, but not by the right key, we get ``None``.
248
249     >>> print(_get_verified_message(
250     ...     deepcopy(signed), pgp_key='01234567'))
251     None
252
253     If it is not signed at all, we get ``None``.
254
255     >>> print(_get_verified_message(
256     ...     deepcopy(message), pgp_key='4332B6E3'))
257     None
258     """
259     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
260     mid = message['message-id']
261     try:
262         decrypted,verified,result = _verify(message=message)
263     except (ValueError, AssertionError):
264         _LOG.warn(_color_string(
265                 string='could not verify {} (not signed?)'.format(mid),
266                 color=bad))
267         return None
268     _LOG.info(_color_string(str(result, 'utf-8'), color=lowlight))
269     tree = _etree.fromstring(result.replace(b'\x00', b''))
270     match = None
271     for signature in tree.findall('.//signature'):
272         for fingerprint in signature.iterchildren('fpr'):
273             if fingerprint.text.endswith(pgp_key):
274                 match = signature
275                 break
276     if match is None:
277         _LOG.warn(_color_string(
278                 string='{} is not signed by the expected key'.format(mid),
279                 color=bad))
280         return None
281     if not verified:
282         sumhex = list(signature.iterchildren('summary'))[0].get('value')
283         summary = int(sumhex, 16)
284         if summary != 0:
285             _LOG.warn(_color_string(
286                     string='{} has an unverified signature'.format(mid),
287                     color=bad))
288             return None
289         # otherwise, we may have an untrusted key.  We'll count that
290         # as verified here, because the caller is explicity looking
291         # for signatures by this fingerprint.
292     for k,v in message.items(): # copy over useful headers
293         if k.lower() not in ['content-type',
294                              'mime-version',
295                              'content-disposition',
296                              ]:
297             decrypted[k] = v
298     return decrypted