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

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

## 113 statements 108 run 5 missing 0 excluded 5 partial

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

1 import os

2 import smtplib

3 from six import string_types

4 from email import encoders

5 from mimetypes import guess_type

6 from email.mime.multipart import MIMEMultipart

7 from email.mime.text import MIMEText

8 from email.mime.base import MIMEBase

9 from email.mime.image import MIMEImage

10 from email.utils import formataddr, getaddresses

11 from gramex import console

12 from gramex.config import app_log

13

14

15 class SMTPMailer(object):

16 '''

17 Creates an object capable of sending HTML emails. Usage::

18

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'})

27

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 }

49

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')

62

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))

88

89

90 def recipients(**kwargs):

91 '''

92 Returns a list of RFC-822 formatted email addresses given:

93

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

106

107

108 def 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:

112

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">``

123

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.

127

128 Here are some examples::

129

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')

155

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

178

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)

187

188 return msg

189

190

191 def _merge(value):

192 return ', '.join(value) if isinstance(value, list) else value

193

194

195 class SMTPStub(object):

196 '''A minimal test stub for smtplib.SMTP with features used in this module'''

197 stubs = []

198

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)

205

206 def starttls(self):

207 self.info.update(starttls=True)

208

209 def login(self, email, password):

210 self.info.update(email=email, password=password)

211

212 def sendmail(self, from_addr, to_addrs, msg):

213 self.info.update(from_addr=from_addr, to_addrs=to_addrs, msg=msg)

214

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'])