color: add ColoredFormatter for more transparent coloring.
[pygrader.git] / pygrader / email.py
1 # -*- coding: utf-8 -*-
2 # Copyright (C) 2012 W. Trevor King <wking@drexel.edu>
3 #
4 # This file is part of pygrader.
5 #
6 # pygrader is free software: you can redistribute it and/or modify it under the
7 # terms of the GNU General Public License as published by the Free Software
8 # Foundation, either version 3 of the License, or (at your option) any later
9 # version.
10 #
11 # pygrader is distributed in the hope that it will be useful, but WITHOUT ANY
12 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
13 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along with
16 # pygrader.  If not, see <http://www.gnu.org/licenses/>.
17
18 from __future__ import absolute_import
19
20 from email.header import Header as _Header
21 from email.header import decode_header as _decode_header
22 from email.mime.message import MIMEMessage as _MIMEMessage
23 from email.mime.multipart import MIMEMultipart as _MIMEMultipart
24 import email.utils as _email_utils
25 import logging as _logging
26 import smtplib as _smtplib
27
28 import pgp_mime as _pgp_mime
29
30 from . import ENCODING as _ENCODING
31 from . import LOG as _LOG
32 from .model.person import Person as _Person
33
34
35 def test_smtp(smtp, author, targets, msg=None):
36     """Test the SMTP connection by sending a message to `target`
37     """
38     if msg is None:
39         msg = _pgp_mime.encodedMIMEText('Success!')
40         msg['Date'] = _email_utils.formatdate()
41         msg['From'] = author
42         msg['Reply-to'] = msg['From']
43         msg['To'] = ', '.join(targets)
44         msg['Subject'] = 'Testing pygrader SMTP connection'
45     _LOG.info('send test message to SMTP server')
46     smtp.send_message(msg=msg)
47 test_smtp.__test__ = False  # not a test for nose
48
49 def send_emails(emails, smtp=None, debug_target=None, dry_run=False):
50     """Iterate through `emails` and mail them off one-by-one
51
52     >>> from email.mime.text import MIMEText
53     >>> from sys import stdout
54     >>> emails = []
55     >>> for target in ['Moneypenny <mp@sis.gov.uk>', 'M <m@sis.gov.uk>']:
56     ...     msg = MIMEText('howdy!', 'plain', 'us-ascii')
57     ...     msg['From'] = 'John Doe <jdoe@a.gov.ru>'
58     ...     msg['To'] = target
59     ...     msg['Bcc'] = 'James Bond <007@sis.gov.uk>'
60     ...     emails.append(
61     ...         (msg,
62     ...          lambda status: stdout.write('SUCCESS: {}\\n'.format(status))))
63     >>> send_emails(emails, dry_run=True)
64     ... # doctest: +REPORT_UDIFF, +NORMALIZE_WHITESPACE
65     SUCCESS: None
66     SUCCESS: None
67     """
68     local_smtp = smtp is None
69     for msg,callback in emails:
70         sources = [
71             _email_utils.formataddr(a) for a in _pgp_mime.email_sources(msg)]
72         author = sources[0]
73         targets = [
74             _email_utils.formataddr(a) for a in _pgp_mime.email_targets(msg)]
75         _pgp_mime.strip_bcc(msg)
76         if _LOG.level <= _logging.DEBUG:
77             # TODO: remove convert_content_transfer_encoding?
78             #if msg.get('content-transfer-encoding', None) == 'base64':
79             #    convert_content_transfer_encoding(msg, '8bit')
80             _LOG.debug('\n{}\n'.format(msg.as_string()))
81         _LOG.info('sending message to {}...'.format(targets))
82         if not dry_run:
83             try:
84                 if local_smtp:
85                     smtp = _smtplib.SMTP('localhost')
86                 if debug_target:
87                     targets = [debug_target]
88                 smtp.sendmail(author, targets, msg.as_string())
89                 if local_smtp:
90                     smtp.quit()
91             except:
92                 _LOG.warning('failed to send message to {}'.format(targets))
93                 if callback:
94                     callback(False)
95                 raise
96             else:
97                 _LOG.info('sent message to {}'.format(targets))
98                 if callback:
99                     callback(True)
100         else:
101             _LOG.info('dry run, so no message sent to {}'.format(targets))
102             if callback:
103                 callback(None)
104
105
106 class Responder (object):
107     def __init__(self, *args, **kwargs):
108         self.args = args
109         if kwargs is None:
110             kwargs = {}
111         self.kwargs = kwargs
112
113     def __call__(self, message):
114         send_emails([(message, None)], *self.args, **self.kwargs)
115
116
117 def get_address(person, header=False):
118     r"""
119     >>> from pygrader.model.person import Person as Person
120     >>> p = Person(name='Jack', emails=['a@b.net'])
121     >>> get_address(p)
122     'Jack <a@b.net>'
123
124     Here's a simple unicode example.
125
126     >>> p.name = '✉'
127     >>> get_address(p)
128     '✉ <a@b.net>'
129
130     When you encode addresses that you intend to place in an email
131     header, you should set the `header` option to `True`.  This
132     encodes the name portion of the address without encoding the email
133     portion.
134
135     >>> get_address(p, header=True)
136     '=?utf-8?b?4pyJ?= <a@b.net>'
137
138     Note that the address is in the clear.  Without the `header`
139     option you'd have to rely on something like:
140
141     >>> from email.header import Header
142     >>> Header(get_address(p), 'utf-8').encode()
143     '=?utf-8?b?4pyJIDxhQGIubmV0Pg==?='
144
145     This can cause trouble when your mailer tries to decode the name
146     following :RFC:`2822`, which limits the locations in which encoded
147     words may appear.
148     """
149     if header:
150         encoding = _pgp_mime.guess_encoding(person.name)
151         if encoding == 'us-ascii':
152             name = person.name
153         else:
154             name = _Header(person.name, encoding).encode()
155         return _email_utils.formataddr((name, person.emails[0]))
156     return _email_utils.formataddr((person.name, person.emails[0]))
157
158 def construct_email(author, targets, subject, message, cc=None):
159     if author.pgp_key:
160         signers = [author.pgp_key]
161     else:
162         signers = []
163     recipients = [p.pgp_key for p in targets if p.pgp_key]
164     encrypt = True
165     for person in targets:
166         if not person.pgp_key:
167             encrypt = False  # cannot encrypt to every recipient
168             break
169     if cc:
170         recipients.extend([p.pgp_key for p in cc if p.pgp_key])
171         for person in cc:
172             if not person.pgp_key:
173                 encrypt = False
174                 break
175     if not recipients:
176         encrypt = False  # noone to encrypt to
177     if signers and encrypt:
178         if author.pgp_key not in recipients:
179             recipients.append(author.pgp_key)
180         message = _pgp_mime.sign_and_encrypt(
181             message=message, signers=signers, recipients=recipients,
182             always_trust=True)
183     elif signers:
184         message = _pgp_mime.sign(message=message, signers=signers)
185     elif encrypt:
186         message = _pgp_mime.encrypt(message=message, recipients=recipients)
187
188     message['Date'] = _email_utils.formatdate()
189     message['From'] = get_address(author, header=True)
190     message['Reply-to'] = message['From']
191     message['To'] = ', '.join(
192         get_address(target, header=True) for target in targets)
193     if cc:
194         message['Cc'] = ', '.join(
195             get_address(target, header=True) for target in cc)
196     subject_encoding = _pgp_mime.guess_encoding(subject)
197     if subject_encoding == 'us-ascii':
198         message['Subject'] = subject
199     else:
200         message['Subject'] = _Header(subject, subject_encoding)
201
202     return message
203
204 def construct_text_email(author, targets, subject, text, cc=None):
205     r"""Build a text/plain email using `Person` instances
206
207     >>> from pygrader.model.person import Person as Person
208     >>> author = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
209     >>> targets = [Person(name='Jill', emails=['c@d.net'])]
210     >>> cc = [Person(name='H.D.', emails=['hd@wall.net'])]
211     >>> msg = construct_text_email(author, targets, cc=cc,
212     ...     subject='Once upon a time', text='Bla bla bla...')
213     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
214     Content-Type: text/plain; charset="us-ascii"
215     MIME-Version: 1.0
216     Content-Transfer-Encoding: 7bit
217     Content-Disposition: inline
218     Date: ...
219     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
220     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
221     To: Jill <c@d.net>
222     Cc: "H.D." <hd@wall.net>
223     Subject: Once upon a time
224     <BLANKLINE>
225     Bla bla bla...
226
227     With unicode text:
228
229     >>> msg = construct_text_email(author, targets, cc=cc,
230     ...     subject='Once upon a time', text='Funky ✉.')
231     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
232     Content-Type: text/plain; charset="utf-8"
233     MIME-Version: 1.0
234     Content-Transfer-Encoding: base64
235     Content-Disposition: inline
236     Date: ...
237     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
238     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
239     To: Jill <c@d.net>
240     Cc: "H.D." <hd@wall.net>
241     Subject: Once upon a time
242     <BLANKLINE>
243     RnVua3kg4pyJLg==
244     <BLANKLINE>
245     """
246     message = _pgp_mime.encodedMIMEText(text)
247     return construct_email(
248         author=author, targets=targets, subject=subject, message=message,
249         cc=cc)
250
251 def construct_response(author, targets, subject, text, original, cc=None):
252     r"""Build a multipart/mixed response email using `Person` instances
253
254     >>> from pygrader.model.person import Person as Person
255     >>> student = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
256     >>> assistant = Person(name='Jill', emails=['c@d.net'])
257     >>> cc = [assistant]
258     >>> msg = construct_text_email(author=student, targets=[assistant],
259     ...     subject='Assignment 1 submission', text='Bla bla bla...')
260     >>> rsp = construct_response(author=assistant, targets=[student],
261     ...     subject='Received assignment 1 submission', text='3 hours late',
262     ...     original=msg)
263     >>> print(rsp.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
264     Content-Type: multipart/mixed; boundary="===============...=="
265     MIME-Version: 1.0
266     Date: ...
267     From: Jill <c@d.net>
268     Reply-to: Jill <c@d.net>
269     To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
270     Subject: Received assignment 1 submission
271     <BLANKLINE>
272     --===============...==
273     Content-Type: text/plain; charset="us-ascii"
274     MIME-Version: 1.0
275     Content-Transfer-Encoding: 7bit
276     Content-Disposition: inline
277     <BLANKLINE>
278     3 hours late
279     --===============...==
280     Content-Type: message/rfc822
281     MIME-Version: 1.0
282     <BLANKLINE>
283     Content-Type: text/plain; charset="us-ascii"
284     MIME-Version: 1.0
285     Content-Transfer-Encoding: 7bit
286     Content-Disposition: inline
287     Date: ...
288     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
289     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
290     To: Jill <c@d.net>
291     Subject: Assignment 1 submission
292     <BLANKLINE>
293     Bla bla bla...
294     --===============...==--
295     """
296     message = _MIMEMultipart('mixed')
297     message.attach(_pgp_mime.encodedMIMEText(text))
298     message.attach(_MIMEMessage(original))
299     return construct_email(
300         author=author, targets=targets, subject=subject, message=message,
301         cc=cc)