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