3a463660eeb7d38b8332bfe8704899bb1a8278cf
[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 .color import standard_colors as _standard_colors
33 from .color import color_string as _color_string
34 from .color import write_color as _write_color
35 from .model.person import Person as _Person
36
37
38 def test_smtp(smtp, author, targets, msg=None):
39     """Test the SMTP connection by sending a message to `target`
40     """
41     if msg is None:
42         msg = _pgp_mime.encodedMIMEText('Success!')
43         msg['Date'] = _email_utils.formatdate()
44         msg['From'] = author
45         msg['Reply-to'] = msg['From']
46         msg['To'] = ', '.join(targets)
47         msg['Subject'] = 'Testing pygrader SMTP connection'
48     _LOG.info('send test message to SMTP server')
49     smtp.send_message(msg=msg)
50 test_smtp.__test__ = False  # not a test for nose
51
52 def send_emails(emails, smtp=None, use_color=None, debug_target=None,
53                 dry_run=False):
54     """Iterate through `emails` and mail them off one-by-one
55
56     >>> from email.mime.text import MIMEText
57     >>> from sys import stdout
58     >>> emails = []
59     >>> for target in ['Moneypenny <mp@sis.gov.uk>', 'M <m@sis.gov.uk>']:
60     ...     msg = MIMEText('howdy!', 'plain', 'us-ascii')
61     ...     msg['From'] = 'John Doe <jdoe@a.gov.ru>'
62     ...     msg['To'] = target
63     ...     msg['Bcc'] = 'James Bond <007@sis.gov.uk>'
64     ...     emails.append(
65     ...         (msg,
66     ...          lambda status: stdout.write('SUCCESS: {}\\n'.format(status))))
67     >>> send_emails(emails, use_color=False, dry_run=True)
68     ... # doctest: +REPORT_UDIFF, +NORMALIZE_WHITESPACE
69     sending message to ['Moneypenny <mp@sis.gov.uk>', 'James Bond <007@sis.gov.uk>']...\tDRY-RUN
70     SUCCESS: None
71     sending message to ['M <m@sis.gov.uk>', 'James Bond <007@sis.gov.uk>']...\tDRY-RUN
72     SUCCESS: None
73     """
74     local_smtp = smtp is None
75     highlight,lowlight,good,bad = _standard_colors(use_color=use_color)
76     for msg,callback in emails:
77         sources = [
78             _email_utils.formataddr(a) for a in _pgp_mime.email_sources(msg)]
79         author = sources[0]
80         targets = [
81             _email_utils.formataddr(a) for a in _pgp_mime.email_targets(msg)]
82         _pgp_mime.strip_bcc(msg)
83         if _LOG.level <= _logging.DEBUG:
84             # TODO: remove convert_content_transfer_encoding?
85             #if msg.get('content-transfer-encoding', None) == 'base64':
86             #    convert_content_transfer_encoding(msg, '8bit')
87             _LOG.debug(_color_string(
88                     '\n{}\n'.format(msg.as_string()), color=lowlight))
89         _write_color('sending message to {}...'.format(targets),
90                      color=highlight)
91         if not dry_run:
92             try:
93                 if local_smtp:
94                     smtp = _smtplib.SMTP('localhost')
95                 if debug_target:
96                     targets = [debug_target]
97                 smtp.sendmail(author, targets, msg.as_string())
98                 if local_smtp:
99                     smtp.quit()
100             except:
101                 _write_color('\tFAILED\n', bad)
102                 if callback:
103                     callback(False)
104                 raise
105             else:
106                 _write_color('\tOK\n', good)
107                 if callback:
108                     callback(True)
109         else:
110             _write_color('\tDRY-RUN\n', good)
111             if callback:
112                 callback(None)
113
114
115 class Responder (object):
116     def __init__(self, *args, **kwargs):
117         self.args = args
118         if kwargs is None:
119             kwargs = {}
120         self.kwargs = kwargs
121
122     def __call__(self, message):
123         send_emails([(message, None)], *self.args, **self.kwargs)
124
125
126 def get_address(person, header=False):
127     r"""
128     >>> from pygrader.model.person import Person as Person
129     >>> p = Person(name='Jack', emails=['a@b.net'])
130     >>> get_address(p)
131     'Jack <a@b.net>'
132
133     Here's a simple unicode example.
134
135     >>> p.name = '✉'
136     >>> get_address(p)
137     '✉ <a@b.net>'
138
139     When you encode addresses that you intend to place in an email
140     header, you should set the `header` option to `True`.  This
141     encodes the name portion of the address without encoding the email
142     portion.
143
144     >>> get_address(p, header=True)
145     '=?utf-8?b?4pyJ?= <a@b.net>'
146
147     Note that the address is in the clear.  Without the `header`
148     option you'd have to rely on something like:
149
150     >>> from email.header import Header
151     >>> Header(get_address(p), 'utf-8').encode()
152     '=?utf-8?b?4pyJIDxhQGIubmV0Pg==?='
153
154     This can cause trouble when your mailer tries to decode the name
155     following :RFC:`2822`, which limits the locations in which encoded
156     words may appear.
157     """
158     if header:
159         encoding = _pgp_mime.guess_encoding(person.name)
160         if encoding == 'us-ascii':
161             name = person.name
162         else:
163             name = _Header(person.name, encoding).encode()
164         return _email_utils.formataddr((name, person.emails[0]))
165     return _email_utils.formataddr((person.name, person.emails[0]))
166
167 def construct_email(author, targets, subject, message, cc=None):
168     if author.pgp_key:
169         signers = [author.pgp_key]
170     else:
171         signers = []
172     recipients = [p.pgp_key for p in targets if p.pgp_key]
173     encrypt = True
174     for person in targets:
175         if not person.pgp_key:
176             encrypt = False  # cannot encrypt to every recipient
177             break
178     if cc:
179         recipients.extend([p.pgp_key for p in cc if p.pgp_key])
180         for person in cc:
181             if not person.pgp_key:
182                 encrypt = False
183                 break
184     if not recipients:
185         encrypt = False  # noone to encrypt to
186     if signers and encrypt:
187         if author.pgp_key not in recipients:
188             recipients.append(author.pgp_key)
189         message = _pgp_mime.sign_and_encrypt(
190             message=message, signers=signers, recipients=recipients,
191             always_trust=True)
192     elif signers:
193         message = _pgp_mime.sign(message=message, signers=signers)
194     elif encrypt:
195         message = _pgp_mime.encrypt(message=message, recipients=recipients)
196
197     message['Date'] = _email_utils.formatdate()
198     message['From'] = get_address(author, header=True)
199     message['Reply-to'] = message['From']
200     message['To'] = ', '.join(
201         get_address(target, header=True) for target in targets)
202     if cc:
203         message['Cc'] = ', '.join(
204             get_address(target, header=True) for target in cc)
205     subject_encoding = _pgp_mime.guess_encoding(subject)
206     if subject_encoding == 'us-ascii':
207         message['Subject'] = subject
208     else:
209         message['Subject'] = _Header(subject, subject_encoding)
210
211     return message
212
213 def construct_text_email(author, targets, subject, text, cc=None):
214     r"""Build a text/plain email using `Person` instances
215
216     >>> from pygrader.model.person import Person as Person
217     >>> author = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
218     >>> targets = [Person(name='Jill', emails=['c@d.net'])]
219     >>> cc = [Person(name='H.D.', emails=['hd@wall.net'])]
220     >>> msg = construct_text_email(author, targets, cc=cc,
221     ...     subject='Once upon a time', text='Bla bla bla...')
222     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
223     Content-Type: text/plain; charset="us-ascii"
224     MIME-Version: 1.0
225     Content-Transfer-Encoding: 7bit
226     Content-Disposition: inline
227     Date: ...
228     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
229     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
230     To: Jill <c@d.net>
231     Cc: "H.D." <hd@wall.net>
232     Subject: Once upon a time
233     <BLANKLINE>
234     Bla bla bla...
235
236     With unicode text:
237
238     >>> msg = construct_text_email(author, targets, cc=cc,
239     ...     subject='Once upon a time', text='Funky ✉.')
240     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
241     Content-Type: text/plain; charset="utf-8"
242     MIME-Version: 1.0
243     Content-Transfer-Encoding: base64
244     Content-Disposition: inline
245     Date: ...
246     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
247     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
248     To: Jill <c@d.net>
249     Cc: "H.D." <hd@wall.net>
250     Subject: Once upon a time
251     <BLANKLINE>
252     RnVua3kg4pyJLg==
253     <BLANKLINE>
254     """
255     message = _pgp_mime.encodedMIMEText(text)
256     return construct_email(
257         author=author, targets=targets, subject=subject, message=message,
258         cc=cc)
259
260 def construct_response(author, targets, subject, text, original, cc=None):
261     r"""Build a multipart/mixed response email using `Person` instances
262
263     >>> from pygrader.model.person import Person as Person
264     >>> student = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
265     >>> assistant = Person(name='Jill', emails=['c@d.net'])
266     >>> cc = [assistant]
267     >>> msg = construct_text_email(author=student, targets=[assistant],
268     ...     subject='Assignment 1 submission', text='Bla bla bla...')
269     >>> rsp = construct_response(author=assistant, targets=[student],
270     ...     subject='Received assignment 1 submission', text='3 hours late',
271     ...     original=msg)
272     >>> print(rsp.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
273     Content-Type: multipart/mixed; boundary="===============...=="
274     MIME-Version: 1.0
275     Date: ...
276     From: Jill <c@d.net>
277     Reply-to: Jill <c@d.net>
278     To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
279     Subject: Received assignment 1 submission
280     <BLANKLINE>
281     --===============...==
282     Content-Type: text/plain; charset="us-ascii"
283     MIME-Version: 1.0
284     Content-Transfer-Encoding: 7bit
285     Content-Disposition: inline
286     <BLANKLINE>
287     3 hours late
288     --===============...==
289     Content-Type: message/rfc822
290     MIME-Version: 1.0
291     <BLANKLINE>
292     Content-Type: text/plain; charset="us-ascii"
293     MIME-Version: 1.0
294     Content-Transfer-Encoding: 7bit
295     Content-Disposition: inline
296     Date: ...
297     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
298     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
299     To: Jill <c@d.net>
300     Subject: Assignment 1 submission
301     <BLANKLINE>
302     Bla bla bla...
303     --===============...==--
304     """
305     message = _MIMEMultipart('mixed')
306     message.attach(_pgp_mime.encodedMIMEText(text))
307     message.attach(_MIMEMessage(original))
308     return construct_email(
309         author=author, targets=targets, subject=subject, message=message,
310         cc=cc)