diff --git a/adjutant/config/workflow.py b/adjutant/config/workflow.py index 438a6f8..6452cf3 100644 --- a/adjutant/config/workflow.py +++ b/adjutant/config/workflow.py @@ -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=[], ) ) diff --git a/adjutant/tasks/v1/utils.py b/adjutant/tasks/v1/utils.py index f418ea4..542268c 100644 --- a/adjutant/tasks/v1/utils.py +++ b/adjutant/tasks/v1/utils.py @@ -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" - ) - html_template = email_conf["html_template"] + # 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"], + ), + ) + + +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, ) diff --git a/releasenotes/notes/multiple-task-emails-0c55ee7103262f14.yaml b/releasenotes/notes/multiple-task-emails-0c55ee7103262f14.yaml new file mode 100644 index 0000000..64fcb80 --- /dev/null +++ b/releasenotes/notes/multiple-task-emails-0c55ee7103262f14.yaml @@ -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.