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
« 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
28import logging
29import re
30import smtplib
31from email.mime.multipart import MIMEMultipart
32from email.mime.text import MIMEText
34from mako.lookup import TemplateLookup
35from mako.template import Template
36from mako.exceptions import TopLevelLookupException
38from wuttjamaican.app import GenericHandler
39from wuttjamaican.util import resource_path
42log = logging.getLogger(__name__)
45class EmailSetting: # pylint: disable=too-few-public-methods
46 """
47 Base class for all :term:`email settings <email setting>`.
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`.
53 The name of each subclass should match the :term:`email key` which
54 it represents. For instance::
56 from wuttjamaican.email import EmailSetting
58 class poser_alert_foo(EmailSetting):
59 \"""
60 Sent when something happens that we think deserves an alert.
61 \"""
63 default_subject = "Something happened!"
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 }
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 })
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.
82 The purpose of defining a subclass for each email type is 2-fold,
83 but really the answer is "for maintenance sake" -
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
90 .. attribute:: key
92 Unique identifier for this :term:`email type`.
94 This is the :term:`email key` used for config/template lookup,
95 e.g. when sending an email.
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::
101 class poser_alert_foo(EmailSetting):
102 default_subject = "Something happened!"
104 handler = app.get_email_handler()
105 setting = handler.get_email_setting("poser_alert_foo")
106 assert setting.key == "poser_alert_foo"
108 See also :attr:`fallback_key`.
110 .. attribute:: default_subject
112 Default subject for sending emails of this type.
114 Usually, if config does not override, this will become
115 :attr:`Message.subject`.
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.
121 Calling code should not access this directly, but instead use
122 :meth:`get_default_subject()` .
123 """
125 default_subject = None
127 default_prefix = None
128 """
129 Default subject prefix for emails of this type.
131 Calling code should not access this directly, but instead use
132 :meth:`get_default_prefix()` .
133 """
135 fallback_key = None
136 """
137 Optional fallback key to use for config/template lookup, if
138 nothing is found for :attr:`key`.
139 """
141 def __init__(self, config):
142 self.config = config
143 self.app = config.get_app()
144 self.key = self.__class__.__name__
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.
152 Default logic will use the class docstring.
154 :returns: String description for the email type
155 """
156 return self.__class__.__doc__.strip()
158 def get_default_prefix(self):
159 """
160 This returns the default subject prefix, for sending emails of
161 this type.
163 Default logic here returns :attr:`default_prefix` as-is.
165 This method will often return ``None`` in which case the
166 global default prefix is used.
168 :returns: Default subject prefix as string, or ``None``
169 """
170 return self.default_prefix
172 def get_default_subject(self):
173 """
174 This must return the default subject, for sending emails of
175 this type.
177 If config does not override, this will become
178 :attr:`Message.subject`.
180 Default logic here returns :attr:`default_subject` as-is.
182 :returns: Default subject as string
183 """
184 return self.default_subject
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 {}
195class Message: # pylint: disable=too-many-instance-attributes
196 """
197 Represents an email message to be sent.
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`.
204 All attributes shown below may also be specified via constructor.
206 .. attribute:: key
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.
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.
220 .. attribute:: sender
222 Sender (``From:``) address for the message.
224 .. attribute:: subject
226 Subject text for the message.
228 .. attribute:: to
230 List of ``To:`` recipients for the message.
232 .. attribute:: cc
234 List of ``Cc:`` recipients for the message.
236 .. attribute:: bcc
238 List of ``Bcc:`` recipients for the message.
240 .. attribute:: replyto
242 Optional reply-to (``Reply-To:``) address for the message.
244 .. attribute:: txt_body
246 String with the ``text/plain`` body content.
248 .. attribute:: html_body
250 String with the ``text/html`` body content.
252 .. attribute:: attachments
254 List of file attachments for the message.
255 """
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 []
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)
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
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])
306 elif self.txt_body:
307 msg = MIMEText(self.txt_body, _charset="utf_8")
309 elif self.html_body:
310 msg = MIMEText(self.html_body, "html", _charset="utf_8")
312 if not msg:
313 raise ValueError("message has no body parts")
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)
324 msg["Subject"] = self.subject
325 msg["From"] = self.sender
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
334 if self.replyto:
335 msg.add_header("Reply-To", self.replyto)
337 return msg.as_string()
340class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
341 """
342 Base class and default implementation for the :term:`email
343 handler`.
345 Responsible for sending email messages on behalf of the
346 :term:`app`.
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 """
353 # nb. this is fallback/default subject for auto-message
354 universal_subject = "Automated message"
356 def __init__(self, *args, **kwargs):
357 super().__init__(*args, **kwargs)
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
373 # convert all to true file paths
374 if templates:
375 templates = [resource_path(p) for p in templates]
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 )
386 def get_email_modules(self):
387 """
388 Returns a list of all known :term:`email modules <email
389 module>`.
391 This will discover all email modules exposed by the
392 :term:`app`, and/or its :term:`providers <provider>`.
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")
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`.
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"] = {}
411 # nb. we only want lower_case_names - all UpperCaseNames
412 # are assumed to be base classes
413 pattern = re.compile(r"^[a-z]")
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
425 return self.classes["email_settings"]
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).
432 :param key: Key for the :term:`email type`.
434 :param instance: Whether to return the class, or an instance.
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
447 def make_message(self, **kwargs):
448 """
449 Make and return a new email message.
451 This is the "raw" factory which is simply a wrapper around the
452 class constructor. See also :meth:`make_auto_message()`.
454 :returns: :class:`~wuttjamaican.email.Message` object.
455 """
456 return Message(**kwargs)
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.
472 Once everything has been collected/prepared,
473 :meth:`make_message()` is called to create the final message,
474 and that is returned.
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.
481 :param context: Context dict used to render template(s) for
482 the message.
484 :param default_subject: Optional :attr:`~Message.subject`
485 template/string to use, if config does not specify one.
487 :param prefix_subject: Boolean indicating the message subject
488 should be auto-prefixed.
490 :param default_prefix: Default subject prefix to use if none
491 is configured.
493 :param fallback_key: Optional fallback :term:`email key` to
494 use for config/template lookup, if nothing is found for
495 ``key``.
497 :param \\**kwargs: Any remaining kwargs are passed as-is to
498 :meth:`make_message()`. More on this below.
500 :returns: :class:`~wuttjamaican.email.Message` object.
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.
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)
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.
549 Normally the input ``context`` is coming from the
550 :meth:`send_email()` param of the same name.
552 By default, this method modifies the input context to add the
553 following:
555 * ``config`` - reference to the :term:`config object`
556 * ``app`` - reference to the :term:`app handler`
558 Subclass may further modify as needed.
560 :param key: The :term:`email key` for which to get context.
562 :param context: Input context dict.
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
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
587 # fall back to global default
588 return self.config.get(
589 f"{self.config.appname}.email.default.sender", default="root@localhost"
590 )
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
602 # fall back to global default, if present
603 return self.config.get(f"{self.config.appname}.email.default.replyto")
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.
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()`.
624 :param key: Key for the :term:`email type`. See also the
625 ``fallback_key`` param, below.
627 :param context: Dict of context for rendering the subject
628 template, if applicable.
630 :param rendered: If this is ``False``, the "raw" subject
631 template will be returned, instead of the final/rendered
632 subject text.
634 :param default: Default subject to use if none is configured.
636 :param fallback_key: Optional fallback :term:`email key` to
637 use for config lookup, if nothing is found for ``key``.
639 :param setting: Optional :class:`EmailSetting` class or
640 instance. This is passed along to
641 :meth:`get_auto_subject_template()`.
643 :param prefix: Boolean indicating the message subject should
644 be auto-prefixed. This is ignored when ``rendered`` param
645 is false.
647 :param default_prefix: Default subject prefix to use if none
648 is configured.
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
658 context = self.get_email_context(key, context)
659 subject = Template(template).render(**context)
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}"
667 return subject
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.
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.
679 The template returned from this method is used to render the
680 final subject line in :meth:`get_auto_subject()`.
682 :param key: Key for the :term:`email type`.
684 :param default: Default subject to use if none is configured.
686 :param fallback_key: Optional fallback :term:`email key` to
687 use for config lookup, if nothing is found for ``key``.
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()`.
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
701 # or use caller-specified default, if applicable
702 if default:
703 return default
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
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
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 )
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()`.
733 Note that unlike the subject proper, the prefix is just a
734 normal string, not a template.
736 Example prefix is ``"[Wutta]"`` - trailing space will be added
737 automatically when applying the prefix to a message subject.
739 :param key: The :term:`email key` requested.
741 :param default: Default prefix to use if none is configured.
743 :param fallback_key: Optional fallback :term:`email key` to
744 use for config lookup, if nothing is found for ``key``.
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()`.
752 :returns: Final subject prefix string
753 """
755 # prefer configured prefix specific to key
756 if prefix := self.config.get(f"{self.config.appname}.email.{key}.prefix"):
757 return prefix
759 # or use caller-specified default, if applicable
760 if default:
761 return default
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
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
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 )
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")
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")
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")
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")
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
815 # fall back to global default
816 return self.config.get_list(
817 f"{self.config.appname}.email.default.{typ}", default=[]
818 )
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
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
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")
857 try:
859 # prefer specific template for key
860 return templates.get_template(f"{key}.{mode}.mako")
862 except TopLevelLookupException:
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
871 return None
873 def get_notes(self, key):
874 """
875 Returns configured "notes" for the given :term:`email key`.
877 :param key: Key for the :term:`email type`.
879 :returns: Notes as string if found; otherwise ``None``.
880 """
881 return self.config.get(f"{self.config.appname}.email.{key}.notes")
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).
889 All email types are enabled by default, unless config says
890 otherwise; e.g. to disable ``foo`` emails:
892 .. code-block:: ini
894 [wutta.email]
896 # nb. this is fallback if specific type is not configured
897 default.enabled = true
899 # this disables 'foo' but e.g 'bar' is still enabled per default above
900 foo.enabled = false
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:
906 .. code-block:: ini
908 [wutta.email]
910 # do not send any emails unless explicitly enabled
911 default.enabled = false
913 # turn on 'bar' for testing
914 bar.enabled = true
916 See also :meth:`sending_is_enabled()` which is more of a
917 master shutoff switch.
919 :param key: Unique identifier for the email type.
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
929 def deliver_message(self, message, sender=None, recips=None):
930 """
931 Deliver a message via SMTP smarthost.
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.
937 :param sender: Optional sender address to use for delivery.
938 If not specified, will be read from ``message``.
940 :param recips: Optional recipient address(es) for delivery.
941 If not specified, will be read from ``message``.
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.
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.
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")
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]
971 recips = set(recips)
972 if not recips:
973 raise ValueError("no recipients identified for message delivery")
975 if not isinstance(message, str):
976 message = message.as_string()
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")
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
991 # smtp connect
992 session = smtplib.SMTP(server)
993 if username and password:
994 session.login(username, password)
996 # smtp send
997 session.sendmail(sender, recips, message)
998 session.quit()
999 log.debug("email was sent")
1001 def sending_is_enabled(self):
1002 """
1003 Returns boolean indicating if email sending is enabled.
1005 Set this flag in config like this:
1007 .. code-block:: ini
1009 [wutta.mail]
1010 send_emails = true
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 )
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.
1031 This method can send a message you provide, or it can
1032 construct one automatically from key / config / templates.
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.
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.
1045 :param context: Context dict for rendering automatic email
1046 template(s).
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.
1052 :param sender: Optional sender address for the
1053 message/delivery.
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).
1059 In any case if ``sender`` is provided, it will be used for
1060 the actual SMTP delivery.
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).
1066 .. note::
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.
1072 If you provide the ``message`` but not the ``recips``,
1073 the latter will be read from message headers: ``To:``,
1074 ``Cc:`` and ``Bcc:``
1076 If you want an auto-generated message but also want to
1077 override various recipient headers, then you must
1078 provide those explicitly::
1080 context = {'data': [1, 2, 3]}
1081 app.send_email('foo', context, to='me@example.com', cc='bobby@example.com')
1083 :param fallback_key: Optional fallback :term:`email key` to
1084 use for config/template lookup, if nothing is found for
1085 ``key``.
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
1095 if message is None:
1096 if not key:
1097 raise ValueError("must specify email key (and/or message object)")
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 )
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)
1118 self.deliver_message(message, recips=recips)