Coverage for gramex\services\emailer.py : 94%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import os
2import smtplib
3from six import string_types
4from email import encoders
5from mimetypes import guess_type
6from email.mime.multipart import MIMEMultipart
7from email.mime.text import MIMEText
8from email.mime.base import MIMEBase
9from email.mime.image import MIMEImage
10from email.utils import formataddr, getaddresses
11from gramex import console
12from gramex.config import app_log
15class SMTPMailer(object):
16 '''
17 Creates an object capable of sending HTML emails. Usage::
19 >>> mailer = SMTPMailer(type='gmail', email='gramex.guide@gmail.com', password='...')
20 >>> mailer.mail(
21 ... to='person@example.com',
22 ... subject='Subject',
23 ... html='<strong>Bold text</strong>. <img src="cid:logo">'
24 ... body='This plain text is shown if the client cannot render HTML',
25 ... attachments=['1.pdf', '2.txt'],
26 ... images={'logo': '/path/to/logo.png'})
28 To test emails without sending them, add a ``stub=True`` option. This queues
29 email info into the ``SMTPStub.stubs`` list without sending it. To print the
30 email contents after sending it, use `stub='log'`.
31 '''
32 clients = {
33 'gmail': {'host': 'smtp.gmail.com'},
34 'yahoo': {'host': 'smtp.mail.yahoo.com'},
35 'live': {'host': 'smtp.live.com'},
36 'mandrill': {'host': 'smtp.mandrillapp.com'},
37 'office365': {'host': 'smtp.office365.com'},
38 'outlook': {'host': 'smtp-mail.outlook.com'},
39 'icloud': {'host': 'smtp.mail.me.com'},
40 'mail.com': {'host': 'smtp.mail.com'},
41 'smtp': {'tls': False},
42 'smtps': {},
43 }
44 # SMTP port, depending on whether TLS is True or False
45 ports = {
46 True: 587,
47 False: 25
48 }
50 def __init__(self, type, email=None, password=None, stub=False, **kwargs):
51 # kwargs: host, port, tls
52 self.type = type
53 self.email = email
54 self.password = password
55 self.stub = stub
56 if type not in self.clients:
57 raise ValueError('Unknown email type: %s' % type)
58 self.client = self.clients[type]
59 self.client.update(kwargs)
60 if 'host' not in self.client:
61 raise ValueError('Missing SMTP host')
63 def mail(self, **kwargs):
64 '''
65 Sends an email. It accepts any email parameter in RFC 2822
66 '''
67 sender = kwargs.get('sender', self.email)
68 # SES allows restricting the From: address. https://amzn.to/2Kqwh2y
69 # Mailgun suggests From: be the same as Sender: http://bit.ly/2tGS5wt
70 kwargs.setdefault('from', sender)
71 to = recipients(**kwargs)
72 msg = message(**kwargs)
73 tls = self.client.get('tls', True)
74 # Test cases specify stub: true. This uses a stub that logs emails
75 if self.stub: 75 ↛ 79line 75 didn't jump to line 79, because the condition on line 75 was never false
76 server = SMTPStub(self.client['host'], self.client.get('port', self.ports[tls]),
77 self.stub)
78 else:
79 server = smtplib.SMTP(self.client['host'], self.client.get('port', self.ports[tls]))
80 if tls: 80 ↛ 82line 80 didn't jump to line 82, because the condition on line 80 was never false
81 server.starttls()
82 if self.email is not None and self.password is not None: 82 ↛ 84line 82 didn't jump to line 84, because the condition on line 82 was never false
83 server.login(self.email, self.password)
84 server.sendmail(sender, to, msg.as_string())
85 server.quit()
86 app_log.info('Email sent via %s (%s) to %s', self.client['host'], self.email,
87 ', '.join(to))
90def recipients(**kwargs):
91 '''
92 Returns a list of RFC-822 formatted email addresses given:
94 - a string with comma-separated emails
95 - a list of strings with emails
96 - a list of strings with comma-separated emails
97 '''
98 recipients = []
99 for key in kwargs:
100 if key.lower() in {'to', 'cc', 'bcc'}:
101 to = kwargs[key]
102 if isinstance(to, string_types):
103 to = [to]
104 recipients += [formataddr(pair) for pair in getaddresses(to)]
105 return recipients
108def message(body=None, html=None, attachments=[], images={}, **kwargs):
109 '''
110 Returns a MIME message object based on text or HTML content, and optional
111 attachments. It accepts 3 parameters:
113 - ``body`` is the text content of the email
114 - ``html`` is the HTML content of the email. If both ``html`` and ``body``
115 are specified, the email contains both parts. Email clients may decide to
116 show one or the other.
117 - ``attachments`` is an array of file names or dicts. Each dict must have:
118 - ``body`` -- a byte array of the content
119 - ``content_type`` indicating the MIME type or ``filename`` indicating the file name
120 - ``images`` is a dict of ``{key: path}``. ``key`` may be anything. ``path``
121 is an absolute path. The HTML can show the image by including
122 ``<img src="cid:key">``
124 In addition, any keyword arguments passed are treated as message headers.
125 Some common message header keys are ``to``, ``cc``, ``bcc``, ``subject``,
126 ``reply_to``, and ``on_behalf_of``. The values must be strings.
128 Here are some examples::
130 >>> message(to='b@example.org', subject=sub, body=text, html=html)
131 >>> message(to='b@example.org', subject=sub, body=text, attachments=['file.pdf'])
132 >>> message(to='b@example.org', subject=sub, body=text, attachments=[
133 {'filename': 'test.txt', 'body': 'File contents'}
134 ])
135 >>> message(to='b@example.org', subject=sub, html='<img src="cid:logo">',
136 images={'logo': 'd:/images/logo.png'})
137 '''
138 if html:
139 if not images:
140 msg = html_part = MIMEText(html.encode('utf-8'), 'html', 'utf-8')
141 else:
142 msg = html_part = MIMEMultipart('related')
143 html_part.attach(MIMEText(html.encode('utf-8'), 'html', 'utf-8'))
144 for name, path in images.items():
145 with open(path, 'rb') as handle:
146 img = MIMEImage(handle.read())
147 img.add_header('Content-ID', '<%s>' % name)
148 html_part.attach(img)
149 if body and html:
150 msg = MIMEMultipart('alternative')
151 msg.attach(MIMEText(body.encode('utf-8'), 'plain', 'utf-8'))
152 msg.attach(html_part)
153 elif not html:
154 msg = MIMEText((body or '').encode('utf-8'), 'plain', 'utf-8')
156 if attachments:
157 msg_addon = MIMEMultipart()
158 msg_addon.attach(msg)
159 for doc in attachments:
160 if isinstance(doc, dict):
161 filename = doc.get('filename', 'data.bin')
162 content_type = doc.get('content_type', guess_type(filename, strict=False)[0])
163 content = doc['body']
164 else:
165 filename = doc
166 content = open(filename, 'rb').read()
167 content_type = guess_type(filename, strict=False)[0]
168 if content_type is None: 168 ↛ 169line 168 didn't jump to line 169, because the condition on line 168 was never true
169 content_type = 'application/octet-stream'
170 maintype, subtype = content_type.split('/', 1)
171 msg = MIMEBase(maintype, subtype)
172 msg.set_payload(content)
173 encoders.encode_base64(msg)
174 msg.add_header('Content-Disposition', 'attachment',
175 filename=os.path.basename(filename))
176 msg_addon.attach(msg)
177 msg = msg_addon
179 # set headers
180 for arg, value in kwargs.items():
181 header = '-'.join([
182 # All SMTP headers are capitalised, except abbreviations
183 w.upper() if w in {'ID', 'MTS', 'IPMS'} else w.capitalize()
184 for w in arg.split('_')
185 ])
186 msg[header] = _merge(value)
188 return msg
191def _merge(value):
192 return ', '.join(value) if isinstance(value, list) else value
195class SMTPStub(object):
196 '''A minimal test stub for smtplib.SMTP with features used in this module'''
197 stubs = []
199 def __init__(self, host, port, options):
200 # Maintain a list of all stub info so far
201 self.options = options
202 self.info = {}
203 self.stubs.append(self.info)
204 self.info.update(host=host, port=port)
206 def starttls(self):
207 self.info.update(starttls=True)
209 def login(self, email, password):
210 self.info.update(email=email, password=password)
212 def sendmail(self, from_addr, to_addrs, msg):
213 self.info.update(from_addr=from_addr, to_addrs=to_addrs, msg=msg)
215 def quit(self):
216 self.info.update(quit=True)
217 if self.options == 'log': 217 ↛ 218line 217 didn't jump to line 218, because the condition on line 217 was never true
218 console('From: %s' % self.info['from_addr'])
219 console('To: %s' % self.info['to_addrs'])
220 console(self.info['msg'])