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
« 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"""
27# pylint: disable=too-many-lines
29import datetime
30import logging
31import re
32import smtplib
33from email.mime.multipart import MIMEMultipart
34from email.mime.text import MIMEText
36from mako.lookup import TemplateLookup
37from mako.template import Template
38from mako.exceptions import TopLevelLookupException
40from wuttjamaican.app import GenericHandler
41from wuttjamaican.util import resource_path
43log = logging.getLogger(__name__)
46class EmailSetting: # pylint: disable=too-few-public-methods
47 """
48 Base class for all :term:`email settings <email setting>`.
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`.
54 The name of each subclass should match the :term:`email key` which
55 it represents. For instance::
57 from wuttjamaican.email import EmailSetting
59 class poser_alert_foo(EmailSetting):
60 \"""
61 Sent when something happens that we think deserves an alert.
62 \"""
64 default_subject = "Something happened!"
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 }
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 })
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.
83 The purpose of defining a subclass for each email type is 2-fold,
84 but really the answer is "for maintenance sake" -
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
91 .. attribute:: key
93 Unique identifier for this :term:`email type`.
95 This is the :term:`email key` used for config/template lookup,
96 e.g. when sending an email.
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::
102 class poser_alert_foo(EmailSetting):
103 default_subject = "Something happened!"
105 handler = app.get_email_handler()
106 setting = handler.get_email_setting("poser_alert_foo")
107 assert setting.key == "poser_alert_foo"
109 See also :attr:`fallback_key`.
110 """
112 default_subject = None
113 """
114 Default subject for sending emails of this type.
116 Usually, if config does not override, this will become
117 :attr:`Message.subject`.
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.
123 Calling code should not access this directly, but instead use
124 :meth:`get_default_subject()` .
125 """
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 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 {}
195class RunnerEmailSetting(EmailSetting):
196 """
197 Base class for email settings for the output from arbitrary
198 command runs. This inherits from :class:`EmailSetting`.
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 """
205 fallback_key = "runner_result"
206 "" # suppress docs
208 default_subject = "Command result"
209 "" # suppress docs
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 }
222class runner_result(RunnerEmailSetting): # pylint: disable=invalid-name
223 """
224 Sends the result and output after running a command.
225 """
228class Message: # pylint: disable=too-many-instance-attributes
229 """
230 Represents an email message to be sent.
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`.
237 All attributes shown below may also be specified via constructor.
239 .. attribute:: key
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.
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.
253 .. attribute:: sender
255 Sender (``From:``) address for the message.
257 .. attribute:: subject
259 Subject text for the message.
261 .. attribute:: to
263 List of ``To:`` recipients for the message.
265 .. attribute:: cc
267 List of ``Cc:`` recipients for the message.
269 .. attribute:: bcc
271 List of ``Bcc:`` recipients for the message.
273 .. attribute:: replyto
275 Optional reply-to (``Reply-To:``) address for the message.
277 .. attribute:: txt_body
279 String with the ``text/plain`` body content.
281 .. attribute:: html_body
283 String with the ``text/html`` body content.
285 .. attribute:: attachments
287 List of file attachments for the message.
288 """
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 []
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)
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
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])
339 elif self.txt_body:
340 msg = MIMEText(self.txt_body, _charset="utf_8")
342 elif self.html_body:
343 msg = MIMEText(self.html_body, "html", _charset="utf_8")
345 if not msg:
346 raise ValueError("message has no body parts")
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)
357 msg["Subject"] = self.subject
358 msg["From"] = self.sender
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
367 if self.replyto:
368 msg.add_header("Reply-To", self.replyto)
370 return msg.as_string()
373class EmailHandler(GenericHandler): # pylint: disable=too-many-public-methods
374 """
375 Base class and default implementation for the :term:`email
376 handler`.
378 Responsible for sending email messages on behalf of the
379 :term:`app`.
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 """
386 # nb. this is fallback/default subject for auto-message
387 universal_subject = "Automated message"
389 def __init__(self, *args, **kwargs):
390 super().__init__(*args, **kwargs)
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
397 # TODO: should auto-discover built-in templates?
398 available = ["wuttjamaican:templates/email"]
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
409 # convert all to true file paths
410 if templates:
411 templates = [resource_path(p) for p in templates]
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 )
422 def get_email_modules(self):
423 """
424 Returns a list of all known :term:`email modules <email
425 module>`.
427 This will discover all email modules exposed by the
428 :term:`app`, and/or its :term:`providers <provider>`.
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")
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`.
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:
446 # TODO: should auto-discover built-in settings?
447 self.classes["email_settings"] = {"runner_result": runner_result}
449 # nb. we only want lower_case_names - all UpperCaseNames
450 # are assumed to be base classes
451 pattern = re.compile(r"^[a-z]")
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
463 return self.classes["email_settings"]
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).
470 :param key: Key for the :term:`email type`.
472 :param instance: Whether to return the class, or an instance.
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
485 def make_message(self, **kwargs):
486 """
487 Make and return a new email message.
489 This is the "raw" factory which is simply a wrapper around the
490 class constructor. See also :meth:`make_auto_message()`.
492 :returns: :class:`~wuttjamaican.email.Message` object.
493 """
494 return Message(**kwargs)
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.
510 Once everything has been collected/prepared,
511 :meth:`make_message()` is called to create the final message,
512 and that is returned.
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.
519 :param context: Context dict used to render template(s) for
520 the message.
522 :param default_subject: Optional :attr:`~Message.subject`
523 template/string to use, if config does not specify one.
525 :param prefix_subject: Boolean indicating the message subject
526 should be auto-prefixed.
528 :param default_prefix: Default subject prefix to use if none
529 is configured.
531 :param fallback_key: Optional fallback :term:`email key` to
532 use for config/template lookup, if nothing is found for
533 ``key``.
535 :param \\**kwargs: Any remaining kwargs are passed as-is to
536 :meth:`make_message()`. More on this below.
538 :returns: :class:`~wuttjamaican.email.Message` object.
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.
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
556 if "sender" not in kwargs:
557 kwargs["sender"] = self.get_auto_sender(key)
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']}"
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)
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 )
590 return self.make_message(**kwargs)
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.
597 Normally the input ``context`` is coming from the
598 :meth:`send_email()` param of the same name.
600 By default, this method modifies the input context to add the
601 following:
603 * ``config`` - reference to the :term:`config object`
604 * ``app`` - reference to the :term:`app handler`
606 Subclass may further modify as needed.
608 :param key: The :term:`email key` for which to get context.
610 :param context: Input context dict.
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
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
635 # fall back to global default
636 return self.config.get(
637 f"{self.config.appname}.email.default.sender", default="root@localhost"
638 )
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
650 # fall back to global default, if present
651 return self.config.get(f"{self.config.appname}.email.default.replyto")
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.
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()`.
672 :param key: Key for the :term:`email type`. See also the
673 ``fallback_key`` param, below.
675 :param context: Dict of context for rendering the subject
676 template, if applicable.
678 :param rendered: If this is ``False``, the "raw" subject
679 template will be returned, instead of the final/rendered
680 subject text.
682 :param default: Default subject to use if none is configured.
684 :param fallback_key: Optional fallback :term:`email key` to
685 use for config lookup, if nothing is found for ``key``.
687 :param setting: Optional :class:`EmailSetting` class or
688 instance. This is passed along to
689 :meth:`get_auto_subject_template()`.
691 :param prefix: Boolean indicating the message subject should
692 be auto-prefixed. This is ignored when ``rendered`` param
693 is false.
695 :param default_prefix: Default subject prefix to use if none
696 is configured.
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
706 context = self.get_email_context(key, context)
707 subject = Template(template).render(**context)
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}"
717 return subject
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.
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.
729 The template returned from this method is used to render the
730 final subject line in :meth:`get_auto_subject()`.
732 :param key: Key for the :term:`email type`.
734 :param default: Default subject to use if none is configured.
736 :param fallback_key: Optional fallback :term:`email key` to
737 use for config lookup, if nothing is found for ``key``.
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()`.
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
751 # or use caller-specified default, if applicable
752 if default:
753 return default
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
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
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 )
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()`.
783 Note that unlike the subject proper, the prefix is just a
784 normal string, not a template.
786 Example prefix is ``"[Wutta]"`` - trailing space will be added
787 automatically when applying the prefix to a message subject.
789 :param key: The :term:`email key` requested.
791 :param default: Default prefix to use if none is configured.
793 :param fallback_key: Optional fallback :term:`email key` to
794 use for config lookup, if nothing is found for ``key``.
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()`.
802 :returns: Final subject prefix string
803 """
805 # prefer configured prefix specific to key
806 if prefix := self.config.get(f"{self.config.appname}.email.{key}.prefix"):
807 return prefix
809 # or use caller-specified default, if applicable
810 if default:
811 return default
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
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
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 )
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")
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")
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")
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")
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
865 # fall back to global default
866 return self.config.get_list(
867 f"{self.config.appname}.email.default.{typ}", default=[]
868 )
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
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
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")
907 try:
909 # prefer specific template for key
910 return templates.get_template(f"{key}.{mode}.mako")
912 except TopLevelLookupException:
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
921 return None
923 def get_notes(self, key):
924 """
925 Returns configured "notes" for the given :term:`email key`.
927 :param key: Key for the :term:`email type`.
929 :returns: Notes as string if found; otherwise ``None``.
930 """
931 return self.config.get(f"{self.config.appname}.email.{key}.notes")
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).
939 All email types are enabled by default, unless config says
940 otherwise; e.g. to disable ``foo`` emails:
942 .. code-block:: ini
944 [wutta.email]
946 # nb. this is fallback if specific type is not configured
947 default.enabled = true
949 # this disables 'foo' but e.g 'bar' is still enabled per default above
950 foo.enabled = false
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:
956 .. code-block:: ini
958 [wutta.email]
960 # do not send any emails unless explicitly enabled
961 default.enabled = false
963 # turn on 'bar' for testing
964 bar.enabled = true
966 See also :meth:`sending_is_enabled()` which is more of a
967 master shutoff switch.
969 :param key: Unique identifier for the email type.
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
979 def deliver_message(self, message, sender=None, recips=None):
980 """
981 Deliver a message via SMTP smarthost.
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.
987 :param sender: Optional sender address to use for delivery.
988 If not specified, will be read from ``message``.
990 :param recips: Optional recipient address(es) for delivery.
991 If not specified, will be read from ``message``.
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.
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.
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")
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]
1021 recips = set(recips)
1022 if not recips:
1023 raise ValueError("no recipients identified for message delivery")
1025 if not isinstance(message, str):
1026 message = message.as_string()
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")
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
1041 # smtp connect
1042 session = smtplib.SMTP(server)
1043 if username and password:
1044 session.login(username, password)
1046 # smtp send
1047 session.sendmail(sender, recips, message)
1048 session.quit()
1049 log.debug("email was sent")
1051 def sending_is_enabled(self):
1052 """
1053 Returns boolean indicating if email sending is enabled.
1055 Set this flag in config like this:
1057 .. code-block:: ini
1059 [wutta.mail]
1060 send_emails = true
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 )
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.
1081 This method can send a message you provide, or it can
1082 construct one automatically from key / config / templates.
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.
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.
1095 :param context: Context dict for rendering automatic email
1096 template(s).
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.
1102 :param sender: Optional sender address for the
1103 message/delivery.
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).
1109 In any case if ``sender`` is provided, it will be used for
1110 the actual SMTP delivery.
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).
1116 .. note::
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.
1122 If you provide the ``message`` but not the ``recips``,
1123 the latter will be read from message headers: ``To:``,
1124 ``Cc:`` and ``Bcc:``
1126 If you want an auto-generated message but also want to
1127 override various recipient headers, then you must
1128 provide those explicitly::
1130 context = {'data': [1, 2, 3]}
1131 app.send_email('foo', context, to='me@example.com', cc='bobby@example.com')
1133 :param fallback_key: Optional fallback :term:`email key` to
1134 use for config/template lookup, if nothing is found for
1135 ``key``.
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
1145 if message is None:
1146 if not key:
1147 raise ValueError("must specify email key (and/or message object)")
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 )
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)
1168 self.deliver_message(message, recips=recips)