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