Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/email/handler.py: 100%
138 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-08-26 14:27 -0500
« prev ^ index » next coverage.py v7.3.2, created at 2024-08-26 14:27 -0500
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 logging
28import smtplib
30from wuttjamaican.app import GenericHandler
31from wuttjamaican.util import resource_path
32from wuttjamaican.email.message import Message
35log = logging.getLogger(__name__)
38class EmailHandler(GenericHandler):
39 """
40 Base class and default implementation for the :term:`email
41 handler`.
43 Responsible for sending email messages on behalf of the
44 :term:`app`.
46 You normally would not create this directly, but instead call
47 :meth:`~wuttjamaican.app.AppHandler.get_email_handler()` on your
48 :term:`app handler`.
49 """
51 # nb. this is fallback/default subject for auto-message
52 universal_subject = "Automated message"
54 def __init__(self, *args, **kwargs):
55 super().__init__(*args, **kwargs)
57 from mako.lookup import TemplateLookup
59 # prefer configured list of template lookup paths, if set
60 templates = self.config.get_list(f'{self.config.appname}.email.templates')
61 if not templates:
63 # otherwise use all available paths, from app providers
64 available = []
65 for provider in self.app.providers.values():
66 if hasattr(provider, 'email_templates'):
67 templates = provider.email_templates
68 if isinstance(templates, str):
69 templates = [templates]
70 if templates:
71 available.extend(templates)
72 templates = available
74 # convert all to true file paths
75 if templates:
76 templates = [resource_path(p) for p in templates]
78 # will use these lookups from now on
79 self.txt_templates = TemplateLookup(directories=templates)
80 self.html_templates = TemplateLookup(directories=templates,
81 # nb. escape HTML special chars
82 # TODO: sounds great but i forget why?
83 default_filters=['h'])
85 def make_message(self, **kwargs):
86 """
87 Make and return a new email message.
89 This is the "raw" factory which is simply a wrapper around the
90 class constructor. See also :meth:`make_auto_message()`.
92 :returns: :class:`~wuttjamaican.email.message.Message` object.
93 """
94 return Message(**kwargs)
96 def make_auto_message(self, key, context={}, **kwargs):
97 """
98 Make a new email message using config to determine its
99 properties, and auto-generating body from a template.
101 Once everything has been collected/prepared,
102 :meth:`make_message()` is called to create the final message,
103 and that is returned.
105 :param key: Unique key for this particular "type" of message.
106 This key is used as a prefix for all config settings and
107 template names pertinent to the message.
109 :param context: Context dict used to render template(s) for
110 the message.
112 :param \**kwargs: Any remaining kwargs are passed as-is to
113 :meth:`make_message()`. More on this below.
115 :returns: :class:`~wuttjamaican.email.message.Message` object.
117 This method may invoke some others, to gather the message
118 attributes. Each will check config, or render a template, or
119 both. However if a particular attribute is provided by the
120 caller, the corresponding "auto" method is skipped.
122 * :meth:`get_auto_sender()`
123 * :meth:`get_auto_subject()`
124 * :meth:`get_auto_to()`
125 * :meth:`get_auto_cc()`
126 * :meth:`get_auto_bcc()`
127 * :meth:`get_auto_txt_body()`
128 * :meth:`get_auto_html_body()`
129 """
130 kwargs['key'] = key
131 if 'sender' not in kwargs:
132 kwargs['sender'] = self.get_auto_sender(key)
133 if 'subject' not in kwargs:
134 kwargs['subject'] = self.get_auto_subject(key, context)
135 if 'to' not in kwargs:
136 kwargs['to'] = self.get_auto_to(key)
137 if 'cc' not in kwargs:
138 kwargs['cc'] = self.get_auto_cc(key)
139 if 'bcc' not in kwargs:
140 kwargs['bcc'] = self.get_auto_bcc(key)
141 if 'txt_body' not in kwargs:
142 kwargs['txt_body'] = self.get_auto_txt_body(key, context)
143 if 'html_body' not in kwargs:
144 kwargs['html_body'] = self.get_auto_html_body(key, context)
145 return self.make_message(**kwargs)
147 def get_auto_sender(self, key):
148 """
149 Returns automatic
150 :attr:`~wuttjamaican.email.message.Message.sender` address for
151 a message, as determined by config.
152 """
153 # prefer configured sender specific to key
154 sender = self.config.get(f'{self.config.appname}.email.{key}.sender')
155 if sender:
156 return sender
158 # fall back to global default (required!)
159 return self.config.require(f'{self.config.appname}.email.default.sender')
161 def get_auto_subject(self, key, context={}, rendered=True):
162 """
163 Returns automatic
164 :attr:`~wuttjamaican.email.message.Message.subject` line for a
165 message, as determined by config.
167 This calls :meth:`get_auto_subject_template()` and then
168 renders the result using the given context.
170 :param rendered: If this is ``False``, the "raw" subject
171 template will be returned, instead of the final/rendered
172 subject text.
173 """
174 from mako.template import Template
176 template = self.get_auto_subject_template(key)
177 if not rendered:
178 return template
179 return Template(template).render(**context)
181 def get_auto_subject_template(self, key):
182 """
183 Returns the template string to use for automatic subject line
184 of a message, as determined by config.
186 In many cases this will be a simple string and not a
187 "template" per se; however it is still treated as a template.
189 The template returned from this method is used to render the
190 final subject line in :meth:`get_auto_subject()`.
191 """
192 # prefer configured subject specific to key
193 template = self.config.get(f'{self.config.appname}.email.{key}.subject')
194 if template:
195 return template
197 # fall back to global default
198 return self.config.get(f'{self.config.appname}.email.default.subject',
199 default=self.universal_subject)
201 def get_auto_to(self, key):
202 """
203 Returns automatic
204 :attr:`~wuttjamaican.email.message.Message.to` recipient
205 address(es) for a message, as determined by config.
206 """
207 return self.get_auto_recips(key, 'to')
209 def get_auto_cc(self, key):
210 """
211 Returns automatic
212 :attr:`~wuttjamaican.email.message.Message.cc` recipient
213 address(es) for a message, as determined by config.
214 """
215 return self.get_auto_recips(key, 'cc')
217 def get_auto_bcc(self, key):
218 """
219 Returns automatic
220 :attr:`~wuttjamaican.email.message.Message.bcc` recipient
221 address(es) for a message, as determined by config.
222 """
223 return self.get_auto_recips(key, 'bcc')
225 def get_auto_recips(self, key, typ):
226 """ """
227 typ = typ.lower()
228 if typ not in ('to', 'cc', 'bcc'):
229 raise ValueError("requested type not supported")
231 # prefer configured recips specific to key
232 recips = self.config.get_list(f'{self.config.appname}.email.{key}.{typ}')
233 if recips:
234 return recips
236 # fall back to global default
237 return self.config.get_list(f'{self.config.appname}.email.default.{typ}',
238 default=[])
240 def get_auto_txt_body(self, key, context={}):
241 """
242 Returns automatic
243 :attr:`~wuttjamaican.email.message.Message.txt_body` content
244 for a message, as determined by config. This renders a
245 template with the given context.
246 """
247 template = self.get_auto_body_template(key, 'txt')
248 if template:
249 return template.render(**context)
251 def get_auto_html_body(self, key, context={}):
252 """
253 Returns automatic
254 :attr:`~wuttjamaican.email.message.Message.html_body` content
255 for a message, as determined by config. This renders a
256 template with the given context.
257 """
258 template = self.get_auto_body_template(key, 'html')
259 if template:
260 return template.render(**context)
262 def get_auto_body_template(self, key, typ):
263 """ """
264 from mako.exceptions import TopLevelLookupException
266 typ = typ.lower()
267 if typ not in ('txt', 'html'):
268 raise ValueError("requested type not supported")
270 if typ == 'txt':
271 templates = self.txt_templates
272 elif typ == 'html':
273 templates = self.html_templates
275 try:
276 return templates.get_template(f'{key}.{typ}.mako')
277 except TopLevelLookupException:
278 pass
280 def deliver_message(self, message, sender=None, recips=None):
281 """
282 Deliver a message via SMTP smarthost.
284 :param message: Either a
285 :class:`~wuttjamaican.email.message.Message` object or
286 similar, or a string representing the complete message to
287 be sent as-is.
289 :param sender: Optional sender address to use for delivery.
290 If not specified, will be read from ``message``.
292 :param recips: Optional recipient address(es) for delivery.
293 If not specified, will be read from ``message``.
295 A general rule here is that you can either provide a proper
296 :class:`~wuttjamaican.email.message.Message` object, **or**
297 you *must* provide ``sender`` and ``recips``. The logic is
298 not smart enough (yet?) to parse sender/recips from a simple
299 string message.
301 Note also, this method does not (yet?) have robust error
302 handling, so if an error occurs with the SMTP session, it will
303 simply raise to caller.
305 :returns: ``None``
306 """
307 if not sender:
308 sender = message.sender
309 if not sender:
310 raise ValueError("no sender identified for message delivery")
312 if not recips:
313 recips = set()
314 if message.to:
315 recips.update(message.to)
316 if message.cc:
317 recips.update(message.cc)
318 if message.bcc:
319 recips.update(message.bcc)
320 elif isinstance(recips, str):
321 recips = [recips]
323 recips = set(recips)
324 if not recips:
325 raise ValueError("no recipients identified for message delivery")
327 if not isinstance(message, str):
328 message = message.as_string()
330 # get smtp info
331 server = self.config.get(f'{self.config.appname}.mail.smtp.server', default='localhost')
332 username = self.config.get(f'{self.config.appname}.mail.smtp.username')
333 password = self.config.get(f'{self.config.appname}.mail.smtp.password')
335 # make sure sending is enabled
336 log.debug("sending email from %s; to %s", sender, recips)
337 if not self.sending_is_enabled():
338 log.debug("nevermind, config says no emails")
339 return
341 # smtp connect
342 session = smtplib.SMTP(server)
343 if username and password:
344 session.login(username, password)
346 # smtp send
347 session.sendmail(sender, recips, message)
348 session.quit()
349 log.debug("email was sent")
351 def sending_is_enabled(self):
352 """
353 Returns boolean indicating if email sending is enabled.
355 Set this flag in config like this:
357 .. code-block:: ini
359 [wutta.mail]
360 send_emails = true
362 Note that it is OFF by default.
363 """
364 return self.config.get_bool(f'{self.config.appname}.mail.send_emails',
365 default=False)
367 def send_email(self, key=None, context={}, message=None, sender=None, recips=None, **kwargs):
368 """
369 Send an email message.
371 This method can send a ``message`` you provide, or it can
372 construct one automatically from key/config/templates.
374 :param key: Indicates which "type" of automatic email to send.
375 Used to lookup config settings and template files.
377 :param context: Context dict for rendering automatic email
378 template(s).
380 :param message: Optional pre-built message instance, to send
381 as-is.
383 :param sender: Optional sender address for the
384 message/delivery.
386 If ``message`` is not provided, then the ``sender`` (if
387 provided) will also be used when constructing the
388 auto-message (i.e. to set the ``From:`` header).
390 In any case if ``sender`` is provided, it will be used for
391 the actual SMTP delivery.
393 :param recips: Optional list of recipient addresses for
394 delivery. If not specified, will be read from the message
395 itself (after auto-generating it, if applicable).
397 .. note::
399 This param does not affect an auto-generated message; it
400 is used for delivery only. As such it must contain
401 *all* true recipients.
403 If you provide the ``message`` but not the ``recips``,
404 the latter will be read from message headers: ``To:``,
405 ``Cc:`` and ``Bcc:``
407 If you want an auto-generated message but also want to
408 override various recipient headers, then you must
409 provide those explicitly::
411 context = {'data': [1, 2, 3]}
412 app.send_email('foo', context, to='me@example.com', cc='bobby@example.com')
414 :param \**kwargs: Any remaining kwargs are passed along to
415 :meth:`make_auto_message()`. So, not used if you provide
416 the ``message``.
417 """
418 if message is None:
419 if sender:
420 kwargs['sender'] = sender
421 message = self.make_auto_message(key, context, **kwargs)
423 self.deliver_message(message, recips=recips)