diff --git a/authentik/admin/templates/administration/task/list.html b/authentik/admin/templates/administration/task/list.html index 7f0ef9ea5..373bff1fc 100644 --- a/authentik/admin/templates/administration/task/list.html +++ b/authentik/admin/templates/administration/task/list.html @@ -38,7 +38,7 @@ {% for task in object_list %} -
{{ task.task_name }}
+ {{ task.html_name|join:"_­" }} diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 4f9e1bd56..72f19b633 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -21,6 +21,17 @@ error_reporting: environment: customer send_pii: false +# Global email settings +email: + host: localhost + port: 25 + username: "" + password: "" + use_tls: false + use_ssl: false + timeout: 10 + from: authentik@localhost + outposts: docker_image_base: "beryju/authentik" # this is prepended to -proxy:version diff --git a/authentik/lib/tasks.py b/authentik/lib/tasks.py index eb9aff13c..132d03a5e 100644 --- a/authentik/lib/tasks.py +++ b/authentik/lib/tasks.py @@ -52,6 +52,11 @@ class TaskInfo: task_description: Optional[str] = field(default=None) + @property + def html_name(self) -> list[str]: + """Get task_name, but split on underscores, so we can join in the html template.""" + return self.task_name.split("_") + @staticmethod def all() -> Dict[str, "TaskInfo"]: """Get all TaskInfo objects""" diff --git a/authentik/root/settings.py b/authentik/root/settings.py index d68852758..4cc359628 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -196,7 +196,7 @@ ROOT_URLCONF = "authentik.root.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": ["/templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -238,6 +238,18 @@ DATABASES = { } } +# Email +EMAIL_HOST = CONFIG.y("email.host") +EMAIL_PORT = int(CONFIG.y("email.port")) +EMAIL_HOST_USER = CONFIG.y("email.username") +EMAIL_HOST_PASSWORD = CONFIG.y("email.password") +EMAIL_USE_TLS = CONFIG.y("email.use_tls") +EMAIL_USE_SSL = CONFIG.y("email.use_ssl") +EMAIL_TIMEOUT = int(CONFIG.y("email.timeout")) +DEFAULT_FROM_EMAIL = CONFIG.y("email.from") +SERVER_EMAIL = DEFAULT_FROM_EMAIL +EMAIL_SUBJECT_PREFIX = "[authentik] " + # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators diff --git a/authentik/stages/email/api.py b/authentik/stages/email/api.py index b3a4860f0..01cdf3d2b 100644 --- a/authentik/stages/email/api.py +++ b/authentik/stages/email/api.py @@ -2,18 +2,23 @@ from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet -from authentik.stages.email.models import EmailStage +from authentik.stages.email.models import EmailStage, get_template_choices class EmailStageSerializer(ModelSerializer): """EmailStage Serializer""" + def __init__(self, *args, **kwrags): + super().__init__(*args, **kwrags) + self.fields["template"].choices = get_template_choices() + class Meta: model = EmailStage fields = [ "pk", "name", + "use_global_settings", "host", "port", "username", diff --git a/authentik/stages/email/apps.py b/authentik/stages/email/apps.py index e7fe92115..5bd33d6f7 100644 --- a/authentik/stages/email/apps.py +++ b/authentik/stages/email/apps.py @@ -2,6 +2,12 @@ from importlib import import_module from django.apps import AppConfig +from django.db import ProgrammingError +from django.template.exceptions import TemplateDoesNotExist +from django.template.loader import get_template +from structlog.stdlib import get_logger + +LOGGER = get_logger() class AuthentikStageEmailConfig(AppConfig): @@ -13,3 +19,30 @@ class AuthentikStageEmailConfig(AppConfig): def ready(self): import_module("authentik.stages.email.tasks") + try: + self.validate_stage_templates() + except ProgrammingError: + pass + + def validate_stage_templates(self): + """Ensure all stage's templates actually exist""" + from authentik.stages.email.models import EmailStage, EmailTemplates + from authentik.events.models import Event, EventAction + + for stage in EmailStage.objects.all(): + try: + get_template(stage.template) + except TemplateDoesNotExist: + LOGGER.warning( + "Stage template does not exist, resetting", path=stage.template + ) + Event.new( + EventAction.CONFIGURATION_ERROR, + stage=stage, + message=( + f"Template {stage.template} does not exist, resetting to default." + f" (Stage {stage.name})" + ), + ).save() + stage.template = EmailTemplates.ACCOUNT_CONFIRM + stage.save() diff --git a/authentik/stages/email/forms.py b/authentik/stages/email/forms.py index 6c3ea986f..303d7834b 100644 --- a/authentik/stages/email/forms.py +++ b/authentik/stages/email/forms.py @@ -2,7 +2,7 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from authentik.stages.email.models import EmailStage +from authentik.stages.email.models import EmailStage, get_template_choices class EmailStageSendForm(forms.Form): @@ -14,11 +14,17 @@ class EmailStageSendForm(forms.Form): class EmailStageForm(forms.ModelForm): """Form to create/edit Email Stage""" + template = forms.ChoiceField(choices=get_template_choices) + class Meta: model = EmailStage fields = [ "name", + "use_global_settings", + "token_expiry", + "subject", + "template", "host", "port", "username", @@ -27,9 +33,6 @@ class EmailStageForm(forms.ModelForm): "use_ssl", "timeout", "from_address", - "token_expiry", - "subject", - "template", ] widgets = { "name": forms.TextInput(), diff --git a/authentik/stages/email/management/__init__.py b/authentik/stages/email/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/email/management/commands/__init__.py b/authentik/stages/email/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/email/management/commands/test_email.py b/authentik/stages/email/management/commands/test_email.py new file mode 100644 index 000000000..02a190c0a --- /dev/null +++ b/authentik/stages/email/management/commands/test_email.py @@ -0,0 +1,42 @@ +"""Send a test-email with global settings""" +from uuid import uuid4 + +from django.core.management.base import BaseCommand, no_translations + +from authentik.stages.email.models import EmailStage +from authentik.stages.email.tasks import send_mail +from authentik.stages.email.utils import TemplateEmailMessage + + +class Command(BaseCommand): # pragma: no cover + """Send a test-email with global settings""" + + @no_translations + def handle(self, *args, **options): + """Send a test-email with global settings""" + delete_stage = False + if options["stage"]: + stage = EmailStage.objects.get(name=options["stage"]) + else: + stage = EmailStage.objects.create( + name=f"temp-global-stage-{uuid4()}", use_global_settings=True + ) + delete_stage = True + message = TemplateEmailMessage( + subject="authentik Test-Email", + template_name="email/setup.html", + to=[options["to"]], + template_context={}, + ) + try: + # pyright: reportGeneralTypeIssues=false + send_mail( # pylint: disable=no-value-for-parameter + stage.pk, message.__dict__ + ) + finally: + if delete_stage: + stage.delete() + + def add_arguments(self, parser): + parser.add_argument("to", type=str) + parser.add_argument("-s", "--stage", type=str) diff --git a/authentik/stages/email/migrations/0001_initial.py b/authentik/stages/email/migrations/0001_initial.py index b3412e76d..8db5fb842 100644 --- a/authentik/stages/email/migrations/0001_initial.py +++ b/authentik/stages/email/migrations/0001_initial.py @@ -50,15 +50,15 @@ class Migration(migrations.Migration): models.TextField( choices=[ ( - "stages/email/for_email/password_reset.html", + "email/password_reset.html", "Password Reset", ), ( - "stages/email/for_email/account_confirmation.html", + "email/account_confirmation.html", "Account Confirmation", ), ], - default="stages/email/for_email/password_reset.html", + default="email/password_reset.html", ), ), ], diff --git a/authentik/stages/email/migrations/0002_emailstage_use_global_settings.py b/authentik/stages/email/migrations/0002_emailstage_use_global_settings.py new file mode 100644 index 000000000..a13acf2de --- /dev/null +++ b/authentik/stages/email/migrations/0002_emailstage_use_global_settings.py @@ -0,0 +1,33 @@ +# Generated by Django 3.1.4 on 2021-01-04 13:15 + +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def update_template_path(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + EmailStage = apps.get_model("authentik_stages_email", "EmailStage") + db_alias = schema_editor.connection.alias + + for stage in EmailStage.objects.using(db_alias).all(): + stage.template = stage.template.replace("stages/email/for_email/", "email/") + stage.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_email", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="emailstage", + name="use_global_settings", + field=models.BooleanField( + default=False, + help_text="When enabled, global Email connection settings will be used and connection settings below will be ignored.", + ), + ), + migrations.RunPython(update_template_path), + ] diff --git a/authentik/stages/email/models.py b/authentik/stages/email/models.py index 448e0f97b..936952b15 100644 --- a/authentik/stages/email/models.py +++ b/authentik/stages/email/models.py @@ -1,6 +1,9 @@ """email stage models""" +from os import R_OK, access +from pathlib import Path from typing import Type +from django.conf import settings from django.core.mail import get_connection from django.core.mail.backends.base import BaseEmailBackend from django.db import models @@ -8,26 +11,60 @@ from django.forms import ModelForm from django.utils.translation import gettext as _ from django.views import View from rest_framework.serializers import BaseSerializer +from structlog.stdlib import get_logger from authentik.flows.models import Stage +LOGGER = get_logger() + class EmailTemplates(models.TextChoices): """Templates used for rendering the Email""" PASSWORD_RESET = ( - "stages/email/for_email/password_reset.html", + "email/password_reset.html", _("Password Reset"), ) # nosec ACCOUNT_CONFIRM = ( - "stages/email/for_email/account_confirmation.html", + "email/account_confirmation.html", _("Account Confirmation"), ) +def get_template_choices(): + """Get all available Email templates, including dynamically mounted ones. + Directories are taken from TEMPLATES.DIR setting""" + static_choices = EmailTemplates.choices + + dirs = [Path(x) for x in settings.TEMPLATES[0]["DIRS"]] + for template_dir in dirs: + if not template_dir.exists(): + continue + for template in template_dir.glob("**/*.html"): + path = str(template) + if not access(path, R_OK): + LOGGER.warning( + "Custom template file is not readable, check permissions", path=path + ) + continue + rel_path = template.relative_to(template_dir) + static_choices.append((str(rel_path), f"Custom Template: {rel_path}")) + return static_choices + + class EmailStage(Stage): """Sends an Email to the user with a token to confirm their Email address.""" + use_global_settings = models.BooleanField( + default=False, + help_text=_( + ( + "When enabled, global Email connection settings will be used and " + "connection settings below will be ignored." + ) + ), + ) + host = models.TextField(default="localhost") port = models.IntegerField(default=25) username = models.TextField(default="", blank=True) @@ -42,7 +79,7 @@ class EmailStage(Stage): ) subject = models.TextField(default="authentik") template = models.TextField( - choices=EmailTemplates.choices, default=EmailTemplates.PASSWORD_RESET + choices=get_template_choices(), default=EmailTemplates.PASSWORD_RESET ) @property @@ -65,7 +102,9 @@ class EmailStage(Stage): @property def backend(self) -> BaseEmailBackend: - """Get fully configured EMail Backend instance""" + """Get fully configured Email Backend instance""" + if self.use_global_settings: + return get_connection() return get_connection( host=self.host, port=self.port, diff --git a/authentik/stages/email/static/stages/email/css/base.css b/authentik/stages/email/static/stages/email/css/base.css index 6f624e138..b8b6ab985 100644 --- a/authentik/stages/email/static/stages/email/css/base.css +++ b/authentik/stages/email/static/stages/email/css/base.css @@ -1,325 +1,285 @@ /* ------------------------------------- -GLOBAL RESETS + GLOBAL + A very basic CSS reset ------------------------------------- */ - -/*All the styling goes here*/ +* { + margin: 0; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + box-sizing: border-box; + font-size: 14px; +} img { - border: none; - -ms-interpolation-mode: bicubic; - max-width: 100%; + max-width: 100%; } body { - background-color: #fafafa; - font-family: sans-serif; - -webkit-font-smoothing: antialiased; - font-size: 14px; - line-height: 1.4; - margin: 0; - padding: 0; - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: none; + width: 100% !important; + height: 100%; + line-height: 1.6em; + /* 1.6em * 14px = 22.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */ + /*line-height: 22px;*/ } -table { - border-collapse: separate; - mso-table-lspace: 0pt; - mso-table-rspace: 0pt; - width: 100%; } - table td { - font-family: sans-serif; - font-size: 14px; - vertical-align: top; - } +/* Let's make sure all tables have defaults */ +table td { + vertical-align: top; +} - /* ------------------------------------- +/* ------------------------------------- BODY & CONTAINER - ------------------------------------- */ +------------------------------------- */ +body { + background-color: #f6f6f6; +} - .body { - background-color: #fafafa; - width: 100%; - } +.body-wrap { + background-color: #f6f6f6; + width: 100%; +} - /* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */ - .container { - display: block; - margin: 0 auto !important; - /* makes it centered */ - max-width: 580px; - padding: 10px; - width: 580px; - } +.container { + display: block !important; + max-width: 600px !important; + margin: 0 auto !important; + /* makes it centered */ + clear: both !important; +} - /* This should also be a block element, so that it will fill 100% of the .container */ - .content { - box-sizing: border-box; - display: block; - margin: 0 auto; - max-width: 580px; - padding: 10px; - } +.content { + max-width: 600px; + margin: 0 auto; + display: block; + padding: 20px; +} - /* ------------------------------------- +/* ------------------------------------- HEADER, FOOTER, MAIN - ------------------------------------- */ - .main { - background: #ffffff; - border-radius: 3px; - width: 100%; - } +------------------------------------- */ +.main { + background-color: #fff; + border: 1px solid #e9e9e9; + border-radius: 3px; +} - .wrapper { - box-sizing: border-box; - padding: 20px; - } +.content-wrap { + padding: 20px; +} - .content-block { - padding-bottom: 10px; - padding-top: 10px; - } +.content-block { + padding: 0 0 20px; +} - .footer { - clear: both; - margin-top: 10px; - text-align: center; - width: 100%; - } - .footer td, - .footer p, - .footer span, - .footer a { - color: #999999; - font-size: 12px; - text-align: center; - } +.header { + width: 100%; + margin-bottom: 20px; +} - /* ------------------------------------- +.footer { + width: 100%; + clear: both; + color: #999; + padding: 20px; +} +.footer p, .footer a, .footer td { + color: #999; + font-size: 12px; +} + +/* ------------------------------------- TYPOGRAPHY - ------------------------------------- */ - h1, - h2, - h3, - h4 { - color: #000000; - font-family: sans-serif; - font-weight: 400; - line-height: 1.4; - margin: 0; - margin-bottom: 30px; - } +------------------------------------- */ +h1, h2, h3 { + font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + color: #000; + margin: 40px 0 0; + line-height: 1.2em; + font-weight: 400; +} - h1 { - font-size: 35px; - font-weight: 300; - text-align: center; - text-transform: capitalize; - } +h1 { + font-size: 32px; + font-weight: 500; + /* 1.2em * 32px = 38.4px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */ + /*line-height: 38px;*/ +} - p, - ul, - ol { - font-family: sans-serif; - font-size: 14px; - font-weight: normal; - margin: 0; - margin-bottom: 15px; - } - p li, - ul li, - ol li { - list-style-position: inside; - margin-left: 5px; - } +h2 { + font-size: 24px; + /* 1.2em * 24px = 28.8px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */ + /*line-height: 29px;*/ +} - a { - color: #06c; - border-radius: 3px; - text-decoration: underline; - } +h3 { + font-size: 18px; + /* 1.2em * 18px = 21.6px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */ + /*line-height: 22px;*/ +} - /* ------------------------------------- - BUTTONS - ------------------------------------- */ - .btn { - box-sizing: border-box; - width: 100%; } - .btn > tbody > tr > td { - padding-bottom: 15px; } - .btn table { - width: auto; - } - .btn table td { - background-color: #ffffff; - border-radius: 5px; - text-align: center; - } - .btn a { - background-color: #ffffff; - border: solid 1px #06c; - border-radius: 5px; - box-sizing: border-box; - color: #06c; - cursor: pointer; - display: inline-block; - font-size: 14px; - font-weight: bold; - margin: 0; - padding: 12px 25px; - text-decoration: none; - text-transform: capitalize; - } +h4 { + font-size: 14px; + font-weight: 600; +} - .btn-primary table td { - background-color: #06c; - } +p, ul, ol { + margin-bottom: 10px; + font-weight: normal; +} +p li, ul li, ol li { + margin-left: 5px; + list-style-position: inside; +} - .btn-primary a { - background-color: #06c; - border-color: #06c; - color: #ffffff; - } +/* ------------------------------------- + LINKS & BUTTONS +------------------------------------- */ +a { + color: #348eda; + text-decoration: underline; +} - /* ------------------------------------- - OTHER STYLES THAT MIGHT BE USEFUL - ------------------------------------- */ - .last { - margin-bottom: 0; - } +.btn-primary { + text-decoration: none; + color: #FFF; + background-color: #348eda; + border: solid #348eda; + border-width: 10px 20px; + line-height: 2em; + /* 2em * 14px = 28px, use px to get airier line-height also in Thunderbird, and Yahoo!, Outlook.com, AOL webmail clients */ + /*line-height: 28px;*/ + font-weight: bold; + text-align: center; + cursor: pointer; + display: inline-block; + border-radius: 5px; + text-transform: capitalize; +} - .first { - margin-top: 0; - } +/* ------------------------------------- + OTHER STYLES THAT MIGHT BE USEFUL +------------------------------------- */ +.last { + margin-bottom: 0; +} - .align-center { - text-align: center; - } +.first { + margin-top: 0; +} - .align-right { - text-align: right; - } +.aligncenter { + text-align: center; +} - .align-left { - text-align: left; - } +.alignright { + text-align: right; +} - .clear { - clear: both; - } +.alignleft { + text-align: left; +} - .mt0 { - margin-top: 0; - } +.clear { + clear: both; +} - .mb0 { - margin-bottom: 0; - } +/* ------------------------------------- + ALERTS + Change the class depending on warning email, good email or bad email +------------------------------------- */ +.alert { + font-size: 16px; + color: #fff; + font-weight: 500; + padding: 20px; + text-align: center; + border-radius: 3px 3px 0 0; +} +.alert a { + color: #fff; + text-decoration: none; + font-weight: 500; + font-size: 16px; +} +.alert-brand { + background-color: #fd4b2d; +} +.alert-warning { + background-color: #F0AB00; +} +.alert-danger { + background-color: #C9190B; +} +.alert-success { + background-color: #3E8635; +} - .preheader { - color: transparent; - display: none; - height: 0; - max-height: 0; - max-width: 0; - opacity: 0; - overflow: hidden; - mso-hide: all; - visibility: hidden; - width: 0; - } +/* ------------------------------------- + INVOICE + Styles for the billing table +------------------------------------- */ +.invoice { + margin: 40px auto; + text-align: left; + width: 80%; +} +.invoice td { + padding: 5px 0; +} +.invoice .invoice-items { + width: 100%; +} +.invoice .invoice-items td { + border-top: #eee 1px solid; +} +.invoice .invoice-items .total td { + border-top: 2px solid #333; + border-bottom: 2px solid #333; + font-weight: 700; +} - .powered-by a { - text-decoration: none; - } +/* ------------------------------------- + RESPONSIVE AND MOBILE FRIENDLY STYLES +------------------------------------- */ +@media only screen and (max-width: 640px) { + body { + padding: 0 !important; + } - hr { - border: 0; - border-bottom: 1px solid #fafafa; - margin: 20px 0; - } + h1, h2, h3, h4 { + font-weight: 800 !important; + margin: 20px 0 5px !important; + } - /* ------------------------------------- - RESPONSIVE AND MOBILE FRIENDLY STYLES - ------------------------------------- */ - @media only screen and (max-width: 620px) { - table[class=body] h1 { - font-size: 28px !important; - margin-bottom: 10px !important; - } - table[class=body] p, - table[class=body] ul, - table[class=body] ol, - table[class=body] td, - table[class=body] span, - table[class=body] a { - font-size: 16px !important; - } - table[class=body] .wrapper, - table[class=body] .article { - padding: 10px !important; - } - table[class=body] .content { - padding: 0 !important; - } - table[class=body] .container { - padding: 0 !important; - width: 100% !important; - } - table[class=body] .main { - border-left-width: 0 !important; - border-radius: 0 !important; - border-right-width: 0 !important; - } - table[class=body] .btn table { - width: 100% !important; - } - table[class=body] .btn a { - width: 100% !important; - } - table[class=body] .img-responsive { - height: auto !important; - max-width: 100% !important; - width: auto !important; - } - } + h1 { + font-size: 22px !important; + } - /* ------------------------------------- - PRESERVE THESE STYLES IN THE HEAD - ------------------------------------- */ - @media all { - .ExternalClass { - width: 100%; - } - .ExternalClass, - .ExternalClass p, - .ExternalClass span, - .ExternalClass font, - .ExternalClass td, - .ExternalClass div { - line-height: 100%; - } - .apple-link a { - color: inherit !important; - font-family: inherit !important; - font-size: inherit !important; - font-weight: inherit !important; - line-height: inherit !important; - text-decoration: none !important; - } - #MessageViewBody a { - color: inherit; - text-decoration: none; - font-size: inherit; - font-family: inherit; - font-weight: inherit; - line-height: inherit; - } - .btn-primary table td:hover { - background-color: #34495e !important; - } - .btn-primary a:hover { - background-color: #34495e !important; - border-color: #34495e !important; - } - } + h2 { + font-size: 18px !important; + } + + h3 { + font-size: 16px !important; + } + + .container { + padding: 0 !important; + width: 100% !important; + } + + .content { + padding: 0 !important; + } + + .content-wrap { + padding: 10px !important; + } + + .invoice { + width: 100% !important; + } +} + +/*# sourceMappingURL=styles.css.map */ diff --git a/authentik/stages/email/tasks.py b/authentik/stages/email/tasks.py index bf1e02634..2903bf002 100644 --- a/authentik/stages/email/tasks.py +++ b/authentik/stages/email/tasks.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List from celery import group from django.core.mail import EmailMultiAlternatives from django.core.mail.utils import DNS_NAME +from django.utils.text import slugify from structlog.stdlib import get_logger from authentik.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus @@ -38,7 +39,7 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]) """Send Email for Email Stage. Retries are scheduled automatically.""" self.save_on_success = False message_id = make_msgid(domain=DNS_NAME) - self.set_uid(message_id) + self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_"))) try: stage: EmailStage = EmailStage.objects.get(pk=email_stage_pk) backend = stage.backend @@ -48,7 +49,8 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]) message_object = EmailMultiAlternatives() for key, value in message.items(): setattr(message_object, key, value) - message_object.from_email = stage.from_address + if not stage.use_global_settings: + message_object.from_email = stage.from_address # Because we use the Message-ID as UID for the task, manually assign it message_object.extra_headers["Message-ID"] = message_id @@ -61,5 +63,6 @@ def send_mail(self: MonitoredTask, email_stage_pk: int, message: Dict[Any, Any]) ) ) except (SMTPException, ConnectionError) as exc: + LOGGER.debug("Error sending email, retrying...", exc=exc) self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc)) raise exc diff --git a/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html b/authentik/stages/email/templates/email/account_confirmation.html similarity index 95% rename from authentik/stages/email/templates/stages/email/for_email/account_confirmation.html rename to authentik/stages/email/templates/email/account_confirmation.html index 8c2b353e4..b6e1f7f62 100644 --- a/authentik/stages/email/templates/stages/email/for_email/account_confirmation.html +++ b/authentik/stages/email/templates/email/account_confirmation.html @@ -1,4 +1,4 @@ -{% extends 'stages/email/for_email/base.html' %} +{% extends 'email/base.html' %} {% load authentik_stages_email %} {% load i18n %} diff --git a/authentik/stages/email/templates/email/base.html b/authentik/stages/email/templates/email/base.html new file mode 100644 index 000000000..3797461a1 --- /dev/null +++ b/authentik/stages/email/templates/email/base.html @@ -0,0 +1,34 @@ +{% load authentik_stages_email %} + + + + + + + + + + + + + + + + +
+
+ + {% block content %} + {% endblock %} +
+ +
+
+ + diff --git a/authentik/stages/email/templates/email/generic.html b/authentik/stages/email/templates/email/generic.html new file mode 100644 index 000000000..01a242111 --- /dev/null +++ b/authentik/stages/email/templates/email/generic.html @@ -0,0 +1,20 @@ +{% extends "email/base.html" %} + +{% block content %} + + + {{ title }} + + + + + + + + +
+ {{ body }} +
+ + +{% endblock %} diff --git a/authentik/stages/email/templates/stages/email/for_email/password_reset.html b/authentik/stages/email/templates/email/password_reset.html similarity index 95% rename from authentik/stages/email/templates/stages/email/for_email/password_reset.html rename to authentik/stages/email/templates/email/password_reset.html index d6818daf2..3d6659ad4 100644 --- a/authentik/stages/email/templates/stages/email/for_email/password_reset.html +++ b/authentik/stages/email/templates/email/password_reset.html @@ -1,4 +1,4 @@ -{% extends "stages/email/for_email/base.html" %} +{% extends "email/base.html" %} {% load authentik_utils %} {% load i18n %} diff --git a/authentik/stages/email/templates/email/setup.html b/authentik/stages/email/templates/email/setup.html new file mode 100644 index 000000000..1efc08004 --- /dev/null +++ b/authentik/stages/email/templates/email/setup.html @@ -0,0 +1,25 @@ +{% extends "email/base.html" %} + +{% load authentik_stages_email %} +{% load i18n %} + +{% block content %} + + + {% trans 'authentik Test-Email' %} + + + + + + + + +
+ {% blocktrans %} + This is a test email to inform you, that you've successfully configured authentik emails. + {% endblocktrans %} +
+ + +{% endblock %} diff --git a/authentik/stages/email/templates/stages/email/for_email/base.html b/authentik/stages/email/templates/stages/email/for_email/base.html deleted file mode 100644 index 1261007a9..000000000 --- a/authentik/stages/email/templates/stages/email/for_email/base.html +++ /dev/null @@ -1,65 +0,0 @@ -{% load authentik_stages_email %} -{% load authentik_utils %} -{% load static %} -{% load i18n %} - - - - - - - - - - - - - - {% block pre_header %} - {% endblock %} - - - - - - - - - - - diff --git a/authentik/stages/email/templates/stages/email/for_email/generic_email.html b/authentik/stages/email/templates/stages/email/for_email/generic_email.html deleted file mode 100644 index 8246f93b6..000000000 --- a/authentik/stages/email/templates/stages/email/for_email/generic_email.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "stages/email/for_email/base.html" %} - -{% block content %} - - - - - - -
-

{{ title }}!

-
- - - - - - - - -
-

{{ body }}

-
- - -{% endblock %} diff --git a/authentik/stages/email/tests/__init__.py b/authentik/stages/email/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/authentik/stages/email/tests/test_sending.py b/authentik/stages/email/tests/test_sending.py new file mode 100644 index 000000000..b97a8ce7c --- /dev/null +++ b/authentik/stages/email/tests/test_sending.py @@ -0,0 +1,83 @@ +"""email tests""" +from smtplib import SMTPException +from unittest.mock import MagicMock, patch + +from django.core import mail +from django.core.mail.backends.locmem import EmailBackend +from django.shortcuts import reverse +from django.test import Client, TestCase + +from authentik.core.models import User +from authentik.flows.markers import StageMarker +from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan +from authentik.flows.views import SESSION_KEY_PLAN +from authentik.stages.email.models import EmailStage + + +class TestEmailStageSending(TestCase): + """Email tests""" + + def setUp(self): + super().setUp() + self.user = User.objects.create_user( + username="unittest", email="test@beryju.org" + ) + self.client = Client() + + self.flow = Flow.objects.create( + name="test-email", + slug="test-email", + designation=FlowDesignation.AUTHENTICATION, + ) + self.stage = EmailStage.objects.create( + name="email", + ) + FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) + + def test_pending_user(self): + """Test with pending user""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + with self.settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend" + ): + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].subject, "authentik") + + def test_send_error(self): + """Test error during sending (sending will be retried)""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + plan.context[PLAN_CONTEXT_PENDING_USER] = self.user + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + + url = reverse( + "authentik_flows:flow-executor", kwargs={"flow_slug": self.flow.slug} + ) + with self.settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend" + ): + with patch( + "django.core.mail.backends.locmem.EmailBackend.send_messages", + MagicMock(side_effect=[SMTPException, EmailBackend.send_messages]), + ): + response = self.client.post(url) + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertTrue(len(mail.outbox) >= 1) + self.assertEqual(mail.outbox[0].subject, "authentik") diff --git a/authentik/stages/email/tests.py b/authentik/stages/email/tests/test_stage.py similarity index 93% rename from authentik/stages/email/tests.py rename to authentik/stages/email/tests/test_stage.py index c71a0b2d5..0a84ecf7b 100644 --- a/authentik/stages/email/tests.py +++ b/authentik/stages/email/tests/test_stage.py @@ -87,6 +87,14 @@ class TestEmailStage(TestCase): self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].subject, "authentik") + def test_use_global_settings(self): + """Test use_global_settings""" + host = "some-unique-string" + with self.settings( + EMAIL_HOST=host, EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" + ): + self.assertEqual(EmailStage(use_global_settings=True).backend.host, host) + def test_token(self): """Test with token""" # Make sure token exists diff --git a/authentik/stages/email/tests/test_templates.py b/authentik/stages/email/tests/test_templates.py new file mode 100644 index 000000000..3c2c06c93 --- /dev/null +++ b/authentik/stages/email/tests/test_templates.py @@ -0,0 +1,28 @@ +"""email tests""" +from os import unlink +from pathlib import Path +from tempfile import gettempdir, mkstemp +from typing import Any + +from django.conf import settings +from django.test import TestCase + +from authentik.stages.email.models import get_template_choices + + +def get_templates_setting(temp_dir: str) -> dict[str, Any]: + """Patch settings TEMPLATE's dir property""" + templates_setting = settings.TEMPLATES + templates_setting[0]["DIRS"] = [temp_dir] + return templates_setting + + +class TestEmailStageTemplates(TestCase): + """Email tests""" + + def test_custom_template(self): + """Test with custom template""" + with self.settings(TEMPLATES=get_templates_setting(gettempdir())): + _, file = mkstemp(suffix=".html") + self.assertEqual(get_template_choices()[-1][0], Path(file).name) + unlink(file) diff --git a/docker-compose.yml b/docker-compose.yml index b6c0b4659..a3653d32a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} volumes: - ./media:/media + - ./custom-templates:/templates ports: - 8000 networks: @@ -57,6 +58,7 @@ services: volumes: - ./backups:/backups - /var/run/docker.sock:/var/run/docker.sock + - ./custom-templates:/templates env_file: - .env static: diff --git a/helm/README.md b/helm/README.md index 5780b8026..a2df9d6cc 100644 --- a/helm/README.md +++ b/helm/README.md @@ -2,9 +2,9 @@ | Name | Default | Description | |-----------------------------------|-------------------------|-------------| -| image.name | beryju/authentik | Image used to run the authentik server and worker | -| image.name_static | beryju/authentik-static | Image used to run the authentik static server (CSS and JS Files) | -| image.tag | 0.14.2-stable | Image tag | +| image.name | beryju/authentik | Image used to run the authentik server and worker | +| image.name_static | beryju/authentik-static | Image used to run the authentik static server (CSS and JS Files) | +| image.tag | 0.14.2-stable | Image tag | | image.pullPolicy | IfNotPresent | Image Pull Policy used for all deployments | | serverReplicas | 1 | Replicas for the Server deployment | | workerReplicas | 1 | Replicas for the Worker deployment | @@ -14,13 +14,21 @@ | config.errorReporting.environment | customer | Environment sent with the error reporting | | config.errorReporting.sendPii | false | Whether to send Personally-identifiable data with the error reporting | | config.logLevel | warning | Log level of authentik | +| config.email.host | localhost | SMTP Host Emails are sent to | +| config.email.port | 25 | SMTP Port Emails are sent to | +| config.email.username | | SMTP Username | +| config.email.password | | SMTP Password | +| config.email.use_tls | false | Enable StartTLS | +| config.email.use_ssl | false | Enable SSL | +| config.email.timeout | 10 | SMTP Timeout | +| config.email.from | authentik@localhost | Email address authentik will send from, should have a correct @domain | | backup.accessKey | | Optionally enable S3 Backup, Access Key | | backup.secretKey | | Optionally enable S3 Backup, Secret Key | | backup.bucket | | Optionally enable S3 Backup, Bucket | | backup.region | | Optionally enable S3 Backup, Region | | backup.host | | Optionally enable S3 Backup, to custom Endpoint like minio | | ingress.annotations | {} | Annotations for the ingress object | -| ingress.hosts | [authentik.k8s.local] | Hosts which the ingress will match | +| ingress.hosts | [authentik.k8s.local] | Hosts which the ingress will match | | ingress.tls | [] | TLS Configuration, same as Ingress objects | | install.postgresql | true | Enables/disables the packaged PostgreSQL Chart | install.redis | true | Enables/disables the packaged Redis Chart diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 61af2bf65..6a3a3c796 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -8,7 +8,6 @@ data: POSTGRESQL__USER: "{{ .Values.postgresql.postgresqlUsername }}" {{- if .Values.backup }} POSTGRESQL__S3_BACKUP__ACCESS_KEY: "{{ .Values.backup.accessKey }}" - POSTGRESQL__S3_BACKUP__SECRET_KEY: "{{ .Values.backup.secretKey }}" POSTGRESQL__S3_BACKUP__BUCKET: "{{ .Values.backup.bucket }}" POSTGRESQL__S3_BACKUP__REGION: "{{ .Values.backup.region }}" POSTGRESQL__S3_BACKUP__HOST: "{{ .Values.backup.host }}" @@ -19,3 +18,10 @@ data: ERROR_REPORTING__SEND_PII: "{{ .Values.config.errorReporting.sendPii }}" LOG_LEVEL: "{{ .Values.config.logLevel }}" OUTPOSTS__DOCKER_IMAGE_BASE: "{{ .Values.image.name_outposts }}" + EMAIL__HOST: "{{ .Values.config.email.host }}" + EMAIL__PORT: "{{ .Values.config.email.port }}" + EMAIL__USERNAM: "{{ .Values.config.email.username }}" + EMAIL__USE_TLS: "{{ .Values.config.email.use_tls }}" + EMAIL__USE_SSL: "{{ .Values.config.email.use_ssl }}" + EMAIL__TIMEOUT: "{{ .Values.config.email.timeout }}" + EMAIL__FROM: "{{ .Values.config.email.from }}" diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml index bbe9ff8d5..90a5d1043 100644 --- a/helm/templates/secret.yaml +++ b/helm/templates/secret.yaml @@ -6,7 +6,11 @@ metadata: data: monitoring_username: bW9uaXRvcg== # monitor in base64 {{- if .Values.config.secretKey }} - secret_key: {{ .Values.config.secretKey | b64enc | quote }} + SECRET_KEY: {{ .Values.config.secretKey | b64enc | quote }} {{- else }} - secret_key: {{ randAlphaNum 50 | b64enc | quote}} + SECRET_KEY: {{ randAlphaNum 50 | b64enc | quote}} {{- end }} + {{- if .Values.backup }} + POSTGRESQL__S3_BACKUP__SECRET_KEY: "{{ .Values.backup.secretKey }}" + {{- end}} + EMAIL__PASSWOR: "{{ .Values.config.email.password }}" diff --git a/helm/templates/web-deployment.yaml b/helm/templates/web-deployment.yaml index 179be6251..5a2fb3268 100644 --- a/helm/templates/web-deployment.yaml +++ b/helm/templates/web-deployment.yaml @@ -51,12 +51,10 @@ spec: - configMapRef: name: {{ include "authentik.fullname" . }}-config prefix: AUTHENTIK_ + - secretRef: + name: {{ include "authentik.fullname" . }}-secret-key + prefix: AUTHENTIK_ env: - - name: AUTHENTIK_SECRET_KEY - valueFrom: - secretKeyRef: - name: {{ include "authentik.fullname" . }}-secret-key - key: secret_key - name: AUTHENTIK_REDIS__PASSWORD valueFrom: secretKeyRef: diff --git a/helm/templates/worker-deployment.yaml b/helm/templates/worker-deployment.yaml index af084295b..60fb58f1f 100644 --- a/helm/templates/worker-deployment.yaml +++ b/helm/templates/worker-deployment.yaml @@ -54,12 +54,10 @@ spec: - configMapRef: name: "{{ include "authentik.fullname" . }}-config" prefix: "AUTHENTIK_" + - secretRef: + name: {{ include "authentik.fullname" . }}-secret-key + prefix: AUTHENTIK_ env: - - name: AUTHENTIK_SECRET_KEY - valueFrom: - secretKeyRef: - name: "{{ include "authentik.fullname" . }}-secret-key" - key: secret_key - name: AUTHENTIK_REDIS__PASSWORD valueFrom: secretKeyRef: diff --git a/helm/values.yaml b/helm/values.yaml index be9aab158..d66d2ef38 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -25,6 +25,21 @@ config: # Log level used by web and worker # Can be either debug, info, warning, error logLevel: warning + # Global Email settings + email: + # SMTP Host Emails are sent to + host: localhost + port: 25 + # Optionally authenticate + username: "" + password: "" + # Use StartTLS + useTls: false + # Use SSL + useSsl: false + timeout: 10 + # Email address authentik will send from, should have a correct @domain + from: authentik@localhost # Enable Database Backups to S3 # backup: diff --git a/swagger.yaml b/swagger.yaml index adbc2e5a2..e0305698f 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -8579,6 +8579,11 @@ definitions: title: Name type: string minLength: 1 + use_global_settings: + title: Use global settings + description: When enabled, global Email connection settings will be used and + connection settings below will be ignored. + type: boolean host: title: Host type: string @@ -8625,8 +8630,8 @@ definitions: title: Template type: string enum: - - stages/email/for_email/password_reset.html - - stages/email/for_email/account_confirmation.html + - email/password_reset.html + - email/account_confirmation.html IdentificationStage: description: IdentificationStage Serializer required: diff --git a/website/docs/flow/stages/email/custom-template.png b/website/docs/flow/stages/email/custom-template.png new file mode 100644 index 000000000..072bae310 Binary files /dev/null and b/website/docs/flow/stages/email/custom-template.png differ diff --git a/website/docs/flow/stages/email/index.md b/website/docs/flow/stages/email/index.md index 3eec6474d..8837570af 100644 --- a/website/docs/flow/stages/email/index.md +++ b/website/docs/flow/stages/email/index.md @@ -5,3 +5,19 @@ title: Email stage This stage can be used for email verification. authentik's background worker will send an email using the specified connection details. When an email can't be delivered, delivery is automatically retried periodically. ![](email-recovery.png) + +## Custom Templates + +You can also use custom email templates, to use your own design or layout. + +Place any custom templates in the `custom-templates` Folder, which is in the same folder as your docker-compose file. Afterwards, you'll be able to select the template when creating/editing an Email stage. + +:::info +This is currently only supported for docker-compose installs, and supported starting version 0.15. +::: + +:::info +If you've add the line and created a file, and can't see if, check the logs using `docker-compose logs -f worker`. +::: + +![](custom-template.png) diff --git a/website/docs/index.md b/website/docs/index.md index 1ea964dea..0b2d63055 100755 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -9,7 +9,7 @@ authentik is an open-source Identity Provider focused on flexibility and versati ## Installation -See [Docker-compose](installation/docker-compose.md) or [Kubernetes](installation/kubernetes.md) +See [Docker-compose](installation/docker-compose) or [Kubernetes](installation/kubernetes) ## Screenshots diff --git a/website/docs/installation/docker-compose.md b/website/docs/installation/docker-compose.md index c33f32036..87005f5f2 100644 --- a/website/docs/installation/docker-compose.md +++ b/website/docs/installation/docker-compose.md @@ -9,7 +9,7 @@ This installation method is for test-setups and small-scale productive setups. - docker - docker-compose -## Install +## Preparation Download the latest `docker-compose.yml` from [here](https://raw.githubusercontent.com/BeryJu/authentik/master/docker-compose.yml). Place it in a directory of your choice. @@ -25,6 +25,30 @@ echo "PG_PASS=$(pwgen 40 1)" >> .env echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env ``` +## Email configuration (optional, but recommended) + +It is also recommended to configure global email credentials. These are used by authentik to notify you about alerts, configuration issues. They can also be used by [Email stages](flow/stages/email/index.md) to send verification/recovery emails. + +Append this block to your `.env` file + +``` +# SMTP Host Emails are sent to +AUTHENTIK_EMAIL__HOST=localhost +AUTHENTIK_EMAIL__PORT=25 +# Optionally authenticate +AUTHENTIK_EMAIL__USERNAME="" +AUTHENTIK_EMAIL__PASSWORD="" +# Use StartTLS +AUTHENTIK_EMAIL__USE_TLS=false +# Use SSL +AUTHENTIK_EMAIL__USE_SSL=false +AUTHENTIK_EMAIL__TIMEOUT=10 +# Email address authentik will send from, should have a correct @domain +AUTHENTIK_EMAIL__FROM=authentik@localhost +``` + +## Startup + Afterwards, run these commands to finish ``` @@ -39,8 +63,6 @@ If you plan to use this setup for production, it is also advised to change the P Now you can pull the Docker images needed by running `docker-compose pull`. After this has finished, run `docker-compose up -d` to start authentik. -authentik will then be reachable via HTTP on port 80, and HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption. - -If you plan to access authentik via a reverse proxy which does SSL Termination, make sure you use the HTTPS port, so authentik is aware of the SSL connection. +authentik will then be reachable HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption. The initial setup process also creates a default admin user, the username and password for which is `akadmin`. It is highly recommended to change this password as soon as you log in. diff --git a/website/docs/installation/kubernetes.md b/website/docs/installation/kubernetes.md index 192e59ec8..79722fc04 100644 --- a/website/docs/installation/kubernetes.md +++ b/website/docs/installation/kubernetes.md @@ -14,6 +14,8 @@ helm install authentik/authentik --devel -f values.yaml This installation automatically applies database migrations on startup. After the installation is done, you can use `akadmin` as username and password. +It is also recommended to configure global email credentials. These are used by authentik to notify you about alerts, configuration issues. They can also be used by [Email stages](flow/stages/email/index.md) to send verification/recovery emails. + ```yaml ################################### # Values directly affecting authentik @@ -41,6 +43,21 @@ config: # Log level used by web and worker # Can be either debug, info, warning, error logLevel: warning + # Global Email settings + email: + # SMTP Host Emails are sent to + host: localhost + port: 25 + # Optionally authenticate + username: "" + password: "" + # Use StartTLS + useTls: false + # Use SSL + useSsl: false + timeout: 10 + # Email address authentik will send from, should have a correct @domain + from: authentik@localhost # Enable Database Backups to S3 # backup: @@ -80,6 +97,4 @@ redis: master: persistence: enabled: false - # https://stackoverflow.com/a/59189742 - disableCommands: [] ``` diff --git a/website/docs/troubleshooting/emails.md b/website/docs/troubleshooting/emails.md new file mode 100644 index 000000000..13323624e --- /dev/null +++ b/website/docs/troubleshooting/emails.md @@ -0,0 +1,23 @@ +--- +title: Troubleshooting Email sending +--- + +To test if an email stage, or the global email settings are configured correctly, you can run the following command: + +```` +./manage.py test_email [-s ] +``` + +If you omit the `-s` parameter, the email will be sent using the global settings. Otherwise, the settings of the specified stage will be used. + +To run this command with docker-compose, use + +``` +docker-compose exec -it worker ./manage.py test_email [...] +``` + +To run this command with Kubernetes, use + +``` +kubectl exec -it authentik-worker-xxxxx -- ./manage.py test_email [...] +``` diff --git a/website/sidebars.js b/website/sidebars.js index 1760bf768..62a3d6f44 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -135,7 +135,10 @@ module.exports = { { type: "category", label: "Troubleshooting", - items: ["troubleshooting/access"], + items: [ + "troubleshooting/access", + "troubleshooting/emails", + ], }, { type: "category", diff --git a/website/static/flows/enrollment-email-verification.pbflow b/website/static/flows/enrollment-email-verification.pbflow index 5a07481a3..b002933a1 100644 --- a/website/static/flows/enrollment-email-verification.pbflow +++ b/website/static/flows/enrollment-email-verification.pbflow @@ -99,7 +99,7 @@ "from_address": "system@authentik.local", "token_expiry": 30, "subject": "authentik", - "template": "stages/email/for_email/account_confirmation.html" + "template": "email/account_confirmation.html" } }, { diff --git a/website/static/flows/recovery-email-verification.pbflow b/website/static/flows/recovery-email-verification.pbflow index 271b5591c..d6b9f65e0 100644 --- a/website/static/flows/recovery-email-verification.pbflow +++ b/website/static/flows/recovery-email-verification.pbflow @@ -92,7 +92,7 @@ "from_address": "system@authentik.local", "token_expiry": 30, "subject": "authentik", - "template": "stages/email/for_email/password_reset.html" + "template": "email/password_reset.html" } }, {