Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/email/handler.py: 100%

138 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-08-26 14:27 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Email Handler 

25""" 

26 

27import logging 

28import smtplib 

29 

30from wuttjamaican.app import GenericHandler 

31from wuttjamaican.util import resource_path 

32from wuttjamaican.email.message import Message 

33 

34 

35log = logging.getLogger(__name__) 

36 

37 

38class EmailHandler(GenericHandler): 

39 """ 

40 Base class and default implementation for the :term:`email 

41 handler`. 

42 

43 Responsible for sending email messages on behalf of the 

44 :term:`app`. 

45 

46 You normally would not create this directly, but instead call 

47 :meth:`~wuttjamaican.app.AppHandler.get_email_handler()` on your 

48 :term:`app handler`. 

49 """ 

50 

51 # nb. this is fallback/default subject for auto-message 

52 universal_subject = "Automated message" 

53 

54 def __init__(self, *args, **kwargs): 

55 super().__init__(*args, **kwargs) 

56 

57 from mako.lookup import TemplateLookup 

58 

59 # prefer configured list of template lookup paths, if set 

60 templates = self.config.get_list(f'{self.config.appname}.email.templates') 

61 if not templates: 

62 

63 # otherwise use all available paths, from app providers 

64 available = [] 

65 for provider in self.app.providers.values(): 

66 if hasattr(provider, 'email_templates'): 

67 templates = provider.email_templates 

68 if isinstance(templates, str): 

69 templates = [templates] 

70 if templates: 

71 available.extend(templates) 

72 templates = available 

73 

74 # convert all to true file paths 

75 if templates: 

76 templates = [resource_path(p) for p in templates] 

77 

78 # will use these lookups from now on 

79 self.txt_templates = TemplateLookup(directories=templates) 

80 self.html_templates = TemplateLookup(directories=templates, 

81 # nb. escape HTML special chars 

82 # TODO: sounds great but i forget why? 

83 default_filters=['h']) 

84 

85 def make_message(self, **kwargs): 

86 """ 

87 Make and return a new email message. 

88 

89 This is the "raw" factory which is simply a wrapper around the 

90 class constructor. See also :meth:`make_auto_message()`. 

91 

92 :returns: :class:`~wuttjamaican.email.message.Message` object. 

93 """ 

94 return Message(**kwargs) 

95 

96 def make_auto_message(self, key, context={}, **kwargs): 

97 """ 

98 Make a new email message using config to determine its 

99 properties, and auto-generating body from a template. 

100 

101 Once everything has been collected/prepared, 

102 :meth:`make_message()` is called to create the final message, 

103 and that is returned. 

104 

105 :param key: Unique key for this particular "type" of message. 

106 This key is used as a prefix for all config settings and 

107 template names pertinent to the message. 

108 

109 :param context: Context dict used to render template(s) for 

110 the message. 

111 

112 :param \**kwargs: Any remaining kwargs are passed as-is to 

113 :meth:`make_message()`. More on this below. 

114 

115 :returns: :class:`~wuttjamaican.email.message.Message` object. 

116 

117 This method may invoke some others, to gather the message 

118 attributes. Each will check config, or render a template, or 

119 both. However if a particular attribute is provided by the 

120 caller, the corresponding "auto" method is skipped. 

121 

122 * :meth:`get_auto_sender()` 

123 * :meth:`get_auto_subject()` 

124 * :meth:`get_auto_to()` 

125 * :meth:`get_auto_cc()` 

126 * :meth:`get_auto_bcc()` 

127 * :meth:`get_auto_txt_body()` 

128 * :meth:`get_auto_html_body()` 

129 """ 

130 kwargs['key'] = key 

131 if 'sender' not in kwargs: 

132 kwargs['sender'] = self.get_auto_sender(key) 

133 if 'subject' not in kwargs: 

134 kwargs['subject'] = self.get_auto_subject(key, context) 

135 if 'to' not in kwargs: 

136 kwargs['to'] = self.get_auto_to(key) 

137 if 'cc' not in kwargs: 

138 kwargs['cc'] = self.get_auto_cc(key) 

139 if 'bcc' not in kwargs: 

140 kwargs['bcc'] = self.get_auto_bcc(key) 

141 if 'txt_body' not in kwargs: 

142 kwargs['txt_body'] = self.get_auto_txt_body(key, context) 

143 if 'html_body' not in kwargs: 

144 kwargs['html_body'] = self.get_auto_html_body(key, context) 

145 return self.make_message(**kwargs) 

146 

147 def get_auto_sender(self, key): 

148 """ 

149 Returns automatic 

150 :attr:`~wuttjamaican.email.message.Message.sender` address for 

151 a message, as determined by config. 

152 """ 

153 # prefer configured sender specific to key 

154 sender = self.config.get(f'{self.config.appname}.email.{key}.sender') 

155 if sender: 

156 return sender 

157 

158 # fall back to global default (required!) 

159 return self.config.require(f'{self.config.appname}.email.default.sender') 

160 

161 def get_auto_subject(self, key, context={}, rendered=True): 

162 """ 

163 Returns automatic 

164 :attr:`~wuttjamaican.email.message.Message.subject` line for a 

165 message, as determined by config. 

166 

167 This calls :meth:`get_auto_subject_template()` and then 

168 renders the result using the given context. 

169 

170 :param rendered: If this is ``False``, the "raw" subject 

171 template will be returned, instead of the final/rendered 

172 subject text. 

173 """ 

174 from mako.template import Template 

175 

176 template = self.get_auto_subject_template(key) 

177 if not rendered: 

178 return template 

179 return Template(template).render(**context) 

180 

181 def get_auto_subject_template(self, key): 

182 """ 

183 Returns the template string to use for automatic subject line 

184 of a message, as determined by config. 

185 

186 In many cases this will be a simple string and not a 

187 "template" per se; however it is still treated as a template. 

188 

189 The template returned from this method is used to render the 

190 final subject line in :meth:`get_auto_subject()`. 

191 """ 

192 # prefer configured subject specific to key 

193 template = self.config.get(f'{self.config.appname}.email.{key}.subject') 

194 if template: 

195 return template 

196 

197 # fall back to global default 

198 return self.config.get(f'{self.config.appname}.email.default.subject', 

199 default=self.universal_subject) 

200 

201 def get_auto_to(self, key): 

202 """ 

203 Returns automatic 

204 :attr:`~wuttjamaican.email.message.Message.to` recipient 

205 address(es) for a message, as determined by config. 

206 """ 

207 return self.get_auto_recips(key, 'to') 

208 

209 def get_auto_cc(self, key): 

210 """ 

211 Returns automatic 

212 :attr:`~wuttjamaican.email.message.Message.cc` recipient 

213 address(es) for a message, as determined by config. 

214 """ 

215 return self.get_auto_recips(key, 'cc') 

216 

217 def get_auto_bcc(self, key): 

218 """ 

219 Returns automatic 

220 :attr:`~wuttjamaican.email.message.Message.bcc` recipient 

221 address(es) for a message, as determined by config. 

222 """ 

223 return self.get_auto_recips(key, 'bcc') 

224 

225 def get_auto_recips(self, key, typ): 

226 """ """ 

227 typ = typ.lower() 

228 if typ not in ('to', 'cc', 'bcc'): 

229 raise ValueError("requested type not supported") 

230 

231 # prefer configured recips specific to key 

232 recips = self.config.get_list(f'{self.config.appname}.email.{key}.{typ}') 

233 if recips: 

234 return recips 

235 

236 # fall back to global default 

237 return self.config.get_list(f'{self.config.appname}.email.default.{typ}', 

238 default=[]) 

239 

240 def get_auto_txt_body(self, key, context={}): 

241 """ 

242 Returns automatic 

243 :attr:`~wuttjamaican.email.message.Message.txt_body` content 

244 for a message, as determined by config. This renders a 

245 template with the given context. 

246 """ 

247 template = self.get_auto_body_template(key, 'txt') 

248 if template: 

249 return template.render(**context) 

250 

251 def get_auto_html_body(self, key, context={}): 

252 """ 

253 Returns automatic 

254 :attr:`~wuttjamaican.email.message.Message.html_body` content 

255 for a message, as determined by config. This renders a 

256 template with the given context. 

257 """ 

258 template = self.get_auto_body_template(key, 'html') 

259 if template: 

260 return template.render(**context) 

261 

262 def get_auto_body_template(self, key, typ): 

263 """ """ 

264 from mako.exceptions import TopLevelLookupException 

265 

266 typ = typ.lower() 

267 if typ not in ('txt', 'html'): 

268 raise ValueError("requested type not supported") 

269 

270 if typ == 'txt': 

271 templates = self.txt_templates 

272 elif typ == 'html': 

273 templates = self.html_templates 

274 

275 try: 

276 return templates.get_template(f'{key}.{typ}.mako') 

277 except TopLevelLookupException: 

278 pass 

279 

280 def deliver_message(self, message, sender=None, recips=None): 

281 """ 

282 Deliver a message via SMTP smarthost. 

283 

284 :param message: Either a 

285 :class:`~wuttjamaican.email.message.Message` object or 

286 similar, or a string representing the complete message to 

287 be sent as-is. 

288 

289 :param sender: Optional sender address to use for delivery. 

290 If not specified, will be read from ``message``. 

291 

292 :param recips: Optional recipient address(es) for delivery. 

293 If not specified, will be read from ``message``. 

294 

295 A general rule here is that you can either provide a proper 

296 :class:`~wuttjamaican.email.message.Message` object, **or** 

297 you *must* provide ``sender`` and ``recips``. The logic is 

298 not smart enough (yet?) to parse sender/recips from a simple 

299 string message. 

300 

301 Note also, this method does not (yet?) have robust error 

302 handling, so if an error occurs with the SMTP session, it will 

303 simply raise to caller. 

304 

305 :returns: ``None`` 

306 """ 

307 if not sender: 

308 sender = message.sender 

309 if not sender: 

310 raise ValueError("no sender identified for message delivery") 

311 

312 if not recips: 

313 recips = set() 

314 if message.to: 

315 recips.update(message.to) 

316 if message.cc: 

317 recips.update(message.cc) 

318 if message.bcc: 

319 recips.update(message.bcc) 

320 elif isinstance(recips, str): 

321 recips = [recips] 

322 

323 recips = set(recips) 

324 if not recips: 

325 raise ValueError("no recipients identified for message delivery") 

326 

327 if not isinstance(message, str): 

328 message = message.as_string() 

329 

330 # get smtp info 

331 server = self.config.get(f'{self.config.appname}.mail.smtp.server', default='localhost') 

332 username = self.config.get(f'{self.config.appname}.mail.smtp.username') 

333 password = self.config.get(f'{self.config.appname}.mail.smtp.password') 

334 

335 # make sure sending is enabled 

336 log.debug("sending email from %s; to %s", sender, recips) 

337 if not self.sending_is_enabled(): 

338 log.debug("nevermind, config says no emails") 

339 return 

340 

341 # smtp connect 

342 session = smtplib.SMTP(server) 

343 if username and password: 

344 session.login(username, password) 

345 

346 # smtp send 

347 session.sendmail(sender, recips, message) 

348 session.quit() 

349 log.debug("email was sent") 

350 

351 def sending_is_enabled(self): 

352 """ 

353 Returns boolean indicating if email sending is enabled. 

354 

355 Set this flag in config like this: 

356 

357 .. code-block:: ini 

358 

359 [wutta.mail] 

360 send_emails = true 

361 

362 Note that it is OFF by default. 

363 """ 

364 return self.config.get_bool(f'{self.config.appname}.mail.send_emails', 

365 default=False) 

366 

367 def send_email(self, key=None, context={}, message=None, sender=None, recips=None, **kwargs): 

368 """ 

369 Send an email message. 

370 

371 This method can send a ``message`` you provide, or it can 

372 construct one automatically from key/config/templates. 

373 

374 :param key: Indicates which "type" of automatic email to send. 

375 Used to lookup config settings and template files. 

376 

377 :param context: Context dict for rendering automatic email 

378 template(s). 

379 

380 :param message: Optional pre-built message instance, to send 

381 as-is. 

382 

383 :param sender: Optional sender address for the 

384 message/delivery. 

385 

386 If ``message`` is not provided, then the ``sender`` (if 

387 provided) will also be used when constructing the 

388 auto-message (i.e. to set the ``From:`` header). 

389 

390 In any case if ``sender`` is provided, it will be used for 

391 the actual SMTP delivery. 

392 

393 :param recips: Optional list of recipient addresses for 

394 delivery. If not specified, will be read from the message 

395 itself (after auto-generating it, if applicable). 

396 

397 .. note:: 

398 

399 This param does not affect an auto-generated message; it 

400 is used for delivery only. As such it must contain 

401 *all* true recipients. 

402 

403 If you provide the ``message`` but not the ``recips``, 

404 the latter will be read from message headers: ``To:``, 

405 ``Cc:`` and ``Bcc:`` 

406 

407 If you want an auto-generated message but also want to 

408 override various recipient headers, then you must 

409 provide those explicitly:: 

410 

411 context = {'data': [1, 2, 3]} 

412 app.send_email('foo', context, to='me@example.com', cc='bobby@example.com') 

413 

414 :param \**kwargs: Any remaining kwargs are passed along to 

415 :meth:`make_auto_message()`. So, not used if you provide 

416 the ``message``. 

417 """ 

418 if message is None: 

419 if sender: 

420 kwargs['sender'] = sender 

421 message = self.make_auto_message(key, context, **kwargs) 

422 

423 self.deliver_message(message, recips=recips)