3 from __future__ import absolute_import
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
11 import os.path as _os_path
15 from pgp_mime import verify as _verify
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
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
30 For example, you can setup your ``.procmailrc`` like this::
36 LOGFILE=$MAILDIR/procmail.log
38 PYGRADE_MAILPIPE="pg.py -d $HOME/grades/phys160"
40 # Grab all incoming homeworks emails. This rule eats matching emails
41 # (i.e. no further procmail processing).
43 * ^Subject:.*\[phys160-sub]
44 | "$PYGRADE_MAILPIPE" mailpipe
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``.
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)
60 basedir=basedir, assignment=assignment, person=person, time=time,
61 max_late=max_late, use_color=use_color, dry_run=dry_run)
63 def _load_messages(course, stream, mailbox=None, input_=None, output=None,
64 use_color=None, dry_run=False):
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)
79 raise ValueError(mailbox)
80 for key,msg in messages:
82 course=course, msg=msg, use_color=use_color)
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
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
95 _LOG.debug(_color_string(
96 string='no Return-Path in {}'.format(mid), color=lowlight))
98 sender = sender[1:-1] # strip wrapping '<' and '>'
100 people = list(course.find_people(email=sender))
102 _LOG.warn(_color_string(
103 string='no person found to match {}'.format(sender),
107 _LOG.warn(_color_string(
108 string='multiple people match {} ({})'.format(
109 sender, ', '.join(str(p) for p in people)),
115 msg = _get_verified_message(msg, person.pgp_key, use_color=use_color)
119 if msg['Subject'] is None:
120 _LOG.warn(_color_string(
121 string='no subject in {}'.format(mid), color=bad))
123 parts = _decode_header(msg['Subject'])
125 _LOG.warn(_color_string(
126 string='multi-part header {}'.format(parts), color=bad))
128 subject,encoding = parts[0]
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):
136 if not _match_assignment(assignment, subject):
137 _LOG.warn(_color_string(
138 string='no assignment found in {}'.format(repr(subject)),
142 time = _message_time(message=msg, use_color=use_color)
143 return (msg, person, assignment, time)
145 def _match_assignment(assignment, subject):
146 return assignment.name.lower() in subject
148 def _save_local_message_copy(msg, person, assignment_path, use_color=None,
150 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
152 _os.makedirs(assignment_path)
155 mpath = _os_path.join(assignment_path, 'mail')
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),
166 for other_msg in mbox:
167 if other_msg['Message-ID'] == msg['Message-ID']:
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)
180 _LOG.debug(_color_string(
181 string='already found {} in {}'.format(
182 msg['Message-ID'], mpath), color=good))
184 def _check_late(basedir, assignment, person, time, max_late=0, use_color=None,
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.),
194 _set_late(basedir=basedir, assignment=assignment, person=person)
196 def _get_verified_message(message, pgp_key, use_color=None):
199 >>> from copy import deepcopy
200 >>> from pgp_mime import sign, encodedMIMEText
202 The student composes a message...
204 >>> message = encodedMIMEText('1.23 joules')
206 ... and signs it (with the pgp-mime test key).
208 >>> signed = sign(message, sign_as='4332B6E3')
210 As it is being delivered, the message picks up extra headers.
212 >>> signed['Message-ID'] = '<01234567@home.net>'
213 >>> signed['Received'] = 'from smtp.mail.uu.edu ...'
214 >>> signed['Received'] = 'from smtp.home.net ...'
216 We check that the message is signed, and that it is signed by the
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"
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 ...
232 If it is signed, but not by the right key, we get ``None``.
234 >>> print(_get_verified_message(
235 ... deepcopy(signed), pgp_key='01234567'))
238 If it is not signed at all, we get ``None``.
240 >>> print(_get_verified_message(
241 ... deepcopy(message), pgp_key='4332B6E3'))
244 highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
245 mid = message['message-id']
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),
253 _LOG.info(_color_string(gpg_message, color=lowlight))
255 _LOG.warn(_color_string(
256 string='{} has an invalid signature'.format(mid), color=bad))
259 for k,v in message.items(): # copy over useful headers
260 if k.lower() not in ['content-type',
262 'content-disposition',