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

292 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-20 23:12 -0600

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2025 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# pylint: disable=too-many-lines 

27 

28import logging 

29import re 

30import smtplib 

31from email.mime.multipart import MIMEMultipart 

32from email.mime.text import MIMEText 

33 

34from mako.lookup import TemplateLookup 

35from mako.template import Template 

36from mako.exceptions import TopLevelLookupException 

37 

38from wuttjamaican.app import GenericHandler 

39from wuttjamaican.util import resource_path 

40 

41 

42log = logging.getLogger(__name__) 

43 

44 

45class EmailSetting: # pylint: disable=too-few-public-methods 

46 """ 

47 Base class for all :term:`email settings <email setting>`. 

48 

49 Each :term:`email type` which needs to have settings exposed 

50 e.g. for editing, should define a subclass within the appropriate 

51 :term:`email module`. 

52 

53 The name of each subclass should match the :term:`email key` which 

54 it represents. For instance:: 

55 

56 from wuttjamaican.email import EmailSetting 

57 

58 class poser_alert_foo(EmailSetting): 

59 \""" 

60 Sent when something happens that we think deserves an alert. 

61 \""" 

62 

63 default_subject = "Something happened!" 

64 

65 # nb. this is not used for sending; only preview 

66 def sample_data(self): 

67 return { 

68 'foo': 1234, 

69 'msg': "Something happened, thought you should know.", 

70 } 

71 

72 # (and elsewhere..) 

73 app.send_email('poser_alert_foo', { 

74 'foo': 5678, 

75 'msg': "Can't take much more, she's gonna blow!", 

76 }) 

77 

78 Defining a subclass for each email type can be a bit tedious, so 

79 why do it? In fact there is no need, if you just want to *send* 

80 emails. 

81 

82 The purpose of defining a subclass for each email type is 2-fold, 

83 but really the answer is "for maintenance sake" - 

84 

85 * gives the app a way to discover all emails, so settings for each 

86 can be exposed for editing 

87 * allows for hard-coded sample context which can be used to render 

88 templates for preview 

89 

90 .. attribute:: key 

91 

92 Unique identifier for this :term:`email type`. 

93 

94 This is the :term:`email key` used for config/template lookup, 

95 e.g. when sending an email. 

96 

97 This is automatically set based on the *class name* so there is 

98 no need (or point) to set it. But the attribute is here for 

99 read access, for convenience / code readability:: 

100 

101 class poser_alert_foo(EmailSetting): 

102 default_subject = "Something happened!" 

103 

104 handler = app.get_email_handler() 

105 setting = handler.get_email_setting("poser_alert_foo") 

106 assert setting.key == "poser_alert_foo" 

107 

108 See also :attr:`fallback_key`. 

109 

110 .. attribute:: default_subject 

111 

112 Default subject for sending emails of this type. 

113 

114 Usually, if config does not override, this will become 

115 :attr:`Message.subject`. 

116 

117 This is technically a Mako template string, so it will be 

118 rendered with the email context. But in most cases that 

119 feature can be ignored, and this will be a simple string. 

120 

121 Calling code should not access this directly, but instead use 

122 :meth:`get_default_subject()` . 

123 """ 

124 

125 default_subject = None 

126 

127 default_prefix = None 

128 """ 

129 Default subject prefix for emails of this type. 

130 

131 Calling code should not access this directly, but instead use 

132 :meth:`get_default_prefix()` . 

133 """ 

134 

135 fallback_key = None 

136 """ 

137 Optional fallback key to use for config/template lookup, if 

138 nothing is found for :attr:`key`. 

139 """ 

140 

141 def __init__(self, config): 

142 self.config = config 

143 self.app = config.get_app() 

144 self.key = self.__class__.__name__ 

145 

146 def get_description(self): 

147 """ 

148 This must return the full description for the :term:`email 

149 type`. It is not used for the sending of email; only for 

150 settings administration. 

151 

152 Default logic will use the class docstring. 

153 

154 :returns: String description for the email type 

155 """ 

156 return self.__class__.__doc__.strip() 

157 

158 def get_default_prefix(self): 

159 """ 

160 This returns the default subject prefix, for sending emails of 

161 this type. 

162 

163 Default logic here returns :attr:`default_prefix` as-is. 

164 

165 This method will often return ``None`` in which case the 

166 global default prefix is used. 

167 

168 :returns: Default subject prefix as string, or ``None`` 

169 """ 

170 return self.default_prefix 

171 

172 def get_default_subject(self): 

173 """ 

174 This must return the default subject, for sending emails of 

175 this type. 

176 

177 If config does not override, this will become 

178 :attr:`Message.subject`. 

179 

180 Default logic here returns :attr:`default_subject` as-is. 

181 

182 :returns: Default subject as string 

183 """ 

184 return self.default_subject 

185 

186 def sample_data(self): 

187 """ 

188 Should return a dict with sample context needed to render the 

189 :term:`email template` for message body. This can be used to 

190 show a "preview" of the email. 

191 """ 

192 return {} 

193 

194 

195class Message: # pylint: disable=too-many-instance-attributes 

196 """ 

197 Represents an email message to be sent. 

198 

199 :param to: Recipient(s) for the message. This may be either a 

200 string, or list of strings. If a string, it will be converted 

201 to a list since that is how the :attr:`to` attribute tracks it. 

202 Similar logic is used for :attr:`cc` and :attr:`bcc`. 

203 

204 All attributes shown below may also be specified via constructor. 

205 

206 .. attribute:: key 

207 

208 Unique key indicating the "type" of message. An "ad-hoc" 

209 message created arbitrarily may not have/need a key; however 

210 one created via 

211 :meth:`~wuttjamaican.email.EmailHandler.make_auto_message()` 

212 will always have a key. 

213 

214 This key is not used for anything within the ``Message`` class 

215 logic. It is used by 

216 :meth:`~wuttjamaican.email.EmailHandler.make_auto_message()` 

217 when constructing the message, and the key is set on the final 

218 message only as a reference. 

219 

220 .. attribute:: sender 

221 

222 Sender (``From:``) address for the message. 

223 

224 .. attribute:: subject 

225 

226 Subject text for the message. 

227 

228 .. attribute:: to 

229 

230 List of ``To:`` recipients for the message. 

231 

232 .. attribute:: cc 

233 

234 List of ``Cc:`` recipients for the message. 

235 

236 .. attribute:: bcc 

237 

238 List of ``Bcc:`` recipients for the message. 

239 

240 .. attribute:: replyto 

241 

242 Optional reply-to (``Reply-To:``) address for the message. 

243 

244 .. attribute:: txt_body 

245 

246 String with the ``text/plain`` body content. 

247 

248 .. attribute:: html_body 

249 

250 String with the ``text/html`` body content. 

251 

252 .. attribute:: attachments 

253 

254 List of file attachments for the message. 

255 """ 

256 

257 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments 

258 self, 

259 key=None, 

260 sender=None, 

261 subject=None, 

262 to=None, 

263 cc=None, 

264 bcc=None, 

265 replyto=None, 

266 txt_body=None, 

267 html_body=None, 

268 attachments=None, 

269 ): 

270 self.key = key 

271 self.sender = sender 

272 self.subject = subject 

273 self.to = self.get_recips(to) 

274 self.cc = self.get_recips(cc) 

275 self.bcc = self.get_recips(bcc) 

276 self.replyto = replyto 

277 self.txt_body = txt_body 

278 self.html_body = html_body 

279 self.attachments = attachments or [] 

280 

281 def get_recips(self, value): # pylint: disable=empty-docstring 

282 """ """ 

283 if value: 

284 if isinstance(value, str): 

285 value = [value] 

286 if not isinstance(value, (list, tuple)): 

287 raise ValueError("must specify a string, tuple or list value") 

288 else: 

289 value = [] 

290 return list(value) 

291 

292 def as_string(self): 

293 """ 

294 Returns the complete message as string. This is called from 

295 within 

296 :meth:`~wuttjamaican.email.EmailHandler.deliver_message()` to 

297 obtain the SMTP payload. 

298 """ 

299 msg = None 

300 

301 if self.txt_body and self.html_body: 

302 txt = MIMEText(self.txt_body, _charset="utf_8") 

303 html = MIMEText(self.html_body, _subtype="html", _charset="utf_8") 

304 msg = MIMEMultipart(_subtype="alternative", _subparts=[txt, html]) 

305 

306 elif self.txt_body: 

307 msg = MIMEText(self.txt_body, _charset="utf_8") 

308 

309 elif self.html_body: 

310 msg = MIMEText(self.html_body, "html", _charset="utf_8") 

311 

312 if not msg: 

313 raise ValueError("message has no body parts") 

314 

315 if self.attachments: 

316 for attachment in self.attachments: 

317 if isinstance(attachment, str): 

318 raise ValueError( 

319 "must specify valid MIME attachments; this class cannot " 

320 "auto-create them from file path etc." 

321 ) 

322 msg = MIMEMultipart(_subtype="mixed", _subparts=[msg] + self.attachments) 

323 

324 msg["Subject"] = self.subject 

325 msg["From"] = self.sender 

326 

327 for addr in self.to: 

328 msg["To"] = addr 

329 for addr in self.cc: 

330 msg["Cc"] = addr 

331 for addr in self.bcc: 

332 msg["Bcc"] = addr 

333 

334 if self.replyto: 

335 msg.add_header("Reply-To", self.replyto) 

336 

337 return msg.as_string() 

338 

339 

340class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods 

341 """ 

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

343 handler`. 

344 

345 Responsible for sending email messages on behalf of the 

346 :term:`app`. 

347 

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

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

350 :term:`app handler`. 

351 """ 

352 

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

354 universal_subject = "Automated message" 

355 

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

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

358 

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

360 templates = self.config.get_list(f"{self.config.appname}.email.templates") 

361 if not templates: 

362 # otherwise use all available paths, from app providers 

363 available = [] 

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

365 if hasattr(provider, "email_templates"): 

366 templates = provider.email_templates 

367 if isinstance(templates, str): 

368 templates = [templates] 

369 if templates: 

370 available.extend(templates) 

371 templates = available 

372 

373 # convert all to true file paths 

374 if templates: 

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

376 

377 # will use these lookups from now on 

378 self.txt_templates = TemplateLookup(directories=templates) 

379 self.html_templates = TemplateLookup( 

380 directories=templates, 

381 # nb. escape HTML special chars 

382 # TODO: sounds great but i forget why? 

383 default_filters=["h"], 

384 ) 

385 

386 def get_email_modules(self): 

387 """ 

388 Returns a list of all known :term:`email modules <email 

389 module>`. 

390 

391 This will discover all email modules exposed by the 

392 :term:`app`, and/or its :term:`providers <provider>`. 

393 

394 Calls 

395 :meth:`~wuttjamaican.app.GenericHandler.get_provider_modules()` 

396 under the hood, for ``email`` module type. 

397 """ 

398 return self.get_provider_modules("email") 

399 

400 def get_email_settings(self): 

401 """ 

402 Returns a dict of all known :term:`email settings <email 

403 setting>`, keyed by :term:`email key`. 

404 

405 This calls :meth:`get_email_modules()` and for each module, it 

406 discovers all the email settings it contains. 

407 """ 

408 if "email_settings" not in self.classes: 

409 self.classes["email_settings"] = {} 

410 

411 # nb. we only want lower_case_names - all UpperCaseNames 

412 # are assumed to be base classes 

413 pattern = re.compile(r"^[a-z]") 

414 

415 for module in self.get_email_modules(): 

416 for name in dir(module): 

417 obj = getattr(module, name) 

418 if ( 

419 isinstance(obj, type) 

420 and issubclass(obj, EmailSetting) 

421 and pattern.match(obj.__name__) 

422 ): 

423 self.classes["email_settings"][obj.__name__] = obj 

424 

425 return self.classes["email_settings"] 

426 

427 def get_email_setting(self, key, instance=True): 

428 """ 

429 Retrieve the :term:`email setting` for the given :term:`email 

430 key` (if it exists). 

431 

432 :param key: Key for the :term:`email type`. 

433 

434 :param instance: Whether to return the class, or an instance. 

435 

436 :returns: :class:`EmailSetting` class or instance, or ``None`` 

437 if the setting could not be found. 

438 """ 

439 settings = self.get_email_settings() 

440 if key in settings: 

441 setting = settings[key] 

442 if instance: 

443 setting = setting(self.config) 

444 return setting 

445 return None 

446 

447 def make_message(self, **kwargs): 

448 """ 

449 Make and return a new email message. 

450 

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

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

453 

454 :returns: :class:`~wuttjamaican.email.Message` object. 

455 """ 

456 return Message(**kwargs) 

457 

458 def make_auto_message( # pylint: disable=too-many-arguments,too-many-positional-arguments 

459 self, 

460 key, 

461 context=None, 

462 default_subject=None, 

463 prefix_subject=True, 

464 default_prefix=None, 

465 fallback_key=None, 

466 **kwargs, 

467 ): 

468 """ 

469 Make a new email message using config to determine its 

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

471 

472 Once everything has been collected/prepared, 

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

474 and that is returned. 

475 

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

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

478 template names pertinent to the message. See also the 

479 ``fallback_key`` param, below. 

480 

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

482 the message. 

483 

484 :param default_subject: Optional :attr:`~Message.subject` 

485 template/string to use, if config does not specify one. 

486 

487 :param prefix_subject: Boolean indicating the message subject 

488 should be auto-prefixed. 

489 

490 :param default_prefix: Default subject prefix to use if none 

491 is configured. 

492 

493 :param fallback_key: Optional fallback :term:`email key` to 

494 use for config/template lookup, if nothing is found for 

495 ``key``. 

496 

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

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

499 

500 :returns: :class:`~wuttjamaican.email.Message` object. 

501 

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

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

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

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

506 

507 * :meth:`get_auto_sender()` 

508 * :meth:`get_auto_subject()` 

509 * :meth:`get_auto_to()` 

510 * :meth:`get_auto_cc()` 

511 * :meth:`get_auto_bcc()` 

512 * :meth:`get_auto_txt_body()` 

513 * :meth:`get_auto_html_body()` 

514 """ 

515 context = context or {} 

516 kwargs["key"] = key 

517 if "sender" not in kwargs: 

518 kwargs["sender"] = self.get_auto_sender(key) 

519 if "subject" not in kwargs: 

520 kwargs["subject"] = self.get_auto_subject( 

521 key, 

522 context, 

523 default=default_subject, 

524 prefix=prefix_subject, 

525 default_prefix=default_prefix, 

526 fallback_key=fallback_key, 

527 ) 

528 if "to" not in kwargs: 

529 kwargs["to"] = self.get_auto_to(key) 

530 if "cc" not in kwargs: 

531 kwargs["cc"] = self.get_auto_cc(key) 

532 if "bcc" not in kwargs: 

533 kwargs["bcc"] = self.get_auto_bcc(key) 

534 if "txt_body" not in kwargs: 

535 kwargs["txt_body"] = self.get_auto_txt_body( 

536 key, context, fallback_key=fallback_key 

537 ) 

538 if "html_body" not in kwargs: 

539 kwargs["html_body"] = self.get_auto_html_body( 

540 key, context, fallback_key=fallback_key 

541 ) 

542 return self.make_message(**kwargs) 

543 

544 def get_email_context(self, key, context=None): # pylint: disable=unused-argument 

545 """ 

546 This must return the "full" context for rendering the email 

547 subject and/or body templates. 

548 

549 Normally the input ``context`` is coming from the 

550 :meth:`send_email()` param of the same name. 

551 

552 By default, this method modifies the input context to add the 

553 following: 

554 

555 * ``config`` - reference to the :term:`config object` 

556 * ``app`` - reference to the :term:`app handler` 

557 

558 Subclass may further modify as needed. 

559 

560 :param key: The :term:`email key` for which to get context. 

561 

562 :param context: Input context dict. 

563 

564 :returns: Final context dict 

565 """ 

566 if context is None: 

567 context = {} 

568 context.update( 

569 { 

570 "config": self.config, 

571 "app": self.app, 

572 } 

573 ) 

574 return context 

575 

576 def get_auto_sender(self, key): 

577 """ 

578 Returns automatic 

579 :attr:`~wuttjamaican.email.Message.sender` address for a 

580 message, as determined by config. 

581 """ 

582 # prefer configured sender specific to key 

583 sender = self.config.get(f"{self.config.appname}.email.{key}.sender") 

584 if sender: 

585 return sender 

586 

587 # fall back to global default 

588 return self.config.get( 

589 f"{self.config.appname}.email.default.sender", default="root@localhost" 

590 ) 

591 

592 def get_auto_replyto(self, key): 

593 """ 

594 Returns automatic :attr:`~wuttjamaican.email.Message.replyto` 

595 address for a message, as determined by config. 

596 """ 

597 # prefer configured replyto specific to key 

598 replyto = self.config.get(f"{self.config.appname}.email.{key}.replyto") 

599 if replyto: 

600 return replyto 

601 

602 # fall back to global default, if present 

603 return self.config.get(f"{self.config.appname}.email.default.replyto") 

604 

605 def get_auto_subject( # pylint: disable=too-many-arguments,too-many-positional-arguments 

606 self, 

607 key, 

608 context=None, 

609 rendered=True, 

610 default=None, 

611 fallback_key=None, 

612 setting=None, 

613 prefix=True, 

614 default_prefix=None, 

615 ): 

616 """ 

617 Returns automatic :attr:`~wuttjamaican.email.Message.subject` 

618 line for a message, as determined by config. 

619 

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

621 (usually) renders the result using the given context, and adds 

622 the :meth:`get_auto_subject_prefix()`. 

623 

624 :param key: Key for the :term:`email type`. See also the 

625 ``fallback_key`` param, below. 

626 

627 :param context: Dict of context for rendering the subject 

628 template, if applicable. 

629 

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

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

632 subject text. 

633 

634 :param default: Default subject to use if none is configured. 

635 

636 :param fallback_key: Optional fallback :term:`email key` to 

637 use for config lookup, if nothing is found for ``key``. 

638 

639 :param setting: Optional :class:`EmailSetting` class or 

640 instance. This is passed along to 

641 :meth:`get_auto_subject_template()`. 

642 

643 :param prefix: Boolean indicating the message subject should 

644 be auto-prefixed. This is ignored when ``rendered`` param 

645 is false. 

646 

647 :param default_prefix: Default subject prefix to use if none 

648 is configured. 

649 

650 :returns: Final subject text, either "raw" or rendered. 

651 """ 

652 template = self.get_auto_subject_template( 

653 key, setting=setting, default=default, fallback_key=fallback_key 

654 ) 

655 if not rendered: 

656 return template 

657 

658 context = self.get_email_context(key, context) 

659 subject = Template(template).render(**context) 

660 

661 if prefix: 

662 if prefix := self.get_auto_subject_prefix( 

663 key, default=default_prefix, setting=setting, fallback_key=fallback_key 

664 ): 

665 subject = f"{prefix} {subject}" 

666 

667 return subject 

668 

669 def get_auto_subject_template( 

670 self, key, default=None, fallback_key=None, setting=None 

671 ): 

672 """ 

673 Returns the template string to use for automatic subject line 

674 of a message, as determined by config. 

675 

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

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

678 

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

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

681 

682 :param key: Key for the :term:`email type`. 

683 

684 :param default: Default subject to use if none is configured. 

685 

686 :param fallback_key: Optional fallback :term:`email key` to 

687 use for config lookup, if nothing is found for ``key``. 

688 

689 :param setting: Optional :class:`EmailSetting` class or 

690 instance. This may be used to determine the "default" 

691 subject if none is configured. You can specify this as an 

692 optimization; otherwise it will be fetched if needed via 

693 :meth:`get_email_setting()`. 

694 

695 :returns: Final subject template, as raw text. 

696 """ 

697 # prefer configured subject specific to key 

698 if template := self.config.get(f"{self.config.appname}.email.{key}.subject"): 

699 return template 

700 

701 # or use caller-specified default, if applicable 

702 if default: 

703 return default 

704 

705 # or use fallback key, if provided 

706 if fallback_key: 

707 if template := self.config.get( 

708 f"{self.config.appname}.email.{fallback_key}.subject" 

709 ): 

710 return template 

711 

712 # or subject from email setting, if defined 

713 if not setting: 

714 setting = self.get_email_setting(key) 

715 if setting: 

716 if subject := setting.get_default_subject(): 

717 return subject 

718 

719 # fall back to global default 

720 return self.config.get( 

721 f"{self.config.appname}.email.default.subject", 

722 default=self.universal_subject, 

723 ) 

724 

725 def get_auto_subject_prefix( 

726 self, key, default=None, fallback_key=None, setting=None 

727 ): 

728 """ 

729 Returns the string to use for automatic subject prefix, as 

730 determined by config. This is called by 

731 :meth:`get_auto_subject()`. 

732 

733 Note that unlike the subject proper, the prefix is just a 

734 normal string, not a template. 

735 

736 Example prefix is ``"[Wutta]"`` - trailing space will be added 

737 automatically when applying the prefix to a message subject. 

738 

739 :param key: The :term:`email key` requested. 

740 

741 :param default: Default prefix to use if none is configured. 

742 

743 :param fallback_key: Optional fallback :term:`email key` to 

744 use for config lookup, if nothing is found for ``key``. 

745 

746 :param setting: Optional :class:`EmailSetting` class or 

747 instance. This may be used to determine the "default" 

748 prefix if none is configured. You can specify this as an 

749 optimization; otherwise it will be fetched if needed via 

750 :meth:`get_email_setting()`. 

751 

752 :returns: Final subject prefix string 

753 """ 

754 

755 # prefer configured prefix specific to key 

756 if prefix := self.config.get(f"{self.config.appname}.email.{key}.prefix"): 

757 return prefix 

758 

759 # or use caller-specified default, if applicable 

760 if default: 

761 return default 

762 

763 # or use fallback key, if provided 

764 if fallback_key: 

765 if prefix := self.config.get( 

766 f"{self.config.appname}.email.{fallback_key}.prefix" 

767 ): 

768 return prefix 

769 

770 # or prefix from email setting, if defined 

771 if not setting: 

772 setting = self.get_email_setting(key) 

773 if setting: 

774 if prefix := setting.get_default_prefix(): 

775 return prefix 

776 

777 # fall back to global default 

778 return self.config.get( 

779 f"{self.config.appname}.email.default.prefix", 

780 default=f"[{self.app.get_node_title()}]", 

781 ) 

782 

783 def get_auto_to(self, key): 

784 """ 

785 Returns automatic :attr:`~wuttjamaican.email.Message.to` 

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

787 """ 

788 return self.get_auto_recips(key, "to") 

789 

790 def get_auto_cc(self, key): 

791 """ 

792 Returns automatic :attr:`~wuttjamaican.email.Message.cc` 

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

794 """ 

795 return self.get_auto_recips(key, "cc") 

796 

797 def get_auto_bcc(self, key): 

798 """ 

799 Returns automatic :attr:`~wuttjamaican.email.Message.bcc` 

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

801 """ 

802 return self.get_auto_recips(key, "bcc") 

803 

804 def get_auto_recips(self, key, typ): # pylint: disable=empty-docstring 

805 """ """ 

806 typ = typ.lower() 

807 if typ not in ("to", "cc", "bcc"): 

808 raise ValueError("requested type not supported") 

809 

810 # prefer configured recips specific to key 

811 recips = self.config.get_list(f"{self.config.appname}.email.{key}.{typ}") 

812 if recips: 

813 return recips 

814 

815 # fall back to global default 

816 return self.config.get_list( 

817 f"{self.config.appname}.email.default.{typ}", default=[] 

818 ) 

819 

820 def get_auto_txt_body(self, key, context=None, fallback_key=None): 

821 """ 

822 Returns automatic :attr:`~wuttjamaican.email.Message.txt_body` 

823 content for a message, as determined by config. This renders 

824 a template with the given context. 

825 """ 

826 template = self.get_auto_body_template(key, "txt", fallback_key=fallback_key) 

827 if template: 

828 context = self.get_email_context(key, context) 

829 return template.render(**context) 

830 return None 

831 

832 def get_auto_html_body(self, key, context=None, fallback_key=None): 

833 """ 

834 Returns automatic 

835 :attr:`~wuttjamaican.email.Message.html_body` content for a 

836 message, as determined by config. This renders a template 

837 with the given context. 

838 """ 

839 template = self.get_auto_body_template(key, "html", fallback_key=fallback_key) 

840 if template: 

841 context = self.get_email_context(key, context) 

842 return template.render(**context) 

843 return None 

844 

845 def get_auto_body_template( # pylint: disable=empty-docstring 

846 self, key, mode, fallback_key=None 

847 ): 

848 """ """ 

849 mode = mode.lower() 

850 if mode == "txt": 

851 templates = self.txt_templates 

852 elif mode == "html": 

853 templates = self.html_templates 

854 else: 

855 raise ValueError("requested mode not supported") 

856 

857 try: 

858 

859 # prefer specific template for key 

860 return templates.get_template(f"{key}.{mode}.mako") 

861 

862 except TopLevelLookupException: 

863 

864 # but can use fallback if applicable 

865 if fallback_key: 

866 try: 

867 return templates.get_template(f"{fallback_key}.{mode}.mako") 

868 except TopLevelLookupException: 

869 pass 

870 

871 return None 

872 

873 def get_notes(self, key): 

874 """ 

875 Returns configured "notes" for the given :term:`email key`. 

876 

877 :param key: Key for the :term:`email type`. 

878 

879 :returns: Notes as string if found; otherwise ``None``. 

880 """ 

881 return self.config.get(f"{self.config.appname}.email.{key}.notes") 

882 

883 def is_enabled(self, key): 

884 """ 

885 Returns flag indicating whether the given email type is 

886 "enabled" - i.e. whether it should ever be sent out (enabled) 

887 or always suppressed (disabled). 

888 

889 All email types are enabled by default, unless config says 

890 otherwise; e.g. to disable ``foo`` emails: 

891 

892 .. code-block:: ini 

893 

894 [wutta.email] 

895 

896 # nb. this is fallback if specific type is not configured 

897 default.enabled = true 

898 

899 # this disables 'foo' but e.g 'bar' is still enabled per default above 

900 foo.enabled = false 

901 

902 In a development setup you may want a reverse example, where 

903 all emails are disabled by default but you can turn on just 

904 one type for testing: 

905 

906 .. code-block:: ini 

907 

908 [wutta.email] 

909 

910 # do not send any emails unless explicitly enabled 

911 default.enabled = false 

912 

913 # turn on 'bar' for testing 

914 bar.enabled = true 

915 

916 See also :meth:`sending_is_enabled()` which is more of a 

917 master shutoff switch. 

918 

919 :param key: Unique identifier for the email type. 

920 

921 :returns: True if this email type is enabled, otherwise false. 

922 """ 

923 for k in set([key, "default"]): 

924 enabled = self.config.get_bool(f"{self.config.appname}.email.{k}.enabled") 

925 if enabled is not None: 

926 return enabled 

927 return True 

928 

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

930 """ 

931 Deliver a message via SMTP smarthost. 

932 

933 :param message: Either a :class:`~wuttjamaican.email.Message` 

934 object or similar, or a string representing the complete 

935 message to be sent as-is. 

936 

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

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

939 

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

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

942 

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

944 :class:`~wuttjamaican.email.Message` object, **or** you *must* 

945 provide ``sender`` and ``recips``. The logic is not smart 

946 enough (yet?) to parse sender/recips from a simple string 

947 message. 

948 

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

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

951 simply raise to caller. 

952 

953 :returns: ``None`` 

954 """ 

955 if not sender: 

956 sender = message.sender 

957 if not sender: 

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

959 

960 if not recips: 

961 recips = set() 

962 if message.to: 

963 recips.update(message.to) 

964 if message.cc: 

965 recips.update(message.cc) 

966 if message.bcc: 

967 recips.update(message.bcc) 

968 elif isinstance(recips, str): 

969 recips = [recips] 

970 

971 recips = set(recips) 

972 if not recips: 

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

974 

975 if not isinstance(message, str): 

976 message = message.as_string() 

977 

978 # get smtp info 

979 server = self.config.get( 

980 f"{self.config.appname}.mail.smtp.server", default="localhost" 

981 ) 

982 username = self.config.get(f"{self.config.appname}.mail.smtp.username") 

983 password = self.config.get(f"{self.config.appname}.mail.smtp.password") 

984 

985 # make sure sending is enabled 

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

987 if not self.sending_is_enabled(): 

988 log.warning("email sending is disabled") 

989 return 

990 

991 # smtp connect 

992 session = smtplib.SMTP(server) 

993 if username and password: 

994 session.login(username, password) 

995 

996 # smtp send 

997 session.sendmail(sender, recips, message) 

998 session.quit() 

999 log.debug("email was sent") 

1000 

1001 def sending_is_enabled(self): 

1002 """ 

1003 Returns boolean indicating if email sending is enabled. 

1004 

1005 Set this flag in config like this: 

1006 

1007 .. code-block:: ini 

1008 

1009 [wutta.mail] 

1010 send_emails = true 

1011 

1012 Note that it is OFF by default. 

1013 """ 

1014 return self.config.get_bool( 

1015 f"{self.config.appname}.mail.send_emails", default=False 

1016 ) 

1017 

1018 def send_email( # pylint: disable=too-many-arguments,too-many-positional-arguments 

1019 self, 

1020 key=None, 

1021 context=None, 

1022 message=None, 

1023 sender=None, 

1024 recips=None, 

1025 fallback_key=None, 

1026 **kwargs, 

1027 ): 

1028 """ 

1029 Send an email message. 

1030 

1031 This method can send a message you provide, or it can 

1032 construct one automatically from key / config / templates. 

1033 

1034 The most common use case is assumed to be the latter, where 

1035 caller does not provide the message proper, but specifies key 

1036 and context so the message is auto-created. In that case this 

1037 method will also check :meth:`is_enabled()` and skip the 

1038 sending if that returns false. 

1039 

1040 :param key: When auto-creating a message, this is the 

1041 :term:`email key` identifying the type of email to send. 

1042 Used to lookup config settings and template files. 

1043 See also the ``fallback_key`` param, below. 

1044 

1045 :param context: Context dict for rendering automatic email 

1046 template(s). 

1047 

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

1049 as-is. If specified, nothing about the message will be 

1050 auto-assigned from config. 

1051 

1052 :param sender: Optional sender address for the 

1053 message/delivery. 

1054 

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

1056 provided) will also be used when constructing the 

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

1058 

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

1060 the actual SMTP delivery. 

1061 

1062 :param recips: Optional list of recipient addresses for 

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

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

1065 

1066 .. note:: 

1067 

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

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

1070 *all* true recipients. 

1071 

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

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

1074 ``Cc:`` and ``Bcc:`` 

1075 

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

1077 override various recipient headers, then you must 

1078 provide those explicitly:: 

1079 

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

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

1082 

1083 :param fallback_key: Optional fallback :term:`email key` to 

1084 use for config/template lookup, if nothing is found for 

1085 ``key``. 

1086 

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

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

1089 the ``message``. 

1090 """ 

1091 if key and not self.is_enabled(key): 

1092 log.debug("skipping disabled email: %s", key) 

1093 return 

1094 

1095 if message is None: 

1096 if not key: 

1097 raise ValueError("must specify email key (and/or message object)") 

1098 

1099 # auto-create message from key + context 

1100 if sender: 

1101 kwargs["sender"] = sender 

1102 message = self.make_auto_message( 

1103 key, context or {}, fallback_key=fallback_key, **kwargs 

1104 ) 

1105 if not (message.txt_body or message.html_body): 

1106 raise RuntimeError( 

1107 f"message (type: {key}) has no body - " 

1108 "perhaps template file not found?" 

1109 ) 

1110 

1111 if not (message.txt_body or message.html_body): 

1112 if key: 

1113 msg = f"message (type: {key}) has no body content" 

1114 else: 

1115 msg = "message has no body content" 

1116 raise ValueError(msg) 

1117 

1118 self.deliver_message(message, recips=recips)