Hide keyboard shortcuts

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 

13 

14 

15class 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 

90def 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 

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: 

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 

191def _merge(value): 

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

193 

194 

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