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

248 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2025-01-09 18:05 -0600

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 importlib 

28import logging 

29import smtplib 

30from email.mime.multipart import MIMEMultipart 

31from email.mime.text import MIMEText 

32 

33from mako.lookup import TemplateLookup 

34from mako.template import Template 

35from mako.exceptions import TopLevelLookupException 

36 

37from wuttjamaican.app import GenericHandler 

38from wuttjamaican.util import resource_path 

39 

40 

41log = logging.getLogger(__name__) 

42 

43 

44class EmailSetting: 

45 """ 

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

47 

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

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

50 :term:`email module`. 

51 

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

53 it represents. For instance:: 

54 

55 from wuttjamaican.email import EmailSetting 

56 

57 class poser_alert_foo(EmailSetting): 

58 \""" 

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

60 \""" 

61 

62 default_subject = "Something happened!" 

63 

64 def sample_data(self): 

65 return { 

66 'foo': 1234, 

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

68 } 

69 

70 # (and elsewhere..) 

71 app.send_email('poser_alert_foo', { 

72 'foo': 5678, 

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

74 }) 

75 

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

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

78 emails. 

79 

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

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

82 

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

84 can be exposed for editing 

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

86 templates for preview 

87 

88 .. attribute:: default_subject 

89 

90 Default :attr:`Message.subject` for the email, if none is 

91 configured. 

92 

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

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

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

96 """ 

97 default_subject = None 

98 

99 def __init__(self, config): 

100 self.config = config 

101 self.app = config.get_app() 

102 self.key = self.__class__.__name__ 

103 

104 def sample_data(self): 

105 """ 

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

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

108 show a "preview" of the email. 

109 """ 

110 return {} 

111 

112 

113class Message: 

114 """ 

115 Represents an email message to be sent. 

116 

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

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

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

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

121 

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

123 

124 .. attribute:: key 

125 

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

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

128 one created via 

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

130 will always have a key. 

131 

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

133 logic. It is used by 

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

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

136 message only as a reference. 

137 

138 .. attribute:: sender 

139 

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

141 

142 .. attribute:: subject 

143 

144 Subject text for the message. 

145 

146 .. attribute:: to 

147 

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

149 

150 .. attribute:: cc 

151 

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

153 

154 .. attribute:: bcc 

155 

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

157 

158 .. attribute:: replyto 

159 

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

161 

162 .. attribute:: txt_body 

163 

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

165 

166 .. attribute:: html_body 

167 

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

169 """ 

170 

171 def __init__( 

172 self, 

173 key=None, 

174 sender=None, 

175 subject=None, 

176 to=None, 

177 cc=None, 

178 bcc=None, 

179 replyto=None, 

180 txt_body=None, 

181 html_body=None, 

182 ): 

183 self.key = key 

184 self.sender = sender 

185 self.subject = subject 

186 self.set_recips('to', to) 

187 self.set_recips('cc', cc) 

188 self.set_recips('bcc', bcc) 

189 self.replyto = replyto 

190 self.txt_body = txt_body 

191 self.html_body = html_body 

192 

193 def set_recips(self, name, value): 

194 """ """ 

195 if value: 

196 if isinstance(value, str): 

197 value = [value] 

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

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

200 else: 

201 value = [] 

202 setattr(self, name, list(value)) 

203 

204 def as_string(self): 

205 """ 

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

207 within 

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

209 obtain the SMTP payload. 

210 """ 

211 msg = None 

212 

213 if self.txt_body and self.html_body: 

214 txt = MIMEText(self.txt_body, _charset='utf_8') 

215 html = MIMEText(self.html_body, _subtype='html', _charset='utf_8') 

216 msg = MIMEMultipart(_subtype='alternative', _subparts=[txt, html]) 

217 

218 elif self.txt_body: 

219 msg = MIMEText(self.txt_body, _charset='utf_8') 

220 

221 elif self.html_body: 

222 msg = MIMEText(self.html_body, 'html', _charset='utf_8') 

223 

224 if not msg: 

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

226 

227 msg['Subject'] = self.subject 

228 msg['From'] = self.sender 

229 

230 for addr in self.to: 

231 msg['To'] = addr 

232 for addr in self.cc: 

233 msg['Cc'] = addr 

234 for addr in self.bcc: 

235 msg['Bcc'] = addr 

236 

237 if self.replyto: 

238 msg.add_header('Reply-To', self.replyto) 

239 

240 return msg.as_string() 

241 

242 

243class EmailHandler(GenericHandler): 

244 """ 

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

246 handler`. 

247 

248 Responsible for sending email messages on behalf of the 

249 :term:`app`. 

250 

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

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

253 :term:`app handler`. 

254 """ 

255 

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

257 universal_subject = "Automated message" 

258 

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

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

261 

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

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

264 if not templates: 

265 

266 # otherwise use all available paths, from app providers 

267 available = [] 

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

269 if hasattr(provider, 'email_templates'): 

270 templates = provider.email_templates 

271 if isinstance(templates, str): 

272 templates = [templates] 

273 if templates: 

274 available.extend(templates) 

275 templates = available 

276 

277 # convert all to true file paths 

278 if templates: 

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

280 

281 # will use these lookups from now on 

282 self.txt_templates = TemplateLookup(directories=templates) 

283 self.html_templates = TemplateLookup(directories=templates, 

284 # nb. escape HTML special chars 

285 # TODO: sounds great but i forget why? 

286 default_filters=['h']) 

287 

288 def get_email_modules(self): 

289 """ 

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

291 module>`. 

292 

293 This will discover all email modules exposed by the 

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

295 """ 

296 if not hasattr(self, '_email_modules'): 

297 self._email_modules = [] 

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

299 if hasattr(provider, 'email_modules'): 

300 modules = provider.email_modules 

301 if modules: 

302 if isinstance(modules, str): 

303 modules = [modules] 

304 for module in modules: 

305 module = importlib.import_module(module) 

306 self._email_modules.append(module) 

307 

308 return self._email_modules 

309 

310 def get_email_settings(self): 

311 """ 

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

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

314 

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

316 discovers all the email settings it contains. 

317 """ 

318 if not hasattr(self, '_email_settings'): 

319 self._email_settings = {} 

320 for module in self.get_email_modules(): 

321 for name in dir(module): 

322 obj = getattr(module, name) 

323 if (isinstance(obj, type) 

324 and obj is not EmailSetting 

325 and issubclass(obj, EmailSetting)): 

326 self._email_settings[obj.__name__] = obj 

327 

328 return self._email_settings 

329 

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

331 """ 

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

333 key` (if it exists). 

334 

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

336 

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

338 

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

340 if the setting could not be found. 

341 """ 

342 settings = self.get_email_settings() 

343 if key in settings: 

344 setting = settings[key] 

345 if instance: 

346 setting = setting(self.config) 

347 return setting 

348 

349 def make_message(self, **kwargs): 

350 """ 

351 Make and return a new email message. 

352 

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

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

355 

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

357 """ 

358 return Message(**kwargs) 

359 

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

361 """ 

362 Make a new email message using config to determine its 

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

364 

365 Once everything has been collected/prepared, 

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

367 and that is returned. 

368 

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

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

371 template names pertinent to the message. 

372 

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

374 the message. 

375 

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

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

378 

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

380 

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

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

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

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

385 

386 * :meth:`get_auto_sender()` 

387 * :meth:`get_auto_subject()` 

388 * :meth:`get_auto_to()` 

389 * :meth:`get_auto_cc()` 

390 * :meth:`get_auto_bcc()` 

391 * :meth:`get_auto_txt_body()` 

392 * :meth:`get_auto_html_body()` 

393 """ 

394 kwargs['key'] = key 

395 if 'sender' not in kwargs: 

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

397 if 'subject' not in kwargs: 

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

399 if 'to' not in kwargs: 

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

401 if 'cc' not in kwargs: 

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

403 if 'bcc' not in kwargs: 

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

405 if 'txt_body' not in kwargs: 

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

407 if 'html_body' not in kwargs: 

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

409 return self.make_message(**kwargs) 

410 

411 def get_auto_sender(self, key): 

412 """ 

413 Returns automatic 

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

415 message, as determined by config. 

416 """ 

417 # prefer configured sender specific to key 

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

419 if sender: 

420 return sender 

421 

422 # fall back to global default 

423 return self.config.get(f'{self.config.appname}.email.default.sender', 

424 default='root@localhost') 

425 

426 def get_auto_replyto(self, key): 

427 """ 

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

429 address for a message, as determined by config. 

430 """ 

431 # prefer configured replyto specific to key 

432 replyto = self.config.get(f'{self.config.appname}.email.{key}.replyto') 

433 if replyto: 

434 return replyto 

435 

436 # fall back to global default, if present 

437 return self.config.get(f'{self.config.appname}.email.default.replyto') 

438 

439 def get_auto_subject(self, key, context={}, rendered=True, setting=None): 

440 """ 

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

442 line for a message, as determined by config. 

443 

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

445 (usually) renders the result using the given context. 

446 

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

448 

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

450 template, if applicable. 

451 

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

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

454 subject text. 

455 

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

457 instance. This is passed along to 

458 :meth:`get_auto_subject_template()`. 

459 

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

461 """ 

462 template = self.get_auto_subject_template(key, setting=setting) 

463 if not rendered: 

464 return template 

465 

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

467 

468 def get_auto_subject_template(self, key, setting=None): 

469 """ 

470 Returns the template string to use for automatic subject line 

471 of a message, as determined by config. 

472 

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

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

475 

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

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

478 

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

480 

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

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

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

484 optimization; otherwise it will be fetched if needed via 

485 :meth:`get_email_setting()`. 

486 

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

488 """ 

489 # prefer configured subject specific to key 

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

491 if template: 

492 return template 

493 

494 # or subject from email setting, if defined 

495 if not setting: 

496 setting = self.get_email_setting(key) 

497 if setting and setting.default_subject: 

498 return setting.default_subject 

499 

500 # fall back to global default 

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

502 default=self.universal_subject) 

503 

504 def get_auto_to(self, key): 

505 """ 

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

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

508 """ 

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

510 

511 def get_auto_cc(self, key): 

512 """ 

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

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

515 """ 

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

517 

518 def get_auto_bcc(self, key): 

519 """ 

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

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

522 """ 

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

524 

525 def get_auto_recips(self, key, typ): 

526 """ """ 

527 typ = typ.lower() 

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

529 raise ValueError("requested type not supported") 

530 

531 # prefer configured recips specific to key 

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

533 if recips: 

534 return recips 

535 

536 # fall back to global default 

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

538 default=[]) 

539 

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

541 """ 

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

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

544 a template with the given context. 

545 """ 

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

547 if template: 

548 return template.render(**context) 

549 

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

551 """ 

552 Returns automatic 

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

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

555 with the given context. 

556 """ 

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

558 if template: 

559 return template.render(**context) 

560 

561 def get_auto_body_template(self, key, mode): 

562 """ """ 

563 mode = mode.lower() 

564 if mode not in ('txt', 'html'): 

565 raise ValueError("requested mode not supported") 

566 

567 if mode == 'txt': 

568 templates = self.txt_templates 

569 elif mode == 'html': 

570 templates = self.html_templates 

571 

572 try: 

573 return templates.get_template(f'{key}.{mode}.mako') 

574 except TopLevelLookupException: 

575 pass 

576 

577 def get_notes(self, key): 

578 """ 

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

580 

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

582 

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

584 """ 

585 return self.config.get(f'{self.config.appname}.email.{key}.notes') 

586 

587 def is_enabled(self, key): 

588 """ 

589 Returns flag indicating whether the given email type is 

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

591 or always suppressed (disabled). 

592 

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

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

595 

596 .. code-block:: ini 

597 

598 [wutta.email] 

599 

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

601 default.enabled = true 

602 

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

604 foo.enabled = false 

605 

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

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

608 one type for testing: 

609 

610 .. code-block:: ini 

611 

612 [wutta.email] 

613 

614 # do not send any emails unless explicitly enabled 

615 default.enabled = false 

616 

617 # turn on 'bar' for testing 

618 bar.enabled = true 

619 

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

621 master shutoff switch. 

622 

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

624 

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

626 """ 

627 for key in set([key, 'default']): 

628 enabled = self.config.get_bool(f'{self.config.appname}.email.{key}.enabled') 

629 if enabled is not None: 

630 return enabled 

631 return True 

632 

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

634 """ 

635 Deliver a message via SMTP smarthost. 

636 

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

638 object or similar, or a string representing the complete 

639 message to be sent as-is. 

640 

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

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

643 

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

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

646 

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

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

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

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

651 message. 

652 

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

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

655 simply raise to caller. 

656 

657 :returns: ``None`` 

658 """ 

659 if not sender: 

660 sender = message.sender 

661 if not sender: 

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

663 

664 if not recips: 

665 recips = set() 

666 if message.to: 

667 recips.update(message.to) 

668 if message.cc: 

669 recips.update(message.cc) 

670 if message.bcc: 

671 recips.update(message.bcc) 

672 elif isinstance(recips, str): 

673 recips = [recips] 

674 

675 recips = set(recips) 

676 if not recips: 

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

678 

679 if not isinstance(message, str): 

680 message = message.as_string() 

681 

682 # get smtp info 

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

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

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

686 

687 # make sure sending is enabled 

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

689 if not self.sending_is_enabled(): 

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

691 return 

692 

693 # smtp connect 

694 session = smtplib.SMTP(server) 

695 if username and password: 

696 session.login(username, password) 

697 

698 # smtp send 

699 session.sendmail(sender, recips, message) 

700 session.quit() 

701 log.debug("email was sent") 

702 

703 def sending_is_enabled(self): 

704 """ 

705 Returns boolean indicating if email sending is enabled. 

706 

707 Set this flag in config like this: 

708 

709 .. code-block:: ini 

710 

711 [wutta.mail] 

712 send_emails = true 

713 

714 Note that it is OFF by default. 

715 """ 

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

717 default=False) 

718 

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

720 """ 

721 Send an email message. 

722 

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

724 construct one automatically from key / config / templates. 

725 

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

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

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

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

730 sending if that returns false. 

731 

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

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

734 Used to lookup config settings and template files. 

735 

736 :param context: Context dict for rendering automatic email 

737 template(s). 

738 

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

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

741 auto-assigned from config. 

742 

743 :param sender: Optional sender address for the 

744 message/delivery. 

745 

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

747 provided) will also be used when constructing the 

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

749 

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

751 the actual SMTP delivery. 

752 

753 :param recips: Optional list of recipient addresses for 

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

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

756 

757 .. note:: 

758 

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

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

761 *all* true recipients. 

762 

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

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

765 ``Cc:`` and ``Bcc:`` 

766 

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

768 override various recipient headers, then you must 

769 provide those explicitly:: 

770 

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

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

773 

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

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

776 the ``message``. 

777 """ 

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

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

780 return 

781 

782 if message is None: 

783 if not key: 

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

785 

786 # auto-create message from key + context 

787 if sender: 

788 kwargs['sender'] = sender 

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

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

791 raise RuntimeError(f"message (type: {key}) has no body - " 

792 "perhaps template file not found?") 

793 

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

795 if key: 

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

797 else: 

798 msg = "message has no body content" 

799 raise ValueError(msg) 

800 

801 self.deliver_message(message, recips=recips)