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

309 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-23 21:59 -0500

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2026 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 

27# pylint: disable=too-many-lines 

28 

29import datetime 

30import logging 

31import re 

32import smtplib 

33from email.mime.multipart import MIMEMultipart 

34from email.mime.text import MIMEText 

35 

36from mako.lookup import TemplateLookup 

37from mako.template import Template 

38from mako.exceptions import TopLevelLookupException 

39 

40from wuttjamaican.app import GenericHandler 

41from wuttjamaican.util import resource_path 

42 

43log = logging.getLogger(__name__) 

44 

45 

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

47 """ 

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

49 

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

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

52 :term:`email module`. 

53 

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

55 it represents. For instance:: 

56 

57 from wuttjamaican.email import EmailSetting 

58 

59 class poser_alert_foo(EmailSetting): 

60 \""" 

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

62 \""" 

63 

64 default_subject = "Something happened!" 

65 

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

67 def get_sample_data(self): 

68 return { 

69 'foo': 1234, 

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

71 } 

72 

73 # (and elsewhere..) 

74 app.send_email('poser_alert_foo', { 

75 'foo': 5678, 

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

77 }) 

78 

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

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

81 emails. 

82 

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

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

85 

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

87 can be exposed for editing 

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

89 templates for preview 

90 

91 .. attribute:: key 

92 

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

94 

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

96 e.g. when sending an email. 

97 

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

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

100 read access, for convenience / code readability:: 

101 

102 class poser_alert_foo(EmailSetting): 

103 default_subject = "Something happened!" 

104 

105 handler = app.get_email_handler() 

106 setting = handler.get_email_setting("poser_alert_foo") 

107 assert setting.key == "poser_alert_foo" 

108 

109 See also :attr:`fallback_key`. 

110 """ 

111 

112 default_subject = None 

113 """ 

114 Default subject for sending emails of this type. 

115 

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

117 :attr:`Message.subject`. 

118 

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

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

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

122 

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

124 :meth:`get_default_subject()` . 

125 """ 

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 get_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 RunnerEmailSetting(EmailSetting): 

196 """ 

197 Base class for email settings for the output from arbitrary 

198 command runs. This inherits from :class:`EmailSetting`. 

199 

200 In some cases it may be helpful to base a custom email setting on 

201 this one, mostly for sake of inheriting the shared email template. 

202 This happens by way of the ``fallback_key``. 

203 """ 

204 

205 fallback_key = "runner_result" 

206 "" # suppress docs 

207 

208 default_subject = "Command result" 

209 "" # suppress docs 

210 

211 def get_sample_data(self): # pylint: disable=empty-docstring 

212 """ """ 

213 return { 

214 "cmd": ["echo", "hello world"], 

215 "retcode": 0, 

216 "output": "hello world", 

217 "runtime": datetime.timedelta(seconds=1), 

218 "runtime_display": "a moment", 

219 } 

220 

221 

222class runner_result(RunnerEmailSetting): # pylint: disable=invalid-name 

223 """ 

224 Sends the result and output after running a command. 

225 """ 

226 

227 

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

229 """ 

230 Represents an email message to be sent. 

231 

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

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

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

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

236 

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

238 

239 .. attribute:: key 

240 

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

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

243 one created via 

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

245 will always have a key. 

246 

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

248 logic. It is used by 

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

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

251 message only as a reference. 

252 

253 .. attribute:: sender 

254 

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

256 

257 .. attribute:: subject 

258 

259 Subject text for the message. 

260 

261 .. attribute:: to 

262 

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

264 

265 .. attribute:: cc 

266 

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

268 

269 .. attribute:: bcc 

270 

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

272 

273 .. attribute:: replyto 

274 

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

276 

277 .. attribute:: txt_body 

278 

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

280 

281 .. attribute:: html_body 

282 

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

284 

285 .. attribute:: attachments 

286 

287 List of file attachments for the message. 

288 """ 

289 

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

291 self, 

292 key=None, 

293 sender=None, 

294 subject=None, 

295 to=None, 

296 cc=None, 

297 bcc=None, 

298 replyto=None, 

299 txt_body=None, 

300 html_body=None, 

301 attachments=None, 

302 ): 

303 self.key = key 

304 self.sender = sender 

305 self.subject = subject 

306 self.to = self.get_recips(to) 

307 self.cc = self.get_recips(cc) 

308 self.bcc = self.get_recips(bcc) 

309 self.replyto = replyto 

310 self.txt_body = txt_body 

311 self.html_body = html_body 

312 self.attachments = attachments or [] 

313 

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

315 """ """ 

316 if value: 

317 if isinstance(value, str): 

318 value = [value] 

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

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

321 else: 

322 value = [] 

323 return list(value) 

324 

325 def as_string(self): 

326 """ 

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

328 within 

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

330 obtain the SMTP payload. 

331 """ 

332 msg = None 

333 

334 if self.txt_body and self.html_body: 

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

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

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

338 

339 elif self.txt_body: 

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

341 

342 elif self.html_body: 

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

344 

345 if not msg: 

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

347 

348 if self.attachments: 

349 for attachment in self.attachments: 

350 if isinstance(attachment, str): 

351 raise ValueError( 

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

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

354 ) 

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

356 

357 msg["Subject"] = self.subject 

358 msg["From"] = self.sender 

359 

360 for addr in self.to: 

361 msg["To"] = addr 

362 for addr in self.cc: 

363 msg["Cc"] = addr 

364 for addr in self.bcc: 

365 msg["Bcc"] = addr 

366 

367 if self.replyto: 

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

369 

370 return msg.as_string() 

371 

372 

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

374 """ 

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

376 handler`. 

377 

378 Responsible for sending email messages on behalf of the 

379 :term:`app`. 

380 

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

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

383 :term:`app handler`. 

384 """ 

385 

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

387 universal_subject = "Automated message" 

388 

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

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

391 

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

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

394 if not templates: 

395 # otherwise use all available paths, from app providers 

396 

397 # TODO: should auto-discover built-in templates? 

398 available = ["wuttjamaican:templates/email"] 

399 

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

401 if hasattr(provider, "email_templates"): 

402 templates = provider.email_templates 

403 if isinstance(templates, str): 

404 templates = [templates] 

405 if templates: 

406 available.extend(templates) 

407 templates = available 

408 

409 # convert all to true file paths 

410 if templates: 

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

412 

413 # will use these lookups from now on 

414 self.txt_templates = TemplateLookup(directories=templates) 

415 self.html_templates = TemplateLookup( 

416 directories=templates, 

417 # nb. escape HTML special chars 

418 # TODO: sounds great but i forget why? 

419 default_filters=["h"], 

420 ) 

421 

422 def get_email_modules(self): 

423 """ 

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

425 module>`. 

426 

427 This will discover all email modules exposed by the 

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

429 

430 Calls 

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

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

433 """ 

434 return self.get_provider_modules("email") 

435 

436 def get_email_settings(self): 

437 """ 

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

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

440 

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

442 discovers all the email settings it contains. 

443 """ 

444 if "email_settings" not in self.classes: 

445 

446 # TODO: should auto-discover built-in settings? 

447 self.classes["email_settings"] = {"runner_result": runner_result} 

448 

449 # nb. we only want lower_case_names - all UpperCaseNames 

450 # are assumed to be base classes 

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

452 

453 for module in self.get_email_modules(): 

454 for name in dir(module): 

455 obj = getattr(module, name) 

456 if ( 

457 isinstance(obj, type) 

458 and issubclass(obj, EmailSetting) 

459 and pattern.match(obj.__name__) 

460 ): 

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

462 

463 return self.classes["email_settings"] 

464 

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

466 """ 

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

468 key` (if it exists). 

469 

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

471 

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

473 

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

475 if the setting could not be found. 

476 """ 

477 settings = self.get_email_settings() 

478 if key in settings: 

479 setting = settings[key] 

480 if instance: 

481 setting = setting(self.config) 

482 return setting 

483 return None 

484 

485 def make_message(self, **kwargs): 

486 """ 

487 Make and return a new email message. 

488 

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

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

491 

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

493 """ 

494 return Message(**kwargs) 

495 

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

497 self, 

498 key, 

499 context=None, 

500 default_subject=None, 

501 prefix_subject=True, 

502 default_prefix=None, 

503 fallback_key=None, 

504 **kwargs, 

505 ): 

506 """ 

507 Make a new email message using config to determine its 

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

509 

510 Once everything has been collected/prepared, 

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

512 and that is returned. 

513 

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

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

516 template names pertinent to the message. See also the 

517 ``fallback_key`` param, below. 

518 

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

520 the message. 

521 

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

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

524 

525 :param prefix_subject: Boolean indicating the message subject 

526 should be auto-prefixed. 

527 

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

529 is configured. 

530 

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

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

533 ``key``. 

534 

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

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

537 

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

539 

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

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

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

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

544 

545 * :meth:`get_auto_sender()` 

546 * :meth:`get_auto_subject()` 

547 * :meth:`get_auto_to()` 

548 * :meth:`get_auto_cc()` 

549 * :meth:`get_auto_bcc()` 

550 * :meth:`get_auto_txt_body()` 

551 * :meth:`get_auto_html_body()` 

552 """ 

553 context = context or {} 

554 kwargs["key"] = key 

555 

556 if "sender" not in kwargs: 

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

558 

559 if not kwargs.get("subject"): 

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

561 key, 

562 context, 

563 default=default_subject, 

564 prefix=prefix_subject, 

565 default_prefix=default_prefix, 

566 fallback_key=fallback_key, 

567 ) 

568 elif prefix_subject: 

569 prefix = self.get_auto_subject_prefix(key) 

570 if not self.config.production(): 

571 prefix = f"[TEST] {prefix}" 

572 kwargs["subject"] = f"{prefix} {kwargs['subject']}" 

573 

574 if "to" not in kwargs: 

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

576 if "cc" not in kwargs: 

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

578 if "bcc" not in kwargs: 

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

580 

581 if "txt_body" not in kwargs: 

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

583 key, context, fallback_key=fallback_key 

584 ) 

585 if "html_body" not in kwargs: 

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

587 key, context, fallback_key=fallback_key 

588 ) 

589 

590 return self.make_message(**kwargs) 

591 

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

593 """ 

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

595 subject and/or body templates. 

596 

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

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

599 

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

601 following: 

602 

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

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

605 

606 Subclass may further modify as needed. 

607 

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

609 

610 :param context: Input context dict. 

611 

612 :returns: Final context dict 

613 """ 

614 if context is None: 

615 context = {} 

616 context.update( 

617 { 

618 "config": self.config, 

619 "app": self.app, 

620 } 

621 ) 

622 return context 

623 

624 def get_auto_sender(self, key): 

625 """ 

626 Returns automatic 

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

628 message, as determined by config. 

629 """ 

630 # prefer configured sender specific to key 

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

632 if sender: 

633 return sender 

634 

635 # fall back to global default 

636 return self.config.get( 

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

638 ) 

639 

640 def get_auto_replyto(self, key): 

641 """ 

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

643 address for a message, as determined by config. 

644 """ 

645 # prefer configured replyto specific to key 

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

647 if replyto: 

648 return replyto 

649 

650 # fall back to global default, if present 

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

652 

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

654 self, 

655 key, 

656 context=None, 

657 rendered=True, 

658 default=None, 

659 fallback_key=None, 

660 setting=None, 

661 prefix=True, 

662 default_prefix=None, 

663 ): 

664 """ 

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

666 line for a message, as determined by config. 

667 

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

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

670 the :meth:`get_auto_subject_prefix()`. 

671 

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

673 ``fallback_key`` param, below. 

674 

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

676 template, if applicable. 

677 

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

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

680 subject text. 

681 

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

683 

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

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

686 

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

688 instance. This is passed along to 

689 :meth:`get_auto_subject_template()`. 

690 

691 :param prefix: Boolean indicating the message subject should 

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

693 is false. 

694 

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

696 is configured. 

697 

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

699 """ 

700 template = self.get_auto_subject_template( 

701 key, setting=setting, default=default, fallback_key=fallback_key 

702 ) 

703 if not rendered: 

704 return template 

705 

706 context = self.get_email_context(key, context) 

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

708 

709 if prefix: 

710 if prefix := self.get_auto_subject_prefix( 

711 key, default=default_prefix, setting=setting, fallback_key=fallback_key 

712 ): 

713 subject = f"{prefix} {subject}" 

714 if not self.config.production(): 

715 subject = f"[TEST] {subject}" 

716 

717 return subject 

718 

719 def get_auto_subject_template( 

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

721 ): 

722 """ 

723 Returns the template string to use for automatic subject line 

724 of a message, as determined by config. 

725 

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

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

728 

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

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

731 

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

733 

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

735 

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

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

738 

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

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

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

742 optimization; otherwise it will be fetched if needed via 

743 :meth:`get_email_setting()`. 

744 

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

746 """ 

747 # prefer configured subject specific to key 

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

749 return template 

750 

751 # or use caller-specified default, if applicable 

752 if default: 

753 return default 

754 

755 # or use fallback key, if provided 

756 if fallback_key: 

757 if template := self.config.get( 

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

759 ): 

760 return template 

761 

762 # or subject from email setting, if defined 

763 if not setting: 

764 setting = self.get_email_setting(key) 

765 if setting: 

766 if subject := setting.get_default_subject(): 

767 return subject 

768 

769 # fall back to global default 

770 return self.config.get( 

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

772 default=self.universal_subject, 

773 ) 

774 

775 def get_auto_subject_prefix( 

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

777 ): 

778 """ 

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

780 determined by config. This is called by 

781 :meth:`get_auto_subject()`. 

782 

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

784 normal string, not a template. 

785 

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

787 automatically when applying the prefix to a message subject. 

788 

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

790 

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

792 

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

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

795 

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

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

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

799 optimization; otherwise it will be fetched if needed via 

800 :meth:`get_email_setting()`. 

801 

802 :returns: Final subject prefix string 

803 """ 

804 

805 # prefer configured prefix specific to key 

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

807 return prefix 

808 

809 # or use caller-specified default, if applicable 

810 if default: 

811 return default 

812 

813 # or use fallback key, if provided 

814 if fallback_key: 

815 if prefix := self.config.get( 

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

817 ): 

818 return prefix 

819 

820 # or prefix from email setting, if defined 

821 if not setting: 

822 setting = self.get_email_setting(key) 

823 if setting: 

824 if prefix := setting.get_default_prefix(): 

825 return prefix 

826 

827 # fall back to global default 

828 return self.config.get( 

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

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

831 ) 

832 

833 def get_auto_to(self, key): 

834 """ 

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

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

837 """ 

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

839 

840 def get_auto_cc(self, key): 

841 """ 

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

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

844 """ 

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

846 

847 def get_auto_bcc(self, key): 

848 """ 

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

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

851 """ 

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

853 

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

855 """ """ 

856 typ = typ.lower() 

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

858 raise ValueError("requested type not supported") 

859 

860 # prefer configured recips specific to key 

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

862 if recips: 

863 return recips 

864 

865 # fall back to global default 

866 return self.config.get_list( 

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

868 ) 

869 

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

871 """ 

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

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

874 a template with the given context. 

875 """ 

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

877 if template: 

878 context = self.get_email_context(key, context) 

879 return template.render(**context) 

880 return None 

881 

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

883 """ 

884 Returns automatic 

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

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

887 with the given context. 

888 """ 

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

890 if template: 

891 context = self.get_email_context(key, context) 

892 return template.render(**context) 

893 return None 

894 

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

896 self, key, mode, fallback_key=None 

897 ): 

898 """ """ 

899 mode = mode.lower() 

900 if mode == "txt": 

901 templates = self.txt_templates 

902 elif mode == "html": 

903 templates = self.html_templates 

904 else: 

905 raise ValueError("requested mode not supported") 

906 

907 try: 

908 

909 # prefer specific template for key 

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

911 

912 except TopLevelLookupException: 

913 

914 # but can use fallback if applicable 

915 if fallback_key: 

916 try: 

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

918 except TopLevelLookupException: 

919 pass 

920 

921 return None 

922 

923 def get_notes(self, key): 

924 """ 

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

926 

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

928 

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

930 """ 

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

932 

933 def is_enabled(self, key): 

934 """ 

935 Returns flag indicating whether the given email type is 

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

937 or always suppressed (disabled). 

938 

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

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

941 

942 .. code-block:: ini 

943 

944 [wutta.email] 

945 

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

947 default.enabled = true 

948 

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

950 foo.enabled = false 

951 

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

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

954 one type for testing: 

955 

956 .. code-block:: ini 

957 

958 [wutta.email] 

959 

960 # do not send any emails unless explicitly enabled 

961 default.enabled = false 

962 

963 # turn on 'bar' for testing 

964 bar.enabled = true 

965 

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

967 master shutoff switch. 

968 

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

970 

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

972 """ 

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

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

975 if enabled is not None: 

976 return enabled 

977 return True 

978 

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

980 """ 

981 Deliver a message via SMTP smarthost. 

982 

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

984 object or similar, or a string representing the complete 

985 message to be sent as-is. 

986 

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

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

989 

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

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

992 

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

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

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

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

997 message. 

998 

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

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

1001 simply raise to caller. 

1002 

1003 :returns: ``None`` 

1004 """ 

1005 if not sender: 

1006 sender = message.sender 

1007 if not sender: 

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

1009 

1010 if not recips: 

1011 recips = set() 

1012 if message.to: 

1013 recips.update(message.to) 

1014 if message.cc: 

1015 recips.update(message.cc) 

1016 if message.bcc: 

1017 recips.update(message.bcc) 

1018 elif isinstance(recips, str): 

1019 recips = [recips] 

1020 

1021 recips = set(recips) 

1022 if not recips: 

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

1024 

1025 if not isinstance(message, str): 

1026 message = message.as_string() 

1027 

1028 # get smtp info 

1029 server = self.config.get( 

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

1031 ) 

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

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

1034 

1035 # make sure sending is enabled 

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

1037 if not self.sending_is_enabled(): 

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

1039 return 

1040 

1041 # smtp connect 

1042 session = smtplib.SMTP(server) 

1043 if username and password: 

1044 session.login(username, password) 

1045 

1046 # smtp send 

1047 session.sendmail(sender, recips, message) 

1048 session.quit() 

1049 log.debug("email was sent") 

1050 

1051 def sending_is_enabled(self): 

1052 """ 

1053 Returns boolean indicating if email sending is enabled. 

1054 

1055 Set this flag in config like this: 

1056 

1057 .. code-block:: ini 

1058 

1059 [wutta.mail] 

1060 send_emails = true 

1061 

1062 Note that it is OFF by default. 

1063 """ 

1064 return self.config.get_bool( 

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

1066 ) 

1067 

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

1069 self, 

1070 key=None, 

1071 context=None, 

1072 message=None, 

1073 sender=None, 

1074 recips=None, 

1075 fallback_key=None, 

1076 **kwargs, 

1077 ): 

1078 """ 

1079 Send an email message. 

1080 

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

1082 construct one automatically from key / config / templates. 

1083 

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

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

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

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

1088 sending if that returns false. 

1089 

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

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

1092 Used to lookup config settings and template files. 

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

1094 

1095 :param context: Context dict for rendering automatic email 

1096 template(s). 

1097 

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

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

1100 auto-assigned from config. 

1101 

1102 :param sender: Optional sender address for the 

1103 message/delivery. 

1104 

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

1106 provided) will also be used when constructing the 

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

1108 

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

1110 the actual SMTP delivery. 

1111 

1112 :param recips: Optional list of recipient addresses for 

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

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

1115 

1116 .. note:: 

1117 

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

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

1120 *all* true recipients. 

1121 

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

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

1124 ``Cc:`` and ``Bcc:`` 

1125 

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

1127 override various recipient headers, then you must 

1128 provide those explicitly:: 

1129 

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

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

1132 

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

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

1135 ``key``. 

1136 

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

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

1139 the ``message``. 

1140 """ 

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

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

1143 return 

1144 

1145 if message is None: 

1146 if not key: 

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

1148 

1149 # auto-create message from key + context 

1150 if sender: 

1151 kwargs["sender"] = sender 

1152 message = self.make_auto_message( 

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

1154 ) 

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

1156 raise RuntimeError( 

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

1158 "perhaps template file not found?" 

1159 ) 

1160 

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

1162 if key: 

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

1164 else: 

1165 msg = "message has no body content" 

1166 raise ValueError(msg) 

1167 

1168 self.deliver_message(message, recips=recips)