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
« 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"""
27import importlib
28import logging
29import smtplib
30from email.mime.multipart import MIMEMultipart
31from email.mime.text import MIMEText
33from mako.lookup import TemplateLookup
34from mako.template import Template
35from mako.exceptions import TopLevelLookupException
37from wuttjamaican.app import GenericHandler
38from wuttjamaican.util import resource_path
41log = logging.getLogger(__name__)
44class EmailSetting:
45 """
46 Base class for all :term:`email settings <email setting>`.
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`.
52 The name of each subclass should match the :term:`email key` which
53 it represents. For instance::
55 from wuttjamaican.email import EmailSetting
57 class poser_alert_foo(EmailSetting):
58 \"""
59 Sent when something happens that we think deserves an alert.
60 \"""
62 default_subject = "Something happened!"
64 def sample_data(self):
65 return {
66 'foo': 1234,
67 'msg': "Something happened, thought you should know.",
68 }
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 })
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.
80 The purpose of defining a subclass for each email type is 2-fold,
81 but really the answer is "for maintenance sake" -
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
88 .. attribute:: default_subject
90 Default :attr:`Message.subject` for the email, if none is
91 configured.
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
99 def __init__(self, config):
100 self.config = config
101 self.app = config.get_app()
102 self.key = self.__class__.__name__
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 {}
113class Message:
114 """
115 Represents an email message to be sent.
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`.
122 All attributes shown below may also be specified via constructor.
124 .. attribute:: key
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.
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.
138 .. attribute:: sender
140 Sender (``From:``) address for the message.
142 .. attribute:: subject
144 Subject text for the message.
146 .. attribute:: to
148 List of ``To:`` recipients for the message.
150 .. attribute:: cc
152 List of ``Cc:`` recipients for the message.
154 .. attribute:: bcc
156 List of ``Bcc:`` recipients for the message.
158 .. attribute:: replyto
160 Optional reply-to (``Reply-To:``) address for the message.
162 .. attribute:: txt_body
164 String with the ``text/plain`` body content.
166 .. attribute:: html_body
168 String with the ``text/html`` body content.
169 """
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
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))
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
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])
218 elif self.txt_body:
219 msg = MIMEText(self.txt_body, _charset='utf_8')
221 elif self.html_body:
222 msg = MIMEText(self.html_body, 'html', _charset='utf_8')
224 if not msg:
225 raise ValueError("message has no body parts")
227 msg['Subject'] = self.subject
228 msg['From'] = self.sender
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
237 if self.replyto:
238 msg.add_header('Reply-To', self.replyto)
240 return msg.as_string()
243class EmailHandler(GenericHandler):
244 """
245 Base class and default implementation for the :term:`email
246 handler`.
248 Responsible for sending email messages on behalf of the
249 :term:`app`.
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 """
256 # nb. this is fallback/default subject for auto-message
257 universal_subject = "Automated message"
259 def __init__(self, *args, **kwargs):
260 super().__init__(*args, **kwargs)
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:
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
277 # convert all to true file paths
278 if templates:
279 templates = [resource_path(p) for p in templates]
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'])
288 def get_email_modules(self):
289 """
290 Returns a list of all known :term:`email modules <email
291 module>`.
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)
308 return self._email_modules
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`.
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
328 return self._email_settings
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).
335 :param key: Key for the :term:`email type`.
337 :param instance: Whether to return the class, or an instance.
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
349 def make_message(self, **kwargs):
350 """
351 Make and return a new email message.
353 This is the "raw" factory which is simply a wrapper around the
354 class constructor. See also :meth:`make_auto_message()`.
356 :returns: :class:`~wuttjamaican.email.Message` object.
357 """
358 return Message(**kwargs)
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.
365 Once everything has been collected/prepared,
366 :meth:`make_message()` is called to create the final message,
367 and that is returned.
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.
373 :param context: Context dict used to render template(s) for
374 the message.
376 :param \**kwargs: Any remaining kwargs are passed as-is to
377 :meth:`make_message()`. More on this below.
379 :returns: :class:`~wuttjamaican.email.Message` object.
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.
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)
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
422 # fall back to global default
423 return self.config.get(f'{self.config.appname}.email.default.sender',
424 default='root@localhost')
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
436 # fall back to global default, if present
437 return self.config.get(f'{self.config.appname}.email.default.replyto')
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.
444 This calls :meth:`get_auto_subject_template()` and then
445 (usually) renders the result using the given context.
447 :param key: Key for the :term:`email type`.
449 :param context: Dict of context for rendering the subject
450 template, if applicable.
452 :param rendered: If this is ``False``, the "raw" subject
453 template will be returned, instead of the final/rendered
454 subject text.
456 :param setting: Optional :class:`EmailSetting` class or
457 instance. This is passed along to
458 :meth:`get_auto_subject_template()`.
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
466 return Template(template).render(**context)
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.
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.
476 The template returned from this method is used to render the
477 final subject line in :meth:`get_auto_subject()`.
479 :param key: Key for the :term:`email type`.
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()`.
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
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
500 # fall back to global default
501 return self.config.get(f'{self.config.appname}.email.default.subject',
502 default=self.universal_subject)
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')
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')
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')
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")
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
536 # fall back to global default
537 return self.config.get_list(f'{self.config.appname}.email.default.{typ}',
538 default=[])
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)
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)
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")
567 if mode == 'txt':
568 templates = self.txt_templates
569 elif mode == 'html':
570 templates = self.html_templates
572 try:
573 return templates.get_template(f'{key}.{mode}.mako')
574 except TopLevelLookupException:
575 pass
577 def get_notes(self, key):
578 """
579 Returns configured "notes" for the given :term:`email key`.
581 :param key: Key for the :term:`email type`.
583 :returns: Notes as string if found; otherwise ``None``.
584 """
585 return self.config.get(f'{self.config.appname}.email.{key}.notes')
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).
593 All email types are enabled by default, unless config says
594 otherwise; e.g. to disable ``foo`` emails:
596 .. code-block:: ini
598 [wutta.email]
600 # nb. this is fallback if specific type is not configured
601 default.enabled = true
603 # this disables 'foo' but e.g 'bar' is still enabled per default above
604 foo.enabled = false
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:
610 .. code-block:: ini
612 [wutta.email]
614 # do not send any emails unless explicitly enabled
615 default.enabled = false
617 # turn on 'bar' for testing
618 bar.enabled = true
620 See also :meth:`sending_is_enabled()` which is more of a
621 master shutoff switch.
623 :param key: Unique identifier for the email type.
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
633 def deliver_message(self, message, sender=None, recips=None):
634 """
635 Deliver a message via SMTP smarthost.
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.
641 :param sender: Optional sender address to use for delivery.
642 If not specified, will be read from ``message``.
644 :param recips: Optional recipient address(es) for delivery.
645 If not specified, will be read from ``message``.
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.
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.
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")
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]
675 recips = set(recips)
676 if not recips:
677 raise ValueError("no recipients identified for message delivery")
679 if not isinstance(message, str):
680 message = message.as_string()
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')
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
693 # smtp connect
694 session = smtplib.SMTP(server)
695 if username and password:
696 session.login(username, password)
698 # smtp send
699 session.sendmail(sender, recips, message)
700 session.quit()
701 log.debug("email was sent")
703 def sending_is_enabled(self):
704 """
705 Returns boolean indicating if email sending is enabled.
707 Set this flag in config like this:
709 .. code-block:: ini
711 [wutta.mail]
712 send_emails = true
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)
719 def send_email(self, key=None, context={}, message=None, sender=None, recips=None, **kwargs):
720 """
721 Send an email message.
723 This method can send a message you provide, or it can
724 construct one automatically from key / config / templates.
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.
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.
736 :param context: Context dict for rendering automatic email
737 template(s).
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.
743 :param sender: Optional sender address for the
744 message/delivery.
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).
750 In any case if ``sender`` is provided, it will be used for
751 the actual SMTP delivery.
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).
757 .. note::
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.
763 If you provide the ``message`` but not the ``recips``,
764 the latter will be read from message headers: ``To:``,
765 ``Cc:`` and ``Bcc:``
767 If you want an auto-generated message but also want to
768 override various recipient headers, then you must
769 provide those explicitly::
771 context = {'data': [1, 2, 3]}
772 app.send_email('foo', context, to='me@example.com', cc='bobby@example.com')
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
782 if message is None:
783 if not key:
784 raise ValueError("must specify email key (and/or message object)")
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?")
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)
801 self.deliver_message(message, recips=recips)