Add new configuration options for task emails

This change extends the functionality for sending emails
in task stages.

Add the following configuration options for configuring
what email address to send task emails to:

* `to` (`str`) - Send the email to the given arbitrary email address
* `email_current_user` (`bool`) - Email the user who initiated the task

New variables have also been added to the task email template context,
to allow referencing other candidate email addresses in the template
(e.g. "A confirmation email has been sent to `<new email address>`
to confirm your email change"):

* `from_address` (`str`) - The email address the email is being sent
  from internally. Not always the same as the `From` header,
  which is set to `reply_address`.
* `reply_address` (`str`) - The reply-to email address added to the
  email header.
* `email_address` (`str`) - The target email address for this
  email template.
* `email_current_user_address` (`str | None`) - The email address of
  the user that created the task, if available.
* `emails_action_addresses` (`dict[str, str]`) - The recipient
  email addresses configured on the task actions, if a recipient
  email address is set. The key is the action name, the value is
  the target email address.

Multiple emails can now be sent per stage in a task by defining
the `emails` (`list[dict[str, Any]]`) option, and setting
configuration overrides for each individual email.

Change-Id: Ifc83c42bdefcc24e343b8489a917080e6c9785dc
This commit is contained in:
Callum Dickinson 2024-07-16 15:31:10 +12:00
parent c2cb1dafda
commit c99499d9df
3 changed files with 221 additions and 39 deletions

View File

@ -14,6 +14,9 @@
from confspirator import groups
from confspirator import fields
from confspirator import types
from adjutant.common import constants
config_group = groups.ConfigGroup("workflow")
@ -39,23 +42,42 @@ config_group.register_child_config(
def _build_default_email_group(
group_name,
email_subject,
subject,
email_from,
email_to,
email_reply,
email_template,
email_html_template,
template,
html_template,
email_current_user,
emails,
):
email_group = groups.ConfigGroup(group_name)
email_group.register_child_config(
fields.StrConfig(
"subject",
help_text="Default email subject for this stage",
default=email_subject,
default=subject,
)
)
email_group.register_child_config(
fields.StrConfig(
"from", help_text="Default from email for this stage", default=email_from
"from",
help_text="Default from email for this stage",
regex=constants.EMAIL_WITH_TEMPLATE_REGEX,
default=email_from,
)
)
email_group.register_child_config(
fields.StrConfig(
"to",
help_text=(
"Send the email to the given email address. "
"If not set, the email will be sent to the "
"recipient email address determined by the action "
"being run."
),
regex=constants.EMAIL_WITH_TEMPLATE_REGEX,
default=email_to,
)
)
email_group.register_child_config(
@ -69,14 +91,32 @@ def _build_default_email_group(
fields.StrConfig(
"template",
help_text="Default email template for this stage",
default=email_template,
default=template,
)
)
email_group.register_child_config(
fields.StrConfig(
"html_template",
help_text="Default email html template for this stage",
default=email_html_template,
default=html_template,
)
)
email_group.register_child_config(
fields.BoolConfig(
"email_current_user",
help_text="Email the user who initiated the task",
default=email_current_user,
)
)
email_group.register_child_config(
fields.ListConfig(
"emails",
item_type=types.List(item_type=types.Dict()),
help_text=(
"Send more than one email, setting parameter overrides "
"for each specific email as required"
),
default=emails,
)
)
return email_group
@ -90,31 +130,40 @@ _task_defaults_group.register_child_config(_email_defaults_group)
_email_defaults_group.register_child_config(
_build_default_email_group(
group_name="initial",
email_subject="Task Confirmation",
email_reply="no-reply@example.com",
subject="Task Confirmation",
email_from="bounce+%(task_uuid)s@example.com",
email_template="initial.txt",
email_html_template=None,
email_to=None,
email_reply="no-reply@example.com",
template="initial.txt",
html_template=None,
email_current_user=False,
emails=[],
)
)
_email_defaults_group.register_child_config(
_build_default_email_group(
group_name="token",
email_subject="Task Token",
email_reply="no-reply@example.com",
subject="Task Token",
email_from="bounce+%(task_uuid)s@example.com",
email_template="token.txt",
email_html_template=None,
email_to=None,
email_reply="no-reply@example.com",
template="token.txt",
html_template=None,
email_current_user=False,
emails=[],
)
)
_email_defaults_group.register_child_config(
_build_default_email_group(
group_name="completed",
email_subject="Task Completed",
email_reply="no-reply@example.com",
subject="Task Completed",
email_from="bounce+%(task_uuid)s@example.com",
email_template="completed.txt",
email_html_template=None,
email_to=None,
email_reply="no-reply@example.com",
template="completed.txt",
html_template=None,
email_current_user=False,
emails=[],
)
)

View File

@ -22,6 +22,7 @@ from django.template import loader
from django.utils import timezone
from adjutant.api.models import Token
from adjutant.common import user_store
from adjutant.notifications.utils import create_notification
from adjutant.config import CONF
from adjutant import exceptions
@ -58,27 +59,109 @@ def create_token(task, expiry_time=None):
def send_stage_email(task, email_conf, token=None):
"""Send one or more stage emails for a task using the given configuration.
This also accepts ``None`` for ``email_conf``, in which case
no emails are sent.
:param task: Task to send the stage email for
:type task: Task
:param email_conf: Stage email configuration (if configured)
:type email_conf: confspirator.groups.GroupNamespace | None
:param token: Token to add to the email template, defaults to None
:type token: str | None, optional
"""
if not email_conf:
return
text_template = loader.get_template(
email_conf["template"], using="include_etc_templates"
# Send one or more emails according to per-email configurations
# if provided. If not, send a single email using the stage-global
# email configuration values.
emails = email_conf["emails"] or [{}]
# For each per-email configuration, send a stage email using
# that configuration.
# We want to use the per-email configuration values if provided,
# but fall back to the stage-global email configuration value
# for any that are not.
for conf in emails:
_send_stage_email(
task=task,
token=token,
subject=conf.get("subject", email_conf["subject"]),
template=conf.get("template", email_conf["template"]),
html_template=conf.get(
"html_template",
email_conf["html_template"],
),
email_from=conf.get("from", email_conf["from"]),
email_to=conf.get("to", email_conf["to"]),
email_reply=conf.get("reply", email_conf["reply"]),
email_current_user=conf.get(
"email_current_user",
email_conf["email_current_user"],
),
)
html_template = email_conf["html_template"]
def _send_stage_email(
task,
token,
subject,
template,
html_template,
email_from,
email_to,
email_reply,
email_current_user,
):
text_template = loader.get_template(template, using="include_etc_templates")
if html_template:
html_template = loader.get_template(
html_template, using="include_etc_templates"
)
# find our set of emails and actions that require email
emails = set()
actions = {}
# find our set of emails and actions that require email
# Fetch all possible email addresses that can be configured.
# Even if these are not actually used as the target email,
# they are made available in the email templates to be referenced.
if CONF.identity.username_is_email and "username" in task.keystone_user:
email_current_user_address = task.keystone_user["username"]
elif "user_id" in task.keystone_user:
id_manager = user_store.IdentityManager()
user = id_manager.get_user(task.keystone_user["user_id"])
email_current_user_address = user.email if user else None
else:
email_current_user_address = None
email_action_addresses = {}
for action in task.actions:
act = action.get_action()
email = act.get_email()
if email:
emails.add(email)
actions[str(act)] = act
action_name = str(act)
email_action_addresses[action_name] = email
actions[action_name] = act
if email_to:
emails.add(email_to)
elif email_current_user:
if not email_current_user_address:
notes = {
"errors": (
"Error: Unable to send update, "
"task email is configured to send to current user "
f"but no username or user ID found in task: {task.uuid}"
),
}
create_notification(task, notes, error=True)
return
emails.add(email_current_user_address)
else:
emails |= set(email_action_addresses.values())
if not emails:
return
@ -93,7 +176,20 @@ def send_stage_email(task, email_conf, token=None):
create_notification(task, notes, error=True)
return
context = {"task": task, "actions": actions}
# from_email is the return-path and is distinct from the
# message headers
from_email = email_from % {"task_uuid": task.uuid} if email_from else email_reply
email_address = emails.pop()
context = {
"task": task,
"actions": actions,
"from_address": from_email,
"reply_address": email_reply,
"email_address": email_address,
"email_current_user_address": email_current_user_address,
"email_action_addresses": email_action_addresses,
}
if token:
tokenurl = CONF.workflow.horizon_url
if not tokenurl.endswith("/"):
@ -104,28 +200,20 @@ def send_stage_email(task, email_conf, token=None):
try:
message = text_template.render(context)
# from_email is the return-path and is distinct from the
# message headers
from_email = email_conf["from"]
if not from_email:
from_email = email_conf["reply"]
elif "%(task_uuid)s" in from_email:
from_email = from_email % {"task_uuid": task.uuid}
# these are the message headers which will be visible to
# the email client.
headers = {
"X-Adjutant-Task-UUID": task.uuid,
# From needs to be set to be disctinct from return-path
"From": email_conf["reply"],
"Reply-To": email_conf["reply"],
"From": email_reply,
"Reply-To": email_reply,
}
email = EmailMultiAlternatives(
email_conf["subject"],
subject,
message,
from_email,
[emails.pop()],
[email_address],
headers=headers,
)

View File

@ -0,0 +1,45 @@
---
features:
- |
Added the ``to`` field to task stage email configurations, for setting
an arbitrary address to send task stage emails to.
- |
Added the ``email_current_user`` field to task stage email configurations,
for sending task stage emails to the user who initiated the task.
Set ``email_current_user`` to ``true`` to enable this behaviour.
- |
Added the ``from_address`` variable to task stage email template
contexts, allowing the address the email is being sent from internally
to be templated in task stage email bodies.
Note that this is not necessarily the same address that is set in the
``From`` header of the email. For that address, use
``reply_address`` instead.
- |
Added the ``reply_address`` variable to task stage email template
contexts, allowing the reply-to address sent to the recipient to be
templated in task stage email bodies.
- |
Added the ``email_address`` variable to task stage email template contexts,
allowing the recipient email address to be templated in task stage email
bodies.
- |
Added the ``email_current_user_address`` variable to task stage email
template contexts, which exposes the email address of the user that
initiated the task for use in task stage email templates.
Note that depending on the task being run this value may not be
available for use, in which case it will be set to ``None``.
- |
Added the ``email_action_addresses`` variable to task stage email
template contexts, which exposes a dictionary mapping task actions
to their recipient email addresses for use in task stage email templates.
Note that depending on the task being run there may not be an email
address available for certain actions, in which case the dictionary will
not store a value for those tasks. If no tasks have any recipient email
addresses, the dictionary will be empty.
- |
Multiple emails can now be sent per task stage using the new ``emails``
configuration field. To send multiple emails per task stage, define a list
of emails to be sent as ``emails``, with per-email configuration set in
the list elements. If a value is not set per-email, the value set in the
stage configuration will be used, and if that is unset, the default value
will be used.