email: remove `header` option to get_address.
[pygrader.git] / pygrader / email.py
1 # -*- coding: utf-8 -*-
2 # Copyright (C) 2012 W. Trevor King <wking@tremily.us>
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):
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.  The name portion of the address
125     is encoded following RFC 2047.
126
127     >>> p.name = '✉'
128     >>> get_address(p)
129     '=?utf-8?b?4pyJ?= <a@b.net>'
130
131     Note that the address is in the clear.  Otherwise you can have
132     trouble when your mailer tries to decode the name following
133     :RFC:`2822`, which limits the locations in which encoded words may
134     appear.
135     """
136     encoding = _pgp_mime.guess_encoding(person.name)
137     return _email_utils.formataddr(
138         (person.name, person.emails[0]), charset=encoding)
139
140 def construct_email(author, targets, subject, message, cc=None):
141     if author.pgp_key:
142         signers = [author.pgp_key]
143     else:
144         signers = []
145     recipients = [p.pgp_key for p in targets if p.pgp_key]
146     encrypt = True
147     for person in targets:
148         if not person.pgp_key:
149             encrypt = False  # cannot encrypt to every recipient
150             break
151     if cc:
152         recipients.extend([p.pgp_key for p in cc if p.pgp_key])
153         for person in cc:
154             if not person.pgp_key:
155                 encrypt = False
156                 break
157     if not recipients:
158         encrypt = False  # noone to encrypt to
159     if signers and encrypt:
160         if author.pgp_key not in recipients:
161             recipients.append(author.pgp_key)
162         message = _pgp_mime.sign_and_encrypt(
163             message=message, signers=signers, recipients=recipients,
164             always_trust=True)
165     elif signers:
166         message = _pgp_mime.sign(message=message, signers=signers)
167     elif encrypt:
168         message = _pgp_mime.encrypt(message=message, recipients=recipients)
169
170     message['Date'] = _email_utils.formatdate()
171     message['From'] = get_address(author)
172     message['Reply-to'] = message['From']
173     message['To'] = ', '.join(
174         get_address(target) for target in targets)
175     if cc:
176         message['Cc'] = ', '.join(
177             get_address(target) for target in cc)
178     subject_encoding = _pgp_mime.guess_encoding(subject)
179     if subject_encoding == 'us-ascii':
180         message['Subject'] = subject
181     else:
182         message['Subject'] = _Header(subject, subject_encoding)
183
184     return message
185
186 def construct_text_email(author, targets, subject, text, cc=None):
187     r"""Build a text/plain email using `Person` instances
188
189     >>> from pygrader.model.person import Person as Person
190     >>> author = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
191     >>> targets = [Person(name='Jill', emails=['c@d.net'])]
192     >>> cc = [Person(name='H.D.', emails=['hd@wall.net'])]
193     >>> msg = construct_text_email(author, targets, cc=cc,
194     ...     subject='Once upon a time', text='Bla bla bla...')
195     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
196     Content-Type: text/plain; charset="us-ascii"
197     MIME-Version: 1.0
198     Content-Transfer-Encoding: 7bit
199     Content-Disposition: inline
200     Date: ...
201     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
202     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
203     To: Jill <c@d.net>
204     Cc: "H.D." <hd@wall.net>
205     Subject: Once upon a time
206     <BLANKLINE>
207     Bla bla bla...
208
209     With unicode text:
210
211     >>> msg = construct_text_email(author, targets, cc=cc,
212     ...     subject='Once upon a time', text='Funky ✉.')
213     >>> print(msg.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
214     Content-Type: text/plain; charset="utf-8"
215     MIME-Version: 1.0
216     Content-Transfer-Encoding: base64
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     RnVua3kg4pyJLg==
226     <BLANKLINE>
227     """
228     message = _pgp_mime.encodedMIMEText(text)
229     return construct_email(
230         author=author, targets=targets, subject=subject, message=message,
231         cc=cc)
232
233 def construct_response(author, targets, subject, text, original, cc=None):
234     r"""Build a multipart/mixed response email using `Person` instances
235
236     >>> from pygrader.model.person import Person as Person
237     >>> student = Person(name='Джон Доу', emails=['jdoe@a.gov.ru'])
238     >>> assistant = Person(name='Jill', emails=['c@d.net'])
239     >>> cc = [assistant]
240     >>> msg = construct_text_email(author=student, targets=[assistant],
241     ...     subject='Assignment 1 submission', text='Bla bla bla...')
242     >>> rsp = construct_response(author=assistant, targets=[student],
243     ...     subject='Received assignment 1 submission', text='3 hours late',
244     ...     original=msg)
245     >>> print(rsp.as_string())  # doctest: +REPORT_UDIFF, +ELLIPSIS
246     Content-Type: multipart/mixed; boundary="===============...=="
247     MIME-Version: 1.0
248     Date: ...
249     From: Jill <c@d.net>
250     Reply-to: Jill <c@d.net>
251     To: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
252     Subject: Received assignment 1 submission
253     <BLANKLINE>
254     --===============...==
255     Content-Type: text/plain; charset="us-ascii"
256     MIME-Version: 1.0
257     Content-Transfer-Encoding: 7bit
258     Content-Disposition: inline
259     <BLANKLINE>
260     3 hours late
261     --===============...==
262     Content-Type: message/rfc822
263     MIME-Version: 1.0
264     <BLANKLINE>
265     Content-Type: text/plain; charset="us-ascii"
266     MIME-Version: 1.0
267     Content-Transfer-Encoding: 7bit
268     Content-Disposition: inline
269     Date: ...
270     From: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
271     Reply-to: =?utf-8?b?0JTQttC+0L0g0JTQvtGD?= <jdoe@a.gov.ru>
272     To: Jill <c@d.net>
273     Subject: Assignment 1 submission
274     <BLANKLINE>
275     Bla bla bla...
276     --===============...==--
277     """
278     message = _MIMEMultipart('mixed')
279     message.attach(_pgp_mime.encodedMIMEText(text))
280     message.attach(_MIMEMessage(original))
281     return construct_email(
282         author=author, targets=targets, subject=subject, message=message,
283         cc=cc)