Repository: goauthentik/authentik
Pull request: stages/authenticator_email: Email OTP
<!--
👋 Hi there! Welcome.

Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
-->

## Details

<!--
Explain what this PR changes, what the rationale behind the change is, if any new requirements are introduced or any breaking changes caused by this PR.

Ideally also link an Issue for context that this PR will close using `closes #`
-->
stages/authenticator_email: Add Email Authenticator Stage

Introduces a new authentication stage that enables email-based two-factor authentication. This stage:
- Allows users to set up and verify their email for 2FA
- Supports configurable email templates for OTP delivery
- Integrates with existing flow and challenge system

closes #3291 

Documentation PR: https://github.com/goauthentik/authentik/pull/12853 

---

## Checklist

-   [x] Local tests pass (`ak test authentik/`)
-   [x] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [x] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [x] The code has been formatted (`make web`)

If applicable

-   [ ] The documentation has been updated
-   [ ] The documentation has been formatted (`make website`)


Problem statement:
Email OTP
**Is your feature request related to a problem? Please describe.**
Authentik does not currently support sending OTP to email.

**Describe the solution you'd like**
The emergence of a new OTP stage, where a temporary password is sent to the user by email.

**Describe alternatives you've considered**
N/A

**Additional context**
N/A

Hints from issue discussion:
Seems like a 'nice to have' idea to me
Not sure about the feasability of this, but having a custom email template with a TOTP code as a variable in it _might be_ doable. (Not sure, I have yet to try this)
+1 for this, would you be open to a PR?

This is vital to my use case. I've been trying to hack it in with Policies, but built-in support really is by far looking to be the only good solution (email sending in Policies is the only hard "this seems like a pretty bad idea" bottleneck, but even given that, I cannot in good faith call crypto code in Policies a solution).

# What would it Fix?
Flows which need to include email verification as part of login (ex. seamless registration as first login for validating an external identity by email, email-code-as-password flows great for time-limited accounts, etc.), are exceedingly more intuitive with OTPs than a new flow-restoring link.

Here are the exact problems that email OTP would solve.

## Flow Restoration Problems
On flow restore, it seems that the redirect link and application reference **are deleted** wholesale. This damages onboarding: The user logging in to 'Service X' will, after verifying their email, be dropped into Authentik's internal application overview instead of where they were going.

Moreover, jf they were referred to a particular URL within Service X, the user will have to find that resource by themselves, try to click the original link again, or give up.

A user checking their email on a different device will need to figure out themselves that they need to re-login on the device they were using; this is less impactful, but still feels brittle enough.

With some Policy hacking to persist redirect URLs in prompt_data across flow restoration, or ex. in the user attributes, maybe this can be solveable (at the cost of complexity). But on the whole it would be little more than a band aid; moreover, there would sometimes be cases that a restoring a flow shouldn't restore the redirect URL...

## Non-Browser SSO
Users using Mobile or Desktop apps for SSO with an internal browser must, after the email stage is triggered, figure out themselves to do the following:
1. Close the SSO login in the app. Click away any 'could not login' messages.
2. Wait for and open the email link, in your real browser. Close the loaded browser tab (keep in mind, the user is just trying to use Service X, and their browser just showed them some strange "Authentik Application List", which probably don't know what is)
3. Perform the SSO login again, which now magically works since no email stage is triggered.

If Email Verification does happen to be required to login every time (ex. temporary or rarely-used accounts like for event registration services where onboarding friction is extremely costly), then the flow **simply cannot be used** in such contexts.

## Flow Complexity Problems
Seamless registration can already make for complex flows. Currenrly, in my testing, navigating the above issues may already have tired the user out; if the system immediately wants more information to function (like MFA setup), the user might just close the window instead.

Policies related to continuing the login process after restoring from an email token can be error prone alone. Using persistent state to defer ex. MFA Configuration to subsequent logins now results in, together with the token restoration state, at least 4 possible states to handle, and that's before adding more configuration.

With Email OTP, the Email stage simply passes or fails. All flow restoration checks evaporate, and 'prompt on subsequent login' logic is simplified without token restoration cases.

# Implementation Idea
First of all, "OTP" and "Link" could be options in the Email Stage. The Link is still the most appropriate for ex. changing an email, or password resets. Alternatively, a new MFA stage for email OTP, which can likely be almost copied from SMS OTP (which I don't use, so please correct me if I'm wrong!)

When run, it would pick 5 words from a dictionary of ~10,000 short words using `secrets.choice`, seperated by -. This could go through a salted hash and be stored anywhere (ex. in the flow context), along with a timestamp indicating timeout of ~10min - it's not a secret value, and ~66 bits of entropy makes password hashers too heavy. A new template would be designed for sending this.

To the user, the Stage would present a special code field, which it would validate by running the hash and comparing. A "resend" button would simply replace the hash already stored, thereby invalidating it. The validation message could say "only the latest code is valid" if the user clicked resend and the code is wrong (one boolean of internal state).

In sum, this provides easily (human) transferred codes with ~66 bits of entropy, which are resistant to brute force and lookup table attacks. It would fix a significant number of use cases, and enable a few key ones altogether, while involving a relatively small change to one stage.

# Workarounds
If one can send emails in Policies, then one could generate the key, send the email, salted hash and store it (no secret keys in Policies), then go to a Prompt stage.

The Prompt stage would have a field (a not so pretty text field, unfortunately) whose validator checks it against the stored hash.

It's not great; this kind of code feels very iffy to run in Policies. For example, not remembering to use salted hashing _significantly_ weakens the code, and direct comparisons without hashing can directly be cracked with timing attacks. Plus, I'm unsure whether there is any way to send an email (or even a webhook via events) from Policies, such that the SMTP server can validate that Authentik is the sender (or whether there even should be...)

# Conclusion
This became a bit long, hope it's okay! Let me know if this seems like reasonable idea. I'd be very interested in implementing it if there's interest.
If I'm correct, this is possible.

Create a new flow `magic-link-login` with `Designation: Authentication` and add the following stage bindings:
- magic-link-identification
- magic-link-email
- default-authentication-mfa-validation
- default-authentication-login

The `magic-link-identification` stage is an identification stage with `User fields: Username,Email` . The `magic-link-email` stage is an email stage with `Activate pending user on success: False` and a custom email template. For testing, the `Account Confirmation` template can be used. 

Edit the `default-authentication-identification` stage, set `Flow settings -> Passwordless flow: magic-link-login`. 

On the login page, you now see `Use a security key`. Click on the link and enter your username/email. You will be sent an email. Hit the `Confirm Account` button and proceed to log in like always.

The only thing I didn't figure out is if I can change the `Use a security key` to `Passwordless Login` or something else. 

The question is how good the UX is with Android / iOS using different browsers/webviews, dose the link always work? 
Is it always possible to copy and past the link?
@yoo 
> The question is how good the UX is with Android / iOS using different browsers/webviews, dose the link always work?
Is it always possible to copy and past the link?

this is the crucial point. A human readable/writable OTP would fix that. 
Email OTP would enable a much more pleasant onboarding experience for non-techi-users. It would enable 2 factor security as soon as the email address is verified. no further steps that need users to fidget around with phones and install apps. I know its less secure as TOTP, but better as nothing or being forced to play first level support the rest of the day.
I think it would also be a good idea to enable the transfer of an e-mail address from an external system, so that authentik immediately sends or fills in the field with the e-mail address.

![authentik drawio](https://github.com/goauthentik/authentik/assets/1092870/0cc02f85-53f6-43ba-924a-3fb13393a17d)


+1 for this
> I think it would also be a good idea to enable the transfer of an e-mail address from an external system, so that authentik immediately sends or fills in the field with the e-mail address.
> 
> [image]

Its a good idea but maybe we should keep the issue clean. imho, "magic links mails" and "OTP mails" are separate features for slightly different needs. this issue is about OTP mails.
+1 for this. 
It seems to be the standard UX expectation from 'non technical' users
Personally I like webauthn but thats a little harder to explain to an average user.

Possibly a seperate feature,but I should be able to have multiple mfa options. E.g webauthn and email otp enabled and I can select which I'd like to use. Github does this well
this feature seems like a no-brainer and I must say for a product that seems well thought out and given it boldly suggest to replace okta etc.  this basic use case seems to make the product appear to fall short. 
I got a rude awakening on this when I demoed this product potential, only to be asked by a would be user (non techie) that why they cannot just get an email sent with a pin they can login with instead of having to jump around figuring the tech tools they need. 
Now just like some other issues I have seen on here, this feature has been open for almost 2 years now. is there a roadmap for this one can follow. similar to how the invitation stage doesn't offer a mail forward feature out of the box either through api or otherwise.

Commit message: Add email connection settings to the Email authenticator stage configuration UI

Patch to review:
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index 8a8438b19b46..66a9aeee8a0a 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -100,6 +100,7 @@
     "authentik.sources.scim",
     "authentik.stages.authenticator",
     "authentik.stages.authenticator_duo",
+    "authentik.stages.authenticator_email",
     "authentik.stages.authenticator_sms",
     "authentik.stages.authenticator_static",
     "authentik.stages.authenticator_totp",
diff --git a/authentik/stages/authenticator_email/__init__.py b/authentik/stages/authenticator_email/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/authentik/stages/authenticator_email/api.py b/authentik/stages/authenticator_email/api.py
new file mode 100644
index 000000000000..bc69d38ab43b
--- /dev/null
+++ b/authentik/stages/authenticator_email/api.py
@@ -0,0 +1,82 @@
+"""AuthenticatorEmailStage API Views"""
+
+from rest_framework import mixins
+from rest_framework.viewsets import GenericViewSet, ModelViewSet
+
+from authentik.core.api.used_by import UsedByMixin
+from authentik.core.api.utils import ModelSerializer
+from authentik.flows.api.stages import StageSerializer
+from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
+
+
+class AuthenticatorEmailStageSerializer(StageSerializer):
+    """AuthenticatorEmailStage Serializer"""
+
+    class Meta:
+        model = AuthenticatorEmailStage
+        fields = StageSerializer.Meta.fields + [
+            "configure_flow",
+            "friendly_name",
+            "use_global_settings",
+            "host",
+            "port",
+            "username",
+            "password",
+            "use_tls",
+            "use_ssl",
+            "timeout",
+            "from_address",
+            "subject",
+            "token_expiry",
+            "template",
+        ]
+
+
+class AuthenticatorEmailStageViewSet(UsedByMixin, ModelViewSet):
+    """AuthenticatorEmailStage Viewset"""
+
+    queryset = AuthenticatorEmailStage.objects.all()
+    serializer_class = AuthenticatorEmailStageSerializer
+    filterset_fields = "__all__"
+    ordering = ["name"]
+    search_fields = ["name"]
+
+
+class EmailDeviceSerializer(ModelSerializer):
+    """Serializer for email authenticator devices"""
+
+    class Meta:
+        model = EmailDevice
+        fields = ["name", "pk", "email"]
+        depth = 2
+        extra_kwargs = {
+            "email": {"read_only": True},
+        }
+
+
+class EmailDeviceViewSet(
+    mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    mixins.DestroyModelMixin,
+    UsedByMixin,
+    mixins.ListModelMixin,
+    GenericViewSet,
+):
+    """Viewset for email authenticator devices"""
+
+    queryset = EmailDevice.objects.all()
+    serializer_class = EmailDeviceSerializer
+    search_fields = ["name"]
+    filterset_fields = ["name"]
+    ordering = ["name"]
+    owner_field = "user"
+
+
+class EmailAdminDeviceViewSet(ModelViewSet):
+    """Viewset for email authenticator devices (for admins)"""
+
+    queryset = EmailDevice.objects.all()
+    serializer_class = EmailDeviceSerializer
+    search_fields = ["name"]
+    filterset_fields = ["name"]
+    ordering = ["name"]
diff --git a/authentik/stages/authenticator_email/apps.py b/authentik/stages/authenticator_email/apps.py
new file mode 100644
index 000000000000..df9d1d57a351
--- /dev/null
+++ b/authentik/stages/authenticator_email/apps.py
@@ -0,0 +1,11 @@
+"""Email"""
+
+from django.apps import AppConfig
+
+
+class AuthentikStageAuthenticatorEmailConfig(AppConfig):
+    """Email App config"""
+
+    name = "authentik.stages.authenticator_email"
+    label = "authentik_stages_authenticator_email"
+    verbose_name = "authentik Stages.Authenticator.Email"
diff --git a/authentik/stages/authenticator_email/migrations/0001_initial.py b/authentik/stages/authenticator_email/migrations/0001_initial.py
new file mode 100644
index 000000000000..9b2f86ee5ef6
--- /dev/null
+++ b/authentik/stages/authenticator_email/migrations/0001_initial.py
@@ -0,0 +1,133 @@
+# Generated by Django 5.0.10 on 2025-01-10 04:55
+
+import django.db.models.deletion
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ("authentik_flows", "0027_auto_20231028_1424"),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="AuthenticatorEmailStage",
+            fields=[
+                (
+                    "stage_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="authentik_flows.stage",
+                    ),
+                ),
+                ("friendly_name", models.TextField(null=True)),
+                (
+                    "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(blank=True, default="")),
+                ("password", models.TextField(blank=True, default="")),
+                ("use_tls", models.BooleanField(default=False)),
+                ("use_ssl", models.BooleanField(default=False)),
+                ("timeout", models.IntegerField(default=10)),
+                (
+                    "from_address",
+                    models.EmailField(default="system@authentik.local", max_length=254),
+                ),
+                (
+                    "activate_user_on_success",
+                    models.BooleanField(
+                        default=False, help_text="Activate users upon completion of stage."
+                    ),
+                ),
+                (
+                    "token_expiry",
+                    models.IntegerField(
+                        default=30, help_text="Time in minutes the token sent is valid."
+                    ),
+                ),
+                ("subject", models.TextField(default="authentik")),
+                (
+                    "configure_flow",
+                    models.ForeignKey(
+                        blank=True,
+                        help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="authentik_flows.flow",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Email Autherenticator Stage",
+                "verbose_name_plural": "Email Authenticator Stages",
+            },
+            bases=("authentik_flows.stage", models.Model),
+        ),
+        migrations.CreateModel(
+            name="EmailDevice",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("last_updated", models.DateTimeField(auto_now=True)),
+                (
+                    "name",
+                    models.CharField(
+                        help_text="The human-readable name of this device.", max_length=64
+                    ),
+                ),
+                (
+                    "confirmed",
+                    models.BooleanField(default=True, help_text="Is this device ready for use?"),
+                ),
+                ("token", models.CharField(blank=True, max_length=16, null=True)),
+                (
+                    "valid_until",
+                    models.DateTimeField(
+                        default=django.utils.timezone.now,
+                        help_text="The timestamp of the moment of expiry of the saved token.",
+                    ),
+                ),
+                ("email", models.EmailField(max_length=254)),
+                ("last_used", models.DateTimeField(auto_now=True)),
+                (
+                    "stage",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="authentik_stages_authenticator_email.authenticatoremailstage",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Email Device",
+                "verbose_name_plural": "Email Devices",
+                "unique_together": {("user", "email")},
+            },
+        ),
+    ]
diff --git a/authentik/stages/authenticator_email/migrations/0002_authenticatoremailstage_template.py b/authentik/stages/authenticator_email/migrations/0002_authenticatoremailstage_template.py
new file mode 100644
index 000000000000..a6932aa42cbf
--- /dev/null
+++ b/authentik/stages/authenticator_email/migrations/0002_authenticatoremailstage_template.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.10 on 2025-01-19 03:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("authentik_stages_authenticator_email", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="authenticatoremailstage",
+            name="template",
+            field=models.TextField(default="email/email_otp.html"),
+        ),
+    ]
diff --git a/authentik/stages/authenticator_email/migrations/__init__.py b/authentik/stages/authenticator_email/migrations/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/authentik/stages/authenticator_email/models.py b/authentik/stages/authenticator_email/models.py
new file mode 100644
index 000000000000..995caf9d14d9
--- /dev/null
+++ b/authentik/stages/authenticator_email/models.py
@@ -0,0 +1,193 @@
+from os import R_OK, access
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.mail.backends.base import BaseEmailBackend
+from django.core.mail.backends.smtp import EmailBackend
+from django.db import models
+from django.template import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
+from authentik.lib.config import CONFIG
+from authentik.lib.models import SerializerModel
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator.models import SideChannelDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class EmailTemplates(models.TextChoices):
+    """Templates used for rendering the Email"""
+
+    EMAIL_OTP = (
+        "email/email_otp.html",
+        _("Email OTP"),
+    )  # nosec
+
+
+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() or not template_dir.is_dir():
+            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 AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
+    """Use Email-based authentication instead of authenticator-based."""
+
+    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)
+    password = models.TextField(default="", blank=True)
+    use_tls = models.BooleanField(default=False)
+    use_ssl = models.BooleanField(default=False)
+    timeout = models.IntegerField(default=10)
+    from_address = models.EmailField(default="system@authentik.local")
+
+    activate_user_on_success = models.BooleanField(
+        default=False, help_text=_("Activate users upon completion of stage.")
+    )
+
+    token_expiry = models.IntegerField(
+        default=30, help_text=_("Time in minutes the token sent is valid.")
+    )
+    subject = models.TextField(default="authentik")
+    template = models.TextField(default=EmailTemplates.EMAIL_OTP)
+
+    @property
+    def serializer(self) -> type[BaseSerializer]:
+        from authentik.stages.authenticator_email.api import AuthenticatorEmailStageSerializer
+
+        return AuthenticatorEmailStageSerializer
+
+    @property
+    def view(self) -> type[View]:
+        from authentik.stages.authenticator_email.stage import AuthenticatorEmailStageView
+
+        return AuthenticatorEmailStageView
+
+    @property
+    def component(self) -> str:
+        return "ak-stage-authenticator-email-form"
+
+    @property
+    def backend_class(self) -> type[BaseEmailBackend]:
+        """Get the email backend class to use"""
+        return EmailBackend
+
+    @property
+    def backend(self) -> BaseEmailBackend:
+        """Get fully configured Email Backend instance"""
+        if self.use_global_settings:
+            CONFIG.refresh("email.password")
+            return self.backend_class(
+                host=CONFIG.get("email.host"),
+                port=CONFIG.get_int("email.port"),
+                username=CONFIG.get("email.username"),
+                password=CONFIG.get("email.password"),
+                use_tls=CONFIG.get_bool("email.use_tls", False),
+                use_ssl=CONFIG.get_bool("email.use_ssl", False),
+                timeout=CONFIG.get_int("email.timeout"),
+            )
+        return self.backend_class(
+            host=self.host,
+            port=self.port,
+            username=self.username,
+            password=self.password,
+            use_tls=self.use_tls,
+            use_ssl=self.use_ssl,
+            timeout=self.timeout,
+        )
+
+    def send(self, device: "EmailDevice"):
+        # Compose the message using templates
+        message = device._compose_email()
+        # Lazy import here to avoid circular import
+        from authentik.stages.authenticator_email.tasks import send_mails
+
+        return send_mails(device.stage, message)
+
+    def __str__(self):
+        return f"Email Stage {self.name}"
+
+    class Meta:
+        verbose_name = _("Email Autherenticator Stage")
+        verbose_name_plural = _("Email Authenticator Stages")
+
+
+class EmailDevice(SerializerModel, SideChannelDevice):
+    """Email Device"""
+
+    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
+    email = models.EmailField()
+    stage = models.ForeignKey(AuthenticatorEmailStage, on_delete=models.CASCADE)
+    last_used = models.DateTimeField(auto_now=True)
+
+    @property
+    def serializer(self) -> type[BaseSerializer]:
+        from authentik.stages.authenticator_email.api import EmailDeviceSerializer
+
+        return EmailDeviceSerializer
+
+    def _compose_email(self) -> TemplateEmailMessage:
+        try:
+            pending_user = self.user
+            stage = self.stage
+            email = self.email
+
+            message = TemplateEmailMessage(
+                subject=_(stage.subject),
+                from_email=stage.from_address,
+                to=[(pending_user.name, email)],
+                template_name=stage.template,
+                template_context={
+                    "user": pending_user,
+                    "expires": self.valid_until,
+                    "token": self.token,
+                },
+            )
+            return message
+        except TemplateSyntaxError as exc:
+            Event.new(
+                EventAction.CONFIGURATION_ERROR,
+                message=_("Exception occurred while rendering E-mail template"),
+                error=exception_to_string(exc),
+                template=stage.template,
+            ).from_http(self.request)
+            raise StageInvalidException from exc
+
+    def __str__(self):
+        return f"Email Device for {self.user}"
+
+    class Meta:
+        verbose_name = _("Email Device")
+        verbose_name_plural = _("Email Devices")
+        unique_together = (("user", "email"),)
diff --git a/authentik/stages/authenticator_email/stage.py b/authentik/stages/authenticator_email/stage.py
new file mode 100644
index 000000000000..0aa9bc4c4863
--- /dev/null
+++ b/authentik/stages/authenticator_email/stage.py
@@ -0,0 +1,214 @@
+"""Email Setup stage"""
+
+from django.db.models import Q
+from django.http import HttpRequest, HttpResponse
+from django.http.request import QueryDict
+from django.template.exceptions import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from rest_framework.exceptions import ValidationError
+from rest_framework.fields import BooleanField, CharField, IntegerField
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.challenge import (
+    Challenge,
+    ChallengeResponse,
+    WithUserInfoChallenge,
+)
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.stage import ChallengeStageView
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator_email.models import (
+    AuthenticatorEmailStage,
+    EmailDevice,
+)
+from authentik.stages.authenticator_email.tasks import send_mails
+from authentik.stages.email.utils import TemplateEmailMessage
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+LOGGER = get_logger()
+
+
+SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device"
+PLAN_CONTEXT_EMAIL = "email"
+PLAN_CONTEXT_EMAIL_SENT = "email_sent"
+PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
+
+
+class AuthenticatorEmailChallenge(WithUserInfoChallenge):
+    """Authenticator Email Setup challenge"""
+
+    # Set to true if no previous prompt stage set the email
+    # this stage will also check prompt_data.email
+    email = CharField(default=None, allow_blank=True, allow_null=True)
+    email_required = BooleanField(default=True)
+    component = CharField(default="ak-stage-authenticator-email")
+    response_status = CharField(default="pending")
+
+
+class AuthenticatorEmailChallengeResponse(ChallengeResponse):
+    """Authenticator Email Challenge response, device is set by get_response_instance"""
+
+    device: EmailDevice
+
+    code = IntegerField(required=False)
+    email = CharField(required=False)
+
+    component = CharField(default="ak-stage-authenticator-email")
+
+    def validate(self, attrs: dict) -> dict:
+        """Check"""
+        if "code" not in attrs:
+            if "email" not in attrs:
+                raise ValidationError("email required")
+            self.device.email = attrs["email"]
+            self.stage.validate_and_send(attrs["email"])
+            return super().validate(attrs)
+        if not self.device.verify_token(str(attrs["code"])):
+            raise ValidationError(_("Code does not match"))
+        self.device.confirmed = True
+        return super().validate(attrs)
+
+
+class AuthenticatorEmailStageView(ChallengeStageView):
+    """Authenticator Email Setup stage"""
+
+    response_class = AuthenticatorEmailChallengeResponse
+
+    def validate_and_send(self, email: str):
+        """Validate email and send message"""
+        pending_user = self.get_pending_user()
+
+        stage: AuthenticatorEmailStage = self.executor.current_stage
+        if EmailDevice.objects.filter(Q(email=email), stage=stage.pk).exists():
+            raise ValidationError(_("Invalid email"))
+
+        device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
+
+        try:
+            message = TemplateEmailMessage(
+                subject=_(stage.subject),
+                from_email=stage.from_address,
+                to=[(pending_user.name, email)],
+                language=pending_user.locale(self.request),
+                template_name=stage.template,
+                template_context={
+                    "user": pending_user,
+                    "expires": device.valid_until,
+                    "token": device.token,
+                },
+            )
+
+            send_mails(stage, message)
+        except TemplateSyntaxError as exc:
+            Event.new(
+                EventAction.CONFIGURATION_ERROR,
+                message=_("Exception occurred while rendering E-mail template"),
+                error=exception_to_string(exc),
+                template=stage.template,
+            ).from_http(self.request)
+            raise StageInvalidException from exc
+
+    def _has_email(self) -> str | None:
+        context = self.executor.plan.context
+
+        if PLAN_CONTEXT_EMAIL in context.get(PLAN_CONTEXT_PROMPT, {}):
+            self.logger.debug("got email from plan context")
+            return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_EMAIL)
+        if SESSION_KEY_EMAIL_DEVICE in self.request.session:
+            self.logger.debug("got email from device in session")
+            device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
+            if device.email == "":
+                return None
+            return device.email
+        return None
+
+    def mask_email(self, email: str | None) -> str | None:
+        """Mask email address for privacy
+
+        Args:
+            email: Email address to mask
+        Returns:
+            Masked email address or None if input is None
+        Example:
+            self.mask_email("myname@company.org")
+            'm*****@c******.org'
+        """
+        if not email:
+            return None
+
+        local, domain = email.split("@")
+        domain_parts = domain.split(".")
+        limit = 2
+
+        # Mask local part (keep first char)
+        if len(local) <= limit:
+            masked_local = "*" * len(local)
+        else:
+            masked_local = local[0] + "*" * (len(local) - 1)
+
+        # Mask each domain part except the last one (TLD)
+        masked_domain_parts = []
+        for _i, part in enumerate(domain_parts[:-1]):  # Process all parts except TLD
+            if len(part) <= limit:
+                masked_part = "*" * len(part)
+            else:
+                masked_part = part[0] + "*" * (len(part) - 1)
+            masked_domain_parts.append(masked_part)
+
+        # Add TLD unchanged
+        masked_domain_parts.append(domain_parts[-1])
+
+        return f"{masked_local}@{'.'.join(masked_domain_parts)}"
+
+    def get_challenge(self, *args, **kwargs) -> Challenge:
+        email = self._has_email()
+        return AuthenticatorEmailChallenge(
+            data={
+                "email": self.mask_email(email),
+                "email_required": email is None,
+                "responseStatus": "pending",
+            }
+        )
+
+    def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
+        response = super().get_response_instance(data)
+        response.device = self.request.session[SESSION_KEY_EMAIL_DEVICE]
+        return response
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        user = self.get_pending_user()
+
+        stage: AuthenticatorEmailStage = self.executor.current_stage
+
+        if SESSION_KEY_EMAIL_DEVICE not in self.request.session:
+            device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device")
+            valid_secs: int = stage.token_expiry * 60  # token_expiry is in minutes
+            device.generate_token(valid_secs=valid_secs, commit=False)
+            self.request.session[SESSION_KEY_EMAIL_DEVICE] = device
+            if email := self._has_email():
+                device.email = email
+                try:
+                    self.validate_and_send(email)
+                except ValidationError as exc:
+                    # We had an email given already (at this point only possible from flow
+                    # context), but an error occurred while sending (most likely)
+                    # due to a duplicate device, so delete the email we got given, reset the state
+                    # (ish) and retry
+                    device.email = ""
+                    self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
+                        PLAN_CONTEXT_EMAIL, None
+                    )
+                    self.request.session.pop(SESSION_KEY_EMAIL_DEVICE, None)
+                    self.logger.warning("failed to send email to pre-set address", exc=exc)
+                    return self.get(request, *args, **kwargs)
+        return super().get(request, *args, **kwargs)
+
+    def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
+        """Email Token is validated by challenge"""
+        device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
+        if not device.confirmed:
+            return self.challenge_invalid(response)
+        device.save()
+        del self.request.session[SESSION_KEY_EMAIL_DEVICE]
+        return self.executor.stage_ok()
diff --git a/authentik/stages/authenticator_email/tasks.py b/authentik/stages/authenticator_email/tasks.py
new file mode 100644
index 000000000000..25f5dd13aaf9
--- /dev/null
+++ b/authentik/stages/authenticator_email/tasks.py
@@ -0,0 +1,105 @@
+"""email stage tasks"""
+
+from email.utils import make_msgid
+from smtplib import SMTPException
+from typing import Any
+
+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.events.models import Event, EventAction, TaskStatus
+from authentik.events.system_tasks import SystemTask
+from authentik.root.celery import CELERY_APP
+from authentik.stages.authenticator_email.models import AuthenticatorEmailStage
+from authentik.stages.email.utils import logo_data
+
+LOGGER = get_logger()
+
+
+def send_mails(stage: AuthenticatorEmailStage, *messages: list[EmailMultiAlternatives]):
+    """Wrapper to convert EmailMessage to dict and send it from worker"""
+    tasks = []
+    for message in messages:
+        tasks.append(send_mail.s(message.__dict__, str(stage.pk)))
+    lazy_group = group(*tasks)
+    promise = lazy_group()
+    return promise
+
+
+def get_email_body(email: EmailMultiAlternatives) -> str:
+    """Get the email's body. Will return HTML alt if set, otherwise plain text body"""
+    for alt_content, alt_type in email.alternatives:
+        if alt_type == "text/html":
+            return alt_content
+    return email.body
+
+
+@CELERY_APP.task(
+    bind=True,
+    autoretry_for=(
+        SMTPException,
+        ConnectionError,
+        OSError,
+    ),
+    retry_backoff=True,
+    base=SystemTask,
+)
+def send_mail(self: SystemTask, message: dict[Any, Any], email_stage_pk: str | None = None):
+    """Send Email for Email Stage. Retries are scheduled automatically."""
+    self.save_on_success = False
+    message_id = make_msgid(domain=DNS_NAME)
+    self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
+    try:
+        if not email_stage_pk:
+            stage: AuthenticatorEmailStage = AuthenticatorEmailStage(use_global_settings=True)
+        else:
+            stages = AuthenticatorEmailStage.objects.filter(pk=email_stage_pk)
+            if not stages.exists():
+                self.set_status(
+                    TaskStatus.WARNING,
+                    f"AuthenticatorEmail stage does not exist anymore. Discarding message {stages}",
+                )
+                return
+            stage: AuthenticatorEmailStage = stages.first()
+        try:
+            backend = stage.backend
+        except ValueError as exc:
+            LOGGER.warning("failed to get email backend", exc=exc)
+            self.set_error(exc)
+            return
+        backend.open()
+        # Since django's EmailMessage objects are not JSON serialisable,
+        # we need to rebuild them from a dict
+        message_object = EmailMultiAlternatives()
+        for key, value in message.items():
+            setattr(message_object, key, value)
+        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
+
+        # Add the logo (we can't add it in the previous message since MIMEImage
+        # can't be converted to json)
+        message_object.attach(logo_data())
+
+        LOGGER.debug("Sending mail!!!!", to=message_object.to)
+        backend.send_messages([message_object])
+        Event.new(
+            EventAction.EMAIL_SENT,
+            message=f"Email to {', '.join(message_object.to)} sent",
+            subject=message_object.subject,
+            body=get_email_body(message_object),
+            from_email=message_object.from_email,
+            to_email=message_object.to,
+        ).save()
+        self.set_status(
+            TaskStatus.SUCCESSFUL,
+            "Successfully sent Mail.",
+        )
+    except (SMTPException, ConnectionError, OSError) as exc:
+        LOGGER.debug("Error sending email, retrying...", exc=exc)
+        self.set_error(exc)
+        raise exc
diff --git a/authentik/stages/authenticator_email/templates/email/base.html b/authentik/stages/authenticator_email/templates/email/base.html
new file mode 100644
index 000000000000..08a60107a035
--- /dev/null
+++ b/authentik/stages/authenticator_email/templates/email/base.html
@@ -0,0 +1,131 @@
+{% load authentik_stages_email %}
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtm=l">
+  <head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+    <meta name="viewport" content="width=device-width">
+
+    <style type="text/css">
+      body {
+        font-family: Arial, sans-serif;
+        font-size: 14px;
+        color: #212124;
+      }
+
+      h2 {
+        display: inline-block;
+        font-family: Arial, sans-serif;
+        font-size: 28px;
+        line-height: 125%;
+        font-weight: 700;
+        padding-top: 10px;
+        padding-bottom: 10px;
+        margin: 0;
+      }
+
+      .flexibleImage {
+        height: auto;
+      }
+
+      img.logo {
+        max-width: 100%;
+        max-height: 35px;
+      }
+
+      .properties-table {
+        width: 100%;
+        text-align: left;
+        font-size: 14px;
+        font-weight: 400;
+        font-family: Arial, sans-serif;
+        border-collapse: collapse;
+      }
+
+      .properties-table tr:first-child {
+        border-top: 1px solid rgba(196, 196, 196, 0.2);
+      }
+
+      .properties-table tr:first-child>td {
+        padding-top: 24px;
+      }
+
+      .properties-table tr:last-child {
+        border-bottom: 1px solid rgba(196, 196, 196, 0.2);
+      }
+
+      .properties-table tr:last-child>td {
+        padding-bottom: 24px;
+      }
+
+      .properties-table td {
+        line-height: 24px;
+        vertical-align: top;
+        padding: 4px 15px;
+      }
+
+      .td-right {
+        text-align: right;
+        white-space: nowrap;
+      }
+      .btn-primary {
+        text-decoration: none;
+        color: #FFF;
+        background-color: #348eda;
+        border: solid #348eda;
+        width: 100%;
+        line-height: 2em;
+        font-weight: bold;
+        text-align: center;
+        cursor: pointer;
+        display: inline-block;
+        text-transform: capitalize;
+      }
+      .btn-primary a {
+        color: #fff;
+      }
+    </style>
+  </head>
+
+  <body>
+    <div class="wrapper">
+      <center>
+        <div style="-ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; table-layout: fixed; width: 100%; max-width: 448px; padding: 60px 20px; font-size: 14px;">
+          <table border="0" align="center" width="100%">
+            <tr>
+              <td style="padding: 20px;border: 1px solid #c1c1c1;">
+                <table width="100%" style="background-color: #FFFFFF; border-spacing: 0; margin-top: 15px;">
+                  <tr height="80">
+                    <td align="center" style="padding: 20px 0;">
+                      <img src="{% block logo_url %}cid:logo.png{% endblock %}" border="0=" alt="authentik logo" class="flexibleImage logo">
+                    </td>
+                  </tr>
+                  {% block content %}
+                  {% endblock %}
+                </table>
+              </td>
+            </tr>
+            <tr>
+              <td>
+                <table border="0" style="margin-top: 10px;" width="100%">
+                  <tr>
+                    <td style="background: #FAFBFB;">
+                      <table style="width: 100%;">
+                        {% block sub_content %}
+                        {% endblock %}
+                      </table>
+                    </td>
+                  </tr>
+                </table>
+              </td>
+            </tr>
+            <tr>
+              <td align="center">
+                Powered by <a rel="noopener noreferrer" target="_blank" href="https://goauthentik.io?utm_source=authentik&utm_medium=email">authentik</a>.
+              </td>
+            </tr>
+          </table>
+        </div>
+      </center>
+    </div>
+  </body>
+</html>
diff --git a/authentik/stages/authenticator_email/templates/email/email_otp.html b/authentik/stages/authenticator_email/templates/email/email_otp.html
new file mode 100644
index 000000000000..18688d145e9e
--- /dev/null
+++ b/authentik/stages/authenticator_email/templates/email/email_otp.html
@@ -0,0 +1,44 @@
+{% extends "email/base.html" %}
+
+{% load i18n %}
+{% load humanize %}
+
+{% block content %}
+<tr>
+  <td align="center">
+    <h1>
+      {% blocktrans with username=user.username %}
+      Hi {{ username }},
+      {% endblocktrans %}
+    </h1>
+  </td>
+</tr>
+<tr>
+  <td align="center">
+    <table border="0">
+      <tr>
+        <td align="center" style="max-width: 300px; padding: 20px 0; color: #212124;">
+          {% blocktrans %}
+          Email MFA code.
+          {% endblocktrans %}
+        </td>
+      </tr>
+      <tr>
+        <td align="center" class="btn btn-primary">
+          {{ token }}
+        </td>
+      </tr>
+    </table>
+  </td>
+</tr>
+{% endblock %}
+
+{% block sub_content %}
+<tr>
+  <td style="padding: 20px; font-size: 12px; color: #212124;" align="center">
+    {% blocktrans with expires=expires|timeuntil %}
+    If you did not request this code, please ignore this email. The code above is valid for {{ expires }}.
+    {% endblocktrans %}
+  </td>
+</tr>
+{% endblock %}
diff --git a/authentik/stages/authenticator_email/templates/email/email_otp.txt b/authentik/stages/authenticator_email/templates/email/email_otp.txt
new file mode 100644
index 000000000000..7a884d0b2e49
--- /dev/null
+++ b/authentik/stages/authenticator_email/templates/email/email_otp.txt
@@ -0,0 +1,13 @@
+{% load i18n %}{% load humanize %}{% autoescape off %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
+
+{% blocktrans %}
+Email MFA code
+{% endblocktrans %}
+{{ token }}
+{% blocktrans with expires=expires|timeuntil %}
+If you did not request this code, please ignore this email. The code above is valid for {{ expires }}.
+{% endblocktrans %}
+
+--
+Powered by goauthentik.io.
+{% endautoescape %}
diff --git a/authentik/stages/authenticator_email/tests.py b/authentik/stages/authenticator_email/tests.py
new file mode 100644
index 000000000000..fca4d04c9875
--- /dev/null
+++ b/authentik/stages/authenticator_email/tests.py
@@ -0,0 +1,139 @@
+"""Test Email Authenticator API"""
+
+from unittest.mock import patch
+
+from django.core import mail
+from django.core.mail.backends.locmem import EmailBackend
+from django.urls import reverse
+from django.utils.timezone import now
+from structlog.stdlib import get_logger
+
+from authentik.core.tests.utils import create_test_admin_user, create_test_flow
+from authentik.flows.models import FlowStageBinding
+from authentik.flows.tests import FlowTestCase
+from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class TestAuthenticatorEmailStage(FlowTestCase):
+    """Test Email Authenticator stage"""
+
+    def setUp(self):
+        super().setUp()
+        self.flow = create_test_flow()
+        self.user = create_test_admin_user()
+        self.stage = AuthenticatorEmailStage.objects.create(
+            name="email-authenticator",
+            use_global_settings=True,
+            from_address="test@authentik.local",
+            configure_flow=self.flow,
+        )
+        self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
+        self.device = EmailDevice.objects.create(
+            user=self.user,
+            stage=self.stage,
+            email="test@authentik.local",
+        )
+        self.client.force_login(self.user)
+
+    def test_device_str(self):
+        """Test string representation of device"""
+        self.assertEqual(str(self.device), f"Email Device for {self.user}")
+
+    def test_stage_str(self):
+        """Test string representation of stage"""
+        self.assertEqual(str(self.stage), f"Email Stage {self.stage.name}")
+
+    def test_token_lifecycle(self):
+        """Test token generation, validation and expiry"""
+        # Initially no token
+        self.assertIsNone(self.device.token)
+
+        # Generate token
+        self.device.generate_token()
+        token = self.device.token
+        self.assertIsNotNone(token)
+        self.assertIsNotNone(self.device.valid_until)
+        self.assertTrue(self.device.valid_until > now())
+
+        # Verify invalid token
+        self.assertFalse(self.device.verify_token("000000"))
+
+        # Verify correct token (should clear token after verification)
+        self.assertTrue(self.device.verify_token(token))
+        self.assertIsNone(self.device.token)
+
+    def test_stage_no_prefill(self):
+        """Test stage without prefilled email"""
+        response = self.client.get(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+        )
+        self.assertStageResponse(
+            response,
+            self.flow,
+            self.user,
+            component="ak-stage-authenticator-email",
+            email_required=True,
+        )
+
+    def test_stage_submit(self):
+        """Test stage email submission"""
+        # Initialize the flow
+        response = self.client.get(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+        )
+        self.assertStageResponse(
+            response,
+            self.flow,
+            self.user,
+            component="ak-stage-authenticator-email",
+            email_required=True,
+        )
+
+        # Test email submission with locmem backend
+        def mock_send_mails(stage, *messages):
+            """Mock send_mails to send directly"""
+            for message in messages:
+                message.send()
+
+        with (
+            patch(
+                "authentik.stages.authenticator_email.models.AuthenticatorEmailStage.backend_class",
+                return_value=EmailBackend,
+            ),
+            patch(
+                "authentik.stages.authenticator_email.stage.send_mails",
+                side_effect=mock_send_mails,
+            ),
+        ):
+            response = self.client.post(
+                reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+                data={"component": "ak-stage-authenticator-email", "email": "test@example.com"},
+            )
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(len(mail.outbox), 1)
+            sent_mail = mail.outbox[0]
+            self.assertEqual(sent_mail.subject, self.stage.subject)
+            self.assertEqual(sent_mail.from_email, self.stage.from_address)
+            self.assertEqual(sent_mail.to, [f"{self.user} <test@example.com>"])
+
+        self.assertStageResponse(
+            response,
+            self.flow,
+            self.user,
+            component="ak-stage-authenticator-email",
+            response_errors={},
+            email_required=False,
+        )
+
+    def test_email_template(self):
+        """Test email template rendering"""
+        self.device.generate_token()
+        message = self.device._compose_email()
+
+        self.assertIsInstance(message, TemplateEmailMessage)
+        self.assertEqual(message.subject, self.stage.subject)
+        self.assertEqual(message.to, [f"{self.user.name} <{self.device.email}>"])
+        self.assertTrue(self.device.token in message.body)
diff --git a/authentik/stages/authenticator_email/urls.py b/authentik/stages/authenticator_email/urls.py
new file mode 100644
index 000000000000..6d93234991eb
--- /dev/null
+++ b/authentik/stages/authenticator_email/urls.py
@@ -0,0 +1,17 @@
+"""API URLs"""
+
+from authentik.stages.authenticator_email.api import (
+    AuthenticatorEmailStageViewSet,
+    EmailAdminDeviceViewSet,
+    EmailDeviceViewSet,
+)
+
+api_urlpatterns = [
+    ("authenticators/email", EmailDeviceViewSet),
+    (
+        "authenticators/admin/email",
+        EmailAdminDeviceViewSet,
+        "admin-emaildevice",
+    ),
+    ("stages/authenticator/email", AuthenticatorEmailStageViewSet),
+]
diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py
index 1f9a656a383d..421f4380b4d3 100644
--- a/authentik/stages/authenticator_validate/challenge.py
+++ b/authentik/stages/authenticator_validate/challenge.py
@@ -30,6 +30,7 @@
 from authentik.stages.authenticator import match_token
 from authentik.stages.authenticator.models import Device
 from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
+from authentik.stages.authenticator_email.models import EmailDevice
 from authentik.stages.authenticator_sms.models import SMSDevice
 from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
 from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
@@ -103,6 +104,8 @@ def select_challenge(request: HttpRequest, device: Device):
     """Callback when the user selected a challenge in the frontend."""
     if isinstance(device, SMSDevice):
         select_challenge_sms(request, device)
+    elif isinstance(device, EmailDevice):
+        select_challenge_email(request, device)
 
 
 def select_challenge_sms(request: HttpRequest, device: SMSDevice):
@@ -111,6 +114,12 @@ def select_challenge_sms(request: HttpRequest, device: SMSDevice):
     device.stage.send(device.token, device)
 
 
+def select_challenge_email(request: HttpRequest, device: EmailDevice):
+    """Send Email"""
+    device.generate_token()
+    device.stage.send(device)
+
+
 def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Device:
     """Validate code-based challenges. We test against every device, on purpose, as
     the user mustn't choose between totp and static devices."""
diff --git a/authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py b/authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py
new file mode 100644
index 000000000000..f9f931ba6b59
--- /dev/null
+++ b/authentik/stages/authenticator_validate/migrations/0014_alter_authenticatorvalidatestage_device_classes.py
@@ -0,0 +1,37 @@
+# Generated by Django 5.0.10 on 2025-01-16 02:48
+
+import authentik.stages.authenticator_validate.models
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        (
+            "authentik_stages_authenticator_validate",
+            "0013_authenticatorvalidatestage_webauthn_allowed_device_types",
+        ),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="authenticatorvalidatestage",
+            name="device_classes",
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.TextField(
+                    choices=[
+                        ("static", "Static"),
+                        ("totp", "TOTP"),
+                        ("webauthn", "WebAuthn"),
+                        ("duo", "Duo"),
+                        ("sms", "SMS"),
+                        ("email", "Email"),
+                    ]
+                ),
+                default=authentik.stages.authenticator_validate.models.default_device_classes,
+                help_text="Device classes which can be used to authenticate",
+                size=None,
+            ),
+        ),
+    ]
diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py
index b0c613368549..5f53ade295ae 100644
--- a/authentik/stages/authenticator_validate/models.py
+++ b/authentik/stages/authenticator_validate/models.py
@@ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices):
     WEBAUTHN = "webauthn", _("WebAuthn")
     DUO = "duo", _("Duo")
     SMS = "sms", _("SMS")
+    EMAIL = "email", _("Email")
 
 
 def default_device_classes() -> list:
@@ -30,6 +31,7 @@ def default_device_classes() -> list:
         DeviceClasses.WEBAUTHN,
         DeviceClasses.DUO,
         DeviceClasses.SMS,
+        DeviceClasses.EMAIL,
     ]
 
 
diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py
index 01d69b4386d1..aa5c14a2b596 100644
--- a/authentik/stages/authenticator_validate/stage.py
+++ b/authentik/stages/authenticator_validate/stage.py
@@ -23,6 +23,7 @@
 from authentik.lib.utils.time import timedelta_from_string
 from authentik.stages.authenticator import devices_for_user
 from authentik.stages.authenticator.models import Device
+from authentik.stages.authenticator_email.models import EmailDevice
 from authentik.stages.authenticator_sms.models import SMSDevice
 from authentik.stages.authenticator_validate.challenge import (
     DeviceChallenge,
@@ -84,7 +85,9 @@ def _challenge_allowed(self, classes: list):
 
     def validate_code(self, code: str) -> str:
         """Validate code-based response, raise error if code isn't allowed"""
-        self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS])
+        self._challenge_allowed(
+            [DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS, DeviceClasses.EMAIL]
+        )
         self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user())
         return code
 
@@ -117,12 +120,17 @@ def validate_selected_challenge(self, challenge: dict) -> dict:
         if not allowed:
             raise ValidationError("invalid challenge selected")
 
-        if challenge.get("device_class", "") != "sms":
-            return challenge
-        devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
-        if not devices.exists():
-            raise ValidationError("invalid challenge selected")
-        select_challenge(self.stage.request, devices.first())
+        device_class = challenge.get("device_class", "")
+        if device_class == "sms":
+            devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
+            if not devices.exists():
+                raise ValidationError("invalid challenge selected")
+            select_challenge(self.stage.request, devices.first())
+        elif device_class == "email":
+            devices = EmailDevice.objects.filter(pk=int(challenge.get("device_uid", "0")))
+            if not devices.exists():
+                raise ValidationError("invalid challenge selected")
+            select_challenge(self.stage.request, devices.first())
         return challenge
 
     def validate_selected_stage(self, stage_pk: str) -> str:
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 8fd4002d447e..46916144a4d1 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -1961,6 +1961,86 @@
                             }
                         }
                     },
+                    {
+                        "type": "object",
+                        "required": [
+                            "model",
+                            "identifiers"
+                        ],
+                        "properties": {
+                            "model": {
+                                "const": "authentik_stages_authenticator_email.authenticatoremailstage"
+                            },
+                            "id": {
+                                "type": "string"
+                            },
+                            "state": {
+                                "type": "string",
+                                "enum": [
+                                    "absent",
+                                    "present",
+                                    "created",
+                                    "must_created"
+                                ],
+                                "default": "present"
+                            },
+                            "conditions": {
+                                "type": "array",
+                                "items": {
+                                    "type": "boolean"
+                                }
+                            },
+                            "permissions": {
+                                "$ref": "#/$defs/model_authentik_stages_authenticator_email.authenticatoremailstage_permissions"
+                            },
+                            "attrs": {
+                                "$ref": "#/$defs/model_authentik_stages_authenticator_email.authenticatoremailstage"
+                            },
+                            "identifiers": {
+                                "$ref": "#/$defs/model_authentik_stages_authenticator_email.authenticatoremailstage"
+                            }
+                        }
+                    },
+                    {
+                        "type": "object",
+                        "required": [
+                            "model",
+                            "identifiers"
+                        ],
+                        "properties": {
+                            "model": {
+                                "const": "authentik_stages_authenticator_email.emaildevice"
+                            },
+                            "id": {
+                                "type": "string"
+                            },
+                            "state": {
+                                "type": "string",
+                                "enum": [
+                                    "absent",
+                                    "present",
+                                    "created",
+                                    "must_created"
+                                ],
+                                "default": "present"
+                            },
+                            "conditions": {
+                                "type": "array",
+                                "items": {
+                                    "type": "boolean"
+                                }
+                            },
+                            "permissions": {
+                                "$ref": "#/$defs/model_authentik_stages_authenticator_email.emaildevice_permissions"
+                            },
+                            "attrs": {
+                                "$ref": "#/$defs/model_authentik_stages_authenticator_email.emaildevice"
+                            },
+                            "identifiers": {
+                                "$ref": "#/$defs/model_authentik_stages_authenticator_email.emaildevice"
+                            }
+                        }
+                    },
                     {
                         "type": "object",
                         "required": [
@@ -4556,6 +4636,7 @@
                         "authentik.sources.scim",
                         "authentik.stages.authenticator",
                         "authentik.stages.authenticator_duo",
+                        "authentik.stages.authenticator_email",
                         "authentik.stages.authenticator_sms",
                         "authentik.stages.authenticator_static",
                         "authentik.stages.authenticator_totp",
@@ -4645,6 +4726,8 @@
                         "authentik_sources_scim.scimsourcepropertymapping",
                         "authentik_stages_authenticator_duo.authenticatorduostage",
                         "authentik_stages_authenticator_duo.duodevice",
+                        "authentik_stages_authenticator_email.authenticatoremailstage",
+                        "authentik_stages_authenticator_email.emaildevice",
                         "authentik_stages_authenticator_sms.authenticatorsmsstage",
                         "authentik_stages_authenticator_sms.smsdevice",
                         "authentik_stages_authenticator_static.authenticatorstaticstage",
@@ -6793,6 +6876,14 @@
                             "authentik_stages_authenticator_duo.delete_duodevice",
                             "authentik_stages_authenticator_duo.view_authenticatorduostage",
                             "authentik_stages_authenticator_duo.view_duodevice",
+                            "authentik_stages_authenticator_email.add_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.add_emaildevice",
+                            "authentik_stages_authenticator_email.change_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.change_emaildevice",
+                            "authentik_stages_authenticator_email.delete_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.delete_emaildevice",
+                            "authentik_stages_authenticator_email.view_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.view_emaildevice",
                             "authentik_stages_authenticator_endpoint_gdtc.add_authenticatorendpointgdtcstage",
                             "authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice",
                             "authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection",
@@ -8918,6 +9009,240 @@
                 }
             }
         },
+        "model_authentik_stages_authenticator_email.authenticatoremailstage": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string",
+                    "minLength": 1,
+                    "title": "Name"
+                },
+                "flow_set": {
+                    "type": "array",
+                    "items": {
+                        "type": "object",
+                        "properties": {
+                            "name": {
+                                "type": "string",
+                                "minLength": 1,
+                                "title": "Name"
+                            },
+                            "slug": {
+                                "type": "string",
+                                "maxLength": 50,
+                                "minLength": 1,
+                                "pattern": "^[-a-zA-Z0-9_]+$",
+                                "title": "Slug",
+                                "description": "Visible in the URL."
+                            },
+                            "title": {
+                                "type": "string",
+                                "minLength": 1,
+                                "title": "Title",
+                                "description": "Shown as the Title in Flow pages."
+                            },
+                            "designation": {
+                                "type": "string",
+                                "enum": [
+                                    "authentication",
+                                    "authorization",
+                                    "invalidation",
+                                    "enrollment",
+                                    "unenrollment",
+                                    "recovery",
+                                    "stage_configuration"
+                                ],
+                                "title": "Designation",
+                                "description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
+                            },
+                            "policy_engine_mode": {
+                                "type": "string",
+                                "enum": [
+                                    "all",
+                                    "any"
+                                ],
+                                "title": "Policy engine mode"
+                            },
+                            "compatibility_mode": {
+                                "type": "boolean",
+                                "title": "Compatibility mode",
+                                "description": "Enable compatibility mode, increases compatibility with password managers on mobile devices."
+                            },
+                            "layout": {
+                                "type": "string",
+                                "enum": [
+                                    "stacked",
+                                    "content_left",
+                                    "content_right",
+                                    "sidebar_left",
+                                    "sidebar_right"
+                                ],
+                                "title": "Layout"
+                            },
+                            "denied_action": {
+                                "type": "string",
+                                "enum": [
+                                    "message_continue",
+                                    "message",
+                                    "continue"
+                                ],
+                                "title": "Denied action",
+                                "description": "Configure what should happen when a flow denies access to a user."
+                            }
+                        },
+                        "required": [
+                            "name",
+                            "slug",
+                            "title",
+                            "designation"
+                        ]
+                    },
+                    "title": "Flow set"
+                },
+                "configure_flow": {
+                    "type": "string",
+                    "format": "uuid",
+                    "title": "Configure flow",
+                    "description": "Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage."
+                },
+                "friendly_name": {
+                    "type": [
+                        "string",
+                        "null"
+                    ],
+                    "minLength": 1,
+                    "title": "Friendly name"
+                },
+                "use_global_settings": {
+                    "type": "boolean",
+                    "title": "Use global settings",
+                    "description": "When enabled, global Email connection settings will be used and connection settings below will be ignored."
+                },
+                "host": {
+                    "type": "string",
+                    "minLength": 1,
+                    "title": "Host"
+                },
+                "port": {
+                    "type": "integer",
+                    "minimum": -2147483648,
+                    "maximum": 2147483647,
+                    "title": "Port"
+                },
+                "username": {
+                    "type": "string",
+                    "title": "Username"
+                },
+                "password": {
+                    "type": "string",
+                    "title": "Password"
+                },
+                "use_tls": {
+                    "type": "boolean",
+                    "title": "Use tls"
+                },
+                "use_ssl": {
+                    "type": "boolean",
+                    "title": "Use ssl"
+                },
+                "timeout": {
+                    "type": "integer",
+                    "minimum": -2147483648,
+                    "maximum": 2147483647,
+                    "title": "Timeout"
+                },
+                "from_address": {
+                    "type": "string",
+                    "format": "email",
+                    "maxLength": 254,
+                    "minLength": 1,
+                    "title": "From address"
+                },
+                "subject": {
+                    "type": "string",
+                    "minLength": 1,
+                    "title": "Subject"
+                },
+                "token_expiry": {
+                    "type": "integer",
+                    "minimum": -2147483648,
+                    "maximum": 2147483647,
+                    "title": "Token expiry",
+                    "description": "Time in minutes the token sent is valid."
+                },
+                "template": {
+                    "type": "string",
+                    "minLength": 1,
+                    "title": "Template"
+                }
+            },
+            "required": []
+        },
+        "model_authentik_stages_authenticator_email.authenticatoremailstage_permissions": {
+            "type": "array",
+            "items": {
+                "type": "object",
+                "required": [
+                    "permission"
+                ],
+                "properties": {
+                    "permission": {
+                        "type": "string",
+                        "enum": [
+                            "add_authenticatoremailstage",
+                            "change_authenticatoremailstage",
+                            "delete_authenticatoremailstage",
+                            "view_authenticatoremailstage"
+                        ]
+                    },
+                    "user": {
+                        "type": "integer"
+                    },
+                    "role": {
+                        "type": "string"
+                    }
+                }
+            }
+        },
+        "model_authentik_stages_authenticator_email.emaildevice": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string",
+                    "maxLength": 64,
+                    "minLength": 1,
+                    "title": "Name",
+                    "description": "The human-readable name of this device."
+                }
+            },
+            "required": []
+        },
+        "model_authentik_stages_authenticator_email.emaildevice_permissions": {
+            "type": "array",
+            "items": {
+                "type": "object",
+                "required": [
+                    "permission"
+                ],
+                "properties": {
+                    "permission": {
+                        "type": "string",
+                        "enum": [
+                            "add_emaildevice",
+                            "change_emaildevice",
+                            "delete_emaildevice",
+                            "view_emaildevice"
+                        ]
+                    },
+                    "user": {
+                        "type": "integer"
+                    },
+                    "role": {
+                        "type": "string"
+                    }
+                }
+            }
+        },
         "model_authentik_stages_authenticator_sms.authenticatorsmsstage": {
             "type": "object",
             "properties": {
@@ -9607,7 +9932,8 @@
                             "totp",
                             "webauthn",
                             "duo",
-                            "sms"
+                            "sms",
+                            "email"
                         ],
                         "title": "Device classes"
                     },
@@ -13042,6 +13368,14 @@
                             "authentik_stages_authenticator_duo.delete_duodevice",
                             "authentik_stages_authenticator_duo.view_authenticatorduostage",
                             "authentik_stages_authenticator_duo.view_duodevice",
+                            "authentik_stages_authenticator_email.add_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.add_emaildevice",
+                            "authentik_stages_authenticator_email.change_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.change_emaildevice",
+                            "authentik_stages_authenticator_email.delete_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.delete_emaildevice",
+                            "authentik_stages_authenticator_email.view_authenticatoremailstage",
+                            "authentik_stages_authenticator_email.view_emaildevice",
                             "authentik_stages_authenticator_endpoint_gdtc.add_authenticatorendpointgdtcstage",
                             "authentik_stages_authenticator_endpoint_gdtc.add_endpointdevice",
                             "authentik_stages_authenticator_endpoint_gdtc.add_endpointdeviceconnection",
diff --git a/schema.yml b/schema.yml
index fbf74d3c91a9..403ecee38c3b 100644
--- a/schema.yml
+++ b/schema.yml
@@ -638,6 +638,234 @@ paths:
               schema:
                 $ref: '#/components/schemas/GenericError'
           description: ''
+  /authenticators/admin/email/:
+    get:
+      operationId: authenticators_admin_email_list
+      description: Viewset for email authenticator devices (for admins)
+      parameters:
+      - in: query
+        name: name
+        schema:
+          type: string
+      - name: ordering
+        required: false
+        in: query
+        description: Which field to use when ordering the results.
+        schema:
+          type: string
+      - name: page
+        required: false
+        in: query
+        description: A page number within the paginated result set.
+        schema:
+          type: integer
+      - name: page_size
+        required: false
+        in: query
+        description: Number of results to return per page.
+        schema:
+          type: integer
+      - name: search
+        required: false
+        in: query
+        description: A search term.
+        schema:
+          type: string
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PaginatedEmailDeviceList'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    post:
+      operationId: authenticators_admin_email_create
+      description: Viewset for email authenticator devices (for admins)
+      tags:
+      - authenticators
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/EmailDeviceRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '201':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /authenticators/admin/email/{id}/:
+    get:
+      operationId: authenticators_admin_email_retrieve
+      description: Viewset for email authenticator devices (for admins)
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    put:
+      operationId: authenticators_admin_email_update
+      description: Viewset for email authenticator devices (for admins)
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/EmailDeviceRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    patch:
+      operationId: authenticators_admin_email_partial_update
+      description: Viewset for email authenticator devices (for admins)
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/PatchedEmailDeviceRequest'
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    delete:
+      operationId: authenticators_admin_email_destroy
+      description: Viewset for email authenticator devices (for admins)
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '204':
+          description: No response body
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
   /authenticators/admin/endpoint/:
     get:
       operationId: authenticators_admin_endpoint_list
@@ -2043,6 +2271,238 @@ paths:
               schema:
                 $ref: '#/components/schemas/GenericError'
           description: ''
+  /authenticators/email/:
+    get:
+      operationId: authenticators_email_list
+      description: Viewset for email authenticator devices
+      parameters:
+      - in: query
+        name: name
+        schema:
+          type: string
+      - name: ordering
+        required: false
+        in: query
+        description: Which field to use when ordering the results.
+        schema:
+          type: string
+      - name: page
+        required: false
+        in: query
+        description: A page number within the paginated result set.
+        schema:
+          type: integer
+      - name: page_size
+        required: false
+        in: query
+        description: Number of results to return per page.
+        schema:
+          type: integer
+      - name: search
+        required: false
+        in: query
+        description: A search term.
+        schema:
+          type: string
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PaginatedEmailDeviceList'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /authenticators/email/{id}/:
+    get:
+      operationId: authenticators_email_retrieve
+      description: Viewset for email authenticator devices
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    put:
+      operationId: authenticators_email_update
+      description: Viewset for email authenticator devices
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/EmailDeviceRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    patch:
+      operationId: authenticators_email_partial_update
+      description: Viewset for email authenticator devices
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/PatchedEmailDeviceRequest'
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/EmailDevice'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    delete:
+      operationId: authenticators_email_destroy
+      description: Viewset for email authenticator devices
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '204':
+          description: No response body
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /authenticators/email/{id}/used_by/:
+    get:
+      operationId: authenticators_email_used_by_list
+      description: Get a list of all objects that use this object
+      parameters:
+      - in: path
+        name: id
+        schema:
+          type: integer
+        description: A unique integer value identifying this Email Device.
+        required: true
+      tags:
+      - authenticators
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/UsedBy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
   /authenticators/endpoint/:
     get:
       operationId: authenticators_endpoint_list
@@ -23653,6 +24113,8 @@ paths:
           - authentik_sources_scim.scimsourcepropertymapping
           - authentik_stages_authenticator_duo.authenticatorduostage
           - authentik_stages_authenticator_duo.duodevice
+          - authentik_stages_authenticator_email.authenticatoremailstage
+          - authentik_stages_authenticator_email.emaildevice
           - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage
           - authentik_stages_authenticator_sms.authenticatorsmsstage
           - authentik_stages_authenticator_sms.smsdevice
@@ -23894,6 +24356,8 @@ paths:
           - authentik_sources_scim.scimsourcepropertymapping
           - authentik_stages_authenticator_duo.authenticatorduostage
           - authentik_stages_authenticator_duo.duodevice
+          - authentik_stages_authenticator_email.authenticatoremailstage
+          - authentik_stages_authenticator_email.emaildevice
           - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage
           - authentik_stages_authenticator_sms.authenticatorsmsstage
           - authentik_stages_authenticator_sms.smsdevice
@@ -30906,6 +31370,341 @@ paths:
               schema:
                 $ref: '#/components/schemas/GenericError'
           description: ''
+  /stages/authenticator/email/:
+    get:
+      operationId: stages_authenticator_email_list
+      description: AuthenticatorEmailStage Viewset
+      parameters:
+      - in: query
+        name: activate_user_on_success
+        schema:
+          type: boolean
+      - in: query
+        name: configure_flow
+        schema:
+          type: string
+          format: uuid
+      - in: query
+        name: friendly_name
+        schema:
+          type: string
+      - in: query
+        name: from_address
+        schema:
+          type: string
+      - in: query
+        name: host
+        schema:
+          type: string
+      - in: query
+        name: name
+        schema:
+          type: string
+      - name: ordering
+        required: false
+        in: query
+        description: Which field to use when ordering the results.
+        schema:
+          type: string
+      - name: page
+        required: false
+        in: query
+        description: A page number within the paginated result set.
+        schema:
+          type: integer
+      - name: page_size
+        required: false
+        in: query
+        description: Number of results to return per page.
+        schema:
+          type: integer
+      - in: query
+        name: password
+        schema:
+          type: string
+      - in: query
+        name: port
+        schema:
+          type: integer
+      - name: search
+        required: false
+        in: query
+        description: A search term.
+        schema:
+          type: string
+      - in: query
+        name: stage_uuid
+        schema:
+          type: string
+          format: uuid
+      - in: query
+        name: subject
+        schema:
+          type: string
+      - in: query
+        name: template
+        schema:
+          type: string
+      - in: query
+        name: timeout
+        schema:
+          type: integer
+      - in: query
+        name: token_expiry
+        schema:
+          type: integer
+      - in: query
+        name: use_global_settings
+        schema:
+          type: boolean
+      - in: query
+        name: use_ssl
+        schema:
+          type: boolean
+      - in: query
+        name: use_tls
+        schema:
+          type: boolean
+      - in: query
+        name: username
+        schema:
+          type: string
+      tags:
+      - stages
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PaginatedAuthenticatorEmailStageList'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    post:
+      operationId: stages_authenticator_email_create
+      description: AuthenticatorEmailStage Viewset
+      tags:
+      - stages
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/AuthenticatorEmailStageRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '201':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AuthenticatorEmailStage'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /stages/authenticator/email/{stage_uuid}/:
+    get:
+      operationId: stages_authenticator_email_retrieve
+      description: AuthenticatorEmailStage Viewset
+      parameters:
+      - in: path
+        name: stage_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this Email Autherenticator Stage.
+        required: true
+      tags:
+      - stages
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AuthenticatorEmailStage'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    put:
+      operationId: stages_authenticator_email_update
+      description: AuthenticatorEmailStage Viewset
+      parameters:
+      - in: path
+        name: stage_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this Email Autherenticator Stage.
+        required: true
+      tags:
+      - stages
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/AuthenticatorEmailStageRequest'
+        required: true
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AuthenticatorEmailStage'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    patch:
+      operationId: stages_authenticator_email_partial_update
+      description: AuthenticatorEmailStage Viewset
+      parameters:
+      - in: path
+        name: stage_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this Email Autherenticator Stage.
+        required: true
+      tags:
+      - stages
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/PatchedAuthenticatorEmailStageRequest'
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/AuthenticatorEmailStage'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+    delete:
+      operationId: stages_authenticator_email_destroy
+      description: AuthenticatorEmailStage Viewset
+      parameters:
+      - in: path
+        name: stage_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this Email Autherenticator Stage.
+        required: true
+      tags:
+      - stages
+      security:
+      - authentik: []
+      responses:
+        '204':
+          description: No response body
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
+  /stages/authenticator/email/{stage_uuid}/used_by/:
+    get:
+      operationId: stages_authenticator_email_used_by_list
+      description: Get a list of all objects that use this object
+      parameters:
+      - in: path
+        name: stage_uuid
+        schema:
+          type: string
+          format: uuid
+        description: A UUID string identifying this Email Autherenticator Stage.
+        required: true
+      tags:
+      - stages
+      security:
+      - authentik: []
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/UsedBy'
+          description: ''
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ValidationError'
+          description: ''
+        '403':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/GenericError'
+          description: ''
   /stages/authenticator/endpoint_gdtc/:
     get:
       operationId: stages_authenticator_endpoint_gdtc_list
@@ -38318,6 +39117,7 @@ components:
       - authentik.sources.scim
       - authentik.stages.authenticator
       - authentik.stages.authenticator_duo
+      - authentik.stages.authenticator_email
       - authentik.stages.authenticator_sms
       - authentik.stages.authenticator_static
       - authentik.stages.authenticator_totp
@@ -38844,6 +39644,194 @@ components:
       - client_id
       - client_secret
       - name
+    AuthenticatorEmailChallenge:
+      type: object
+      description: Authenticator Email Setup challenge
+      properties:
+        flow_info:
+          $ref: '#/components/schemas/ContextualFlowInfo'
+        component:
+          type: string
+          default: ak-stage-authenticator-email
+        response_errors:
+          type: object
+          additionalProperties:
+            type: array
+            items:
+              $ref: '#/components/schemas/ErrorDetail'
+        pending_user:
+          type: string
+        pending_user_avatar:
+          type: string
+        email:
+          type: string
+          nullable: true
+        email_required:
+          type: boolean
+          default: true
+        response_status:
+          type: string
+          default: pending
+      required:
+      - pending_user
+      - pending_user_avatar
+    AuthenticatorEmailChallengeResponseRequest:
+      type: object
+      description: Authenticator Email Challenge response, device is set by get_response_instance
+      properties:
+        component:
+          type: string
+          minLength: 1
+          default: ak-stage-authenticator-email
+        code:
+          type: integer
+        email:
+          type: string
+          minLength: 1
+    AuthenticatorEmailStage:
+      type: object
+      description: AuthenticatorEmailStage Serializer
+      properties:
+        pk:
+          type: string
+          format: uuid
+          readOnly: true
+          title: Stage uuid
+        name:
+          type: string
+        component:
+          type: string
+          description: Get object type so that we know how to edit the object
+          readOnly: true
+        verbose_name:
+          type: string
+          description: Return object's verbose_name
+          readOnly: true
+        verbose_name_plural:
+          type: string
+          description: Return object's plural verbose_name
+          readOnly: true
+        meta_model_name:
+          type: string
+          description: Return internal model name
+          readOnly: true
+        flow_set:
+          type: array
+          items:
+            $ref: '#/components/schemas/FlowSet'
+        configure_flow:
+          type: string
+          format: uuid
+          nullable: true
+          description: Flow used by an authenticated user to configure this Stage.
+            If empty, user will not be able to configure this stage.
+        friendly_name:
+          type: string
+          nullable: true
+        use_global_settings:
+          type: boolean
+          description: When enabled, global Email connection settings will be used
+            and connection settings below will be ignored.
+        host:
+          type: string
+        port:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+        username:
+          type: string
+        password:
+          type: string
+        use_tls:
+          type: boolean
+        use_ssl:
+          type: boolean
+        timeout:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+        from_address:
+          type: string
+          format: email
+          maxLength: 254
+        subject:
+          type: string
+        token_expiry:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+          description: Time in minutes the token sent is valid.
+        template:
+          type: string
+      required:
+      - component
+      - meta_model_name
+      - name
+      - pk
+      - verbose_name
+      - verbose_name_plural
+    AuthenticatorEmailStageRequest:
+      type: object
+      description: AuthenticatorEmailStage Serializer
+      properties:
+        name:
+          type: string
+          minLength: 1
+        flow_set:
+          type: array
+          items:
+            $ref: '#/components/schemas/FlowSetRequest'
+        configure_flow:
+          type: string
+          format: uuid
+          nullable: true
+          description: Flow used by an authenticated user to configure this Stage.
+            If empty, user will not be able to configure this stage.
+        friendly_name:
+          type: string
+          nullable: true
+          minLength: 1
+        use_global_settings:
+          type: boolean
+          description: When enabled, global Email connection settings will be used
+            and connection settings below will be ignored.
+        host:
+          type: string
+          minLength: 1
+        port:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+        username:
+          type: string
+        password:
+          type: string
+        use_tls:
+          type: boolean
+        use_ssl:
+          type: boolean
+        timeout:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+        from_address:
+          type: string
+          format: email
+          minLength: 1
+          maxLength: 254
+        subject:
+          type: string
+          minLength: 1
+        token_expiry:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+          description: Time in minutes the token sent is valid.
+        template:
+          type: string
+          minLength: 1
+      required:
+      - name
     AuthenticatorEndpointGDTCStage:
       type: object
       description: AuthenticatorEndpointGDTCStage Serializer
@@ -40164,6 +41152,7 @@ components:
       - $ref: '#/components/schemas/AccessDeniedChallenge'
       - $ref: '#/components/schemas/AppleLoginChallenge'
       - $ref: '#/components/schemas/AuthenticatorDuoChallenge'
+      - $ref: '#/components/schemas/AuthenticatorEmailChallenge'
       - $ref: '#/components/schemas/AuthenticatorSMSChallenge'
       - $ref: '#/components/schemas/AuthenticatorStaticChallenge'
       - $ref: '#/components/schemas/AuthenticatorTOTPChallenge'
@@ -40192,6 +41181,7 @@ components:
           ak-stage-access-denied: '#/components/schemas/AccessDeniedChallenge'
           ak-source-oauth-apple: '#/components/schemas/AppleLoginChallenge'
           ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallenge'
+          ak-stage-authenticator-email: '#/components/schemas/AuthenticatorEmailChallenge'
           ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallenge'
           ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallenge'
           ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallenge'
@@ -40951,6 +41941,7 @@ components:
       - webauthn
       - duo
       - sms
+      - email
       type: string
     DigestAlgorithmEnum:
       enum:
@@ -41313,6 +42304,37 @@ components:
           type: string
           minLength: 1
           default: ak-stage-email
+    EmailDevice:
+      type: object
+      description: Serializer for email authenticator devices
+      properties:
+        name:
+          type: string
+          description: The human-readable name of this device.
+          maxLength: 64
+        pk:
+          type: integer
+          readOnly: true
+          title: ID
+        email:
+          type: string
+          format: email
+          readOnly: true
+      required:
+      - email
+      - name
+      - pk
+    EmailDeviceRequest:
+      type: object
+      description: Serializer for email authenticator devices
+      properties:
+        name:
+          type: string
+          minLength: 1
+          description: The human-readable name of this device.
+          maxLength: 64
+      required:
+      - name
     EmailStage:
       type: object
       description: EmailStage Serializer
@@ -42107,6 +43129,7 @@ components:
       oneOf:
       - $ref: '#/components/schemas/AppleChallengeResponseRequest'
       - $ref: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest'
+      - $ref: '#/components/schemas/AuthenticatorEmailChallengeResponseRequest'
       - $ref: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest'
       - $ref: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest'
       - $ref: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest'
@@ -42131,6 +43154,7 @@ components:
         mapping:
           ak-source-oauth-apple: '#/components/schemas/AppleChallengeResponseRequest'
           ak-stage-authenticator-duo: '#/components/schemas/AuthenticatorDuoChallengeResponseRequest'
+          ak-stage-authenticator-email: '#/components/schemas/AuthenticatorEmailChallengeResponseRequest'
           ak-stage-authenticator-sms: '#/components/schemas/AuthenticatorSMSChallengeResponseRequest'
           ak-stage-authenticator-static: '#/components/schemas/AuthenticatorStaticChallengeResponseRequest'
           ak-stage-authenticator-totp: '#/components/schemas/AuthenticatorTOTPChallengeResponseRequest'
@@ -45199,6 +46223,8 @@ components:
       - authentik_sources_scim.scimsourcepropertymapping
       - authentik_stages_authenticator_duo.authenticatorduostage
       - authentik_stages_authenticator_duo.duodevice
+      - authentik_stages_authenticator_email.authenticatoremailstage
+      - authentik_stages_authenticator_email.emaildevice
       - authentik_stages_authenticator_sms.authenticatorsmsstage
       - authentik_stages_authenticator_sms.smsdevice
       - authentik_stages_authenticator_static.authenticatorstaticstage
@@ -46359,6 +47385,18 @@ components:
       required:
       - pagination
       - results
+    PaginatedAuthenticatorEmailStageList:
+      type: object
+      properties:
+        pagination:
+          $ref: '#/components/schemas/Pagination'
+        results:
+          type: array
+          items:
+            $ref: '#/components/schemas/AuthenticatorEmailStage'
+      required:
+      - pagination
+      - results
     PaginatedAuthenticatorEndpointGDTCStageList:
       type: object
       properties:
@@ -46575,6 +47613,18 @@ components:
       required:
       - pagination
       - results
+    PaginatedEmailDeviceList:
+      type: object
+      properties:
+        pagination:
+          $ref: '#/components/schemas/Pagination'
+        results:
+          type: array
+          items:
+            $ref: '#/components/schemas/EmailDevice'
+      required:
+      - pagination
+      - results
     PaginatedEmailStageList:
       type: object
       properties:
@@ -48307,6 +49357,66 @@ components:
         admin_secret_key:
           type: string
           writeOnly: true
+    PatchedAuthenticatorEmailStageRequest:
+      type: object
+      description: AuthenticatorEmailStage Serializer
+      properties:
+        name:
+          type: string
+          minLength: 1
+        flow_set:
+          type: array
+          items:
+            $ref: '#/components/schemas/FlowSetRequest'
+        configure_flow:
+          type: string
+          format: uuid
+          nullable: true
+          description: Flow used by an authenticated user to configure this Stage.
+            If empty, user will not be able to configure this stage.
+        friendly_name:
+          type: string
+          nullable: true
+          minLength: 1
+        use_global_settings:
+          type: boolean
+          description: When enabled, global Email connection settings will be used
+            and connection settings below will be ignored.
+        host:
+          type: string
+          minLength: 1
+        port:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+        username:
+          type: string
+        password:
+          type: string
+        use_tls:
+          type: boolean
+        use_ssl:
+          type: boolean
+        timeout:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+        from_address:
+          type: string
+          format: email
+          minLength: 1
+          maxLength: 254
+        subject:
+          type: string
+          minLength: 1
+        token_expiry:
+          type: integer
+          maximum: 2147483647
+          minimum: -2147483648
+          description: Time in minutes the token sent is valid.
+        template:
+          type: string
+          minLength: 1
     PatchedAuthenticatorEndpointGDTCStageRequest:
       type: object
       description: AuthenticatorEndpointGDTCStage Serializer
@@ -48758,6 +49868,15 @@ components:
           minLength: 1
           description: The human-readable name of this device.
           maxLength: 64
+    PatchedEmailDeviceRequest:
+      type: object
+      description: Serializer for email authenticator devices
+      properties:
+        name:
+          type: string
+          minLength: 1
+          description: The human-readable name of this device.
+          maxLength: 64
     PatchedEmailStageRequest:
       type: object
       description: EmailStage Serializer
diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts
index 19e9b5e2b294..3056d41d032e 100644
--- a/web/src/admin/stages/StageListPage.ts
+++ b/web/src/admin/stages/StageListPage.ts
@@ -2,6 +2,7 @@ import "@goauthentik/admin/rbac/ObjectPermissionModal";
 import "@goauthentik/admin/stages/StageWizard";
 import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
 import "@goauthentik/admin/stages/authenticator_duo/DuoDeviceImportForm";
+import "@goauthentik/admin/stages/authenticator_email/AuthenticatorEmailStageForm";
 import "@goauthentik/admin/stages/authenticator_endpoint_gdtc/AuthenticatorEndpointGDTCStageForm";
 import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
 import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
diff --git a/web/src/admin/stages/StageWizard.ts b/web/src/admin/stages/StageWizard.ts
index 1335ac280e36..46d99f4a1675 100644
--- a/web/src/admin/stages/StageWizard.ts
+++ b/web/src/admin/stages/StageWizard.ts
@@ -1,6 +1,7 @@
 import "@goauthentik/admin/common/ak-license-notice";
 import { StageBindingForm } from "@goauthentik/admin/flows/StageBindingForm";
 import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
+import "@goauthentik/admin/stages/authenticator_email/AuthenticatorEmailStageForm";
 import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
 import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
 import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";
diff --git a/web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts b/web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts
new file mode 100644
index 000000000000..0e092dda8f2a
--- /dev/null
+++ b/web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts
@@ -0,0 +1,285 @@
+import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
+import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
+import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { first } from "@goauthentik/common/utils";
+import "@goauthentik/elements/forms/FormGroup";
+import "@goauthentik/elements/forms/HorizontalFormElement";
+import "@goauthentik/elements/forms/Radio";
+import "@goauthentik/elements/forms/SearchSelect";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import {
+    AuthenticatorEmailStage,
+    Flow,
+    FlowsApi,
+    FlowsInstancesListDesignationEnum,
+    FlowsInstancesListRequest, // NotificationWebhookMapping,
+    // PropertymappingsApi,
+    // PropertymappingsNotificationListRequest,
+    StagesApi,
+} from "@goauthentik/api";
+
+@customElement("ak-stage-authenticator-email-form")
+export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmailStage> {
+    async loadInstance(pk: string): Promise<AuthenticatorEmailStage> {
+        const stage = await new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailRetrieve({
+            stageUuid: pk,
+        });
+        this.showConnectionSettings = !stage.useGlobalSettings;
+        return stage;
+    }
+
+    @property({ type: Boolean })
+    showConnectionSettings = false;
+
+    async send(data: AuthenticatorEmailStage): Promise<AuthenticatorEmailStage> {
+        if (this.instance) {
+            return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailUpdate({
+                stageUuid: this.instance.pk || "",
+                authenticatorEmailStageRequest: data,
+            });
+        } else {
+            return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorEmailCreate({
+                authenticatorEmailStageRequest: data,
+            });
+        }
+    }
+
+    renderConnectionSettings(): TemplateResult {
+        if (!this.showConnectionSettings) {
+            return html``;
+        }
+        return html`<ak-form-group>
+            <span slot="header"> ${msg("Connection settings")} </span>
+            <div slot="body" class="pf-c-form">
+                <ak-form-element-horizontal label=${msg("SMTP Host")} ?required=${true} name="host">
+                    <input
+                        type="text"
+                        value="${ifDefined(this.instance?.host || "")}"
+                        class="pf-c-form-control"
+                        required
+                    />
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal label=${msg("SMTP Port")} ?required=${true} name="port">
+                    <input
+                        type="number"
+                        value="${first(this.instance?.port, 25)}"
+                        class="pf-c-form-control"
+                        required
+                    />
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal label=${msg("SMTP Username")} name="username">
+                    <input
+                        type="text"
+                        value="${ifDefined(this.instance?.username || "")}"
+                        class="pf-c-form-control"
+                    />
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal
+                    label=${msg("SMTP Password")}
+                    ?writeOnly=${this.instance !== undefined}
+                    name="password"
+                >
+                    <input type="text" value="" class="pf-c-form-control" />
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal name="useTls">
+                    <label class="pf-c-switch">
+                        <input
+                            class="pf-c-switch__input"
+                            type="checkbox"
+                            ?checked=${first(this.instance?.useTls, true)}
+                        />
+                        <span class="pf-c-switch__toggle">
+                            <span class="pf-c-switch__toggle-icon">
+                                <i class="fas fa-check" aria-hidden="true"></i>
+                            </span>
+                        </span>
+                        <span class="pf-c-switch__label">${msg("Use TLS")}</span>
+                    </label>
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal name="useSsl">
+                    <label class="pf-c-switch">
+                        <input
+                            class="pf-c-switch__input"
+                            type="checkbox"
+                            ?checked=${first(this.instance?.useSsl, false)}
+                        />
+                        <span class="pf-c-switch__toggle">
+                            <span class="pf-c-switch__toggle-icon">
+                                <i class="fas fa-check" aria-hidden="true"></i>
+                            </span>
+                        </span>
+                        <span class="pf-c-switch__label">${msg("Use SSL")}</span>
+                    </label>
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal
+                    label=${msg("Timeout")}
+                    ?required=${true}
+                    name="timeout"
+                >
+                    <input
+                        type="number"
+                        value="${first(this.instance?.timeout, 30)}"
+                        class="pf-c-form-control"
+                        required
+                    />
+                </ak-form-element-horizontal>
+                <ak-form-element-horizontal
+                    label=${msg("From address")}
+                    ?required=${true}
+                    name="fromAddress"
+                >
+                    <input
+                        type="text"
+                        value="${ifDefined(this.instance?.fromAddress || "system@authentik.local")}"
+                        class="pf-c-form-control"
+                        required
+                    />
+                    <p class="pf-c-form__helper-text">
+                        ${msg("Email address the verification email will be sent from.")}
+                    </p>
+                </ak-form-element-horizontal>
+            </div>
+        </ak-form-group>`;
+    }
+
+    renderForm(): TemplateResult {
+        return html` <span> ${msg("Stage used to configure an Email-based authenticator.")} </span>
+            <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
+                <input
+                    type="text"
+                    value="${first(this.instance?.name, "")}"
+                    class="pf-c-form-control"
+                    required
+                />
+            </ak-form-element-horizontal>
+            <ak-form-element-horizontal
+                label=${msg("Authenticator type name")}
+                ?required=${false}
+                name="friendlyName"
+            >
+                <input
+                    type="text"
+                    value="${first(this.instance?.friendlyName, "")}"
+                    class="pf-c-form-control"
+                />
+                <p class="pf-c-form__helper-text">
+                    ${msg(
+                        "Display name of this authenticator, used by users when they enroll an authenticator.",
+                    )}
+                </p>
+            </ak-form-element-horizontal>
+            <ak-form-group .expanded=${true}>
+                <span slot="header"> ${msg("Stage-specific settings")} </span>
+                <div slot="body" class="pf-c-form">
+                    <ak-form-element-horizontal
+                        label=${msg("Subject")}
+                        ?required=${true}
+                        name="subject"
+                    >
+                        <input
+                            type="text"
+                            value="${first(this.instance?.subject, "")}"
+                            class="pf-c-form-control"
+                            required
+                        />
+                        <p class="pf-c-form__helper-text">
+                            ${msg("Subject of the verification email.")}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal
+                        label=${msg("Token expiration time")}
+                        ?required=${true}
+                        name="tokenExpiry"
+                    >
+                        <input
+                            type="number"
+                            value="${first(this.instance?.tokenExpiry, 10)}"
+                            class="pf-c-form-control"
+                            required
+                        />
+                        <p class="pf-c-form__helper-text">
+                            ${msg("Time in minutes the token sent is valid.")}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal
+                        label=${msg("Configuration flow")}
+                        name="configureFlow"
+                    >
+                        <ak-search-select
+                            .fetchObjects=${async (query?: string): Promise<Flow[]> => {
+                                const args: FlowsInstancesListRequest = {
+                                    ordering: "slug",
+                                    designation:
+                                        FlowsInstancesListDesignationEnum.StageConfiguration,
+                                };
+                                if (query !== undefined) {
+                                    args.search = query;
+                                }
+                                const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
+                                    args,
+                                );
+                                return flows.results;
+                            }}
+                            .renderElement=${(flow: Flow): string => {
+                                return RenderFlowOption(flow);
+                            }}
+                            .renderDescription=${(flow: Flow): TemplateResult => {
+                                return html`${flow.name}`;
+                            }}
+                            .value=${(flow: Flow | undefined): string | undefined => {
+                                return flow?.pk;
+                            }}
+                            .selected=${(flow: Flow): boolean => {
+                                return this.instance?.configureFlow === flow.pk;
+                            }}
+                            ?blankable=${true}
+                        >
+                        </ak-search-select>
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                    <ak-form-element-horizontal name="useGlobalSettings">
+                        <label class="pf-c-switch">
+                            <input
+                                class="pf-c-switch__input"
+                                type="checkbox"
+                                ?checked=${first(this.instance?.useGlobalSettings, true)}
+                                @change=${(ev: Event) => {
+                                    const target = ev.target as HTMLInputElement;
+                                    this.showConnectionSettings = !target.checked;
+                                }}
+                            />
+                            <span class="pf-c-switch__toggle">
+                                <span class="pf-c-switch__toggle-icon">
+                                    <i class="fas fa-check" aria-hidden="true"></i>
+                                </span>
+                            </span>
+                            <span class="pf-c-switch__label"
+                                >${msg("Use global connection settings")}</span
+                            >
+                        </label>
+                        <p class="pf-c-form__helper-text">
+                            ${msg(
+                                "When enabled, global Email connection settings will be used and connection settings below will be ignored.",
+                            )}
+                        </p>
+                    </ak-form-element-horizontal>
+                </div>
+            </ak-form-group>
+            ${this.renderConnectionSettings()}`;
+    }
+}
+
+declare global {
+    interface HTMLElementTagNameMap {
+        "ak-stage-authenticator-email-form": AuthenticatorEmailStageForm;
+    }
+}
diff --git a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts
index 7fed4be5ccb6..9302d8748afc 100644
--- a/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts
+++ b/web/src/admin/stages/authenticator_validate/AuthenticatorValidateStageForm.ts
@@ -79,6 +79,7 @@ export class AuthenticatorValidateStageForm extends BaseStageForm<AuthenticatorV
             [DeviceClassesEnum.Webauthn, msg("WebAuthn Authenticators")],
             [DeviceClassesEnum.Duo, msg("Duo Authenticators")],
             [DeviceClassesEnum.Sms, msg("SMS-based Authenticators")],
+            [DeviceClassesEnum.Email, msg("Email-based Authenticators")],
         ];
 
         return html`
diff --git a/web/src/admin/users/UserDevicesTable.ts b/web/src/admin/users/UserDevicesTable.ts
index 241f8d020f27..36e885fcd265 100644
--- a/web/src/admin/users/UserDevicesTable.ts
+++ b/web/src/admin/users/UserDevicesTable.ts
@@ -58,6 +58,8 @@ export class UserDeviceTable extends Table<Device> {
         switch (device.type) {
             case "authentik_stages_authenticator_duo.DuoDevice":
                 return api.authenticatorsAdminDuoDestroy({ id: parseInt(device.pk, 10) });
+            case "authentik_stages_authenticator_email.EmailDevice":
+                return api.authenticatorsAdminEmailDestroy({ id: parseInt(device.pk, 10) });
             case "authentik_stages_authenticator_sms.SMSDevice":
                 return api.authenticatorsAdminSmsDestroy({ id: parseInt(device.pk, 10) });
             case "authentik_stages_authenticator_totp.TOTPDevice":
diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts
index 63efdeff180a..522470ca3b19 100644
--- a/web/src/flow/FlowExecutor.ts
+++ b/web/src/flow/FlowExecutor.ts
@@ -392,6 +392,14 @@ export class FlowExecutor extends Interface implements StageHost {
                     .host=${this as StageHost}
                     .challenge=${this.challenge}
                 ></ak-stage-authenticator-webauthn>`;
+            case "ak-stage-authenticator-email":
+                await import(
+                    "@goauthentik/flow/stages/authenticator_email/AuthenticatorEmailStage"
+                );
+                return html`<ak-stage-authenticator-email
+                    .host=${this as StageHost}
+                    .challenge=${this.challenge}
+                ></ak-stage-authenticator-email>`;
             case "ak-stage-authenticator-sms":
                 await import("@goauthentik/flow/stages/authenticator_sms/AuthenticatorSMSStage");
                 return html`<ak-stage-authenticator-sms
diff --git a/web/src/flow/stages/authenticator_email/AuthenticatorEmailStage.ts b/web/src/flow/stages/authenticator_email/AuthenticatorEmailStage.ts
new file mode 100644
index 000000000000..689d049241a5
--- /dev/null
+++ b/web/src/flow/stages/authenticator_email/AuthenticatorEmailStage.ts
@@ -0,0 +1,165 @@
+import "@goauthentik/elements/EmptyState";
+import "@goauthentik/elements/forms/FormElement";
+import "@goauthentik/flow/FormStatic";
+import { BaseStage } from "@goauthentik/flow/stages/base";
+
+import { msg } from "@lit/localize";
+import { CSSResult, TemplateResult, html } from "lit";
+import { customElement } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
+import PFButton from "@patternfly/patternfly/components/Button/button.css";
+import PFForm from "@patternfly/patternfly/components/Form/form.css";
+import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
+import PFLogin from "@patternfly/patternfly/components/Login/login.css";
+import PFTitle from "@patternfly/patternfly/components/Title/title.css";
+import PFBase from "@patternfly/patternfly/patternfly-base.css";
+
+import {
+    AuthenticatorEmailChallenge,
+    AuthenticatorEmailChallengeResponseRequest,
+} from "@goauthentik/api";
+
+@customElement("ak-stage-authenticator-email")
+export class AuthenticatorEmailStage extends BaseStage<
+    AuthenticatorEmailChallenge,
+    AuthenticatorEmailChallengeResponseRequest
+> {
+    static get styles(): CSSResult[] {
+        return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
+    }
+
+    renderEmailInput(): TemplateResult {
+        return html`<header class="pf-c-login__main-header">
+                <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
+            </header>
+            <div class="pf-c-login__main-body">
+                <form
+                    class="pf-c-form"
+                    @submit=${(e: Event) => {
+                        this.submitForm(e);
+                    }}
+                >
+                    <ak-form-static
+                        class="pf-c-form__group"
+                        userAvatar="${this.challenge.pendingUserAvatar}"
+                        user=${this.challenge.pendingUser}
+                    >
+                        <div slot="link">
+                            <a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
+                                >${msg("Not you?")}</a
+                            >
+                        </div>
+                    </ak-form-static>
+                    <ak-form-element
+                        label="${msg("Configure your email")}"
+                        required
+                        class="pf-c-form__group"
+                        .errors=${(this.challenge?.responseErrors || {})["email"]}
+                    >
+                        <input
+                            type="email"
+                            name="email"
+                            placeholder="${msg("Please enter your email address.")}"
+                            autofocus=""
+                            autocomplete="email"
+                            class="pf-c-form-control"
+                            required
+                        />
+                    </ak-form-element>
+                    ${this.renderNonFieldErrors()}
+                    <div class="pf-c-form__group pf-m-action">
+                        <button type="submit" class="pf-c-button pf-m-primary pf-m-block">
+                            ${msg("Continue")}
+                        </button>
+                    </div>
+                </form>
+            </div>
+            <footer class="pf-c-login__main-footer">
+                <ul class="pf-c-login__main-footer-links"></ul>
+            </footer>`;
+    }
+
+    renderEmailOTPInput(): TemplateResult {
+        return html`<header class="pf-c-login__main-header">
+                <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
+            </header>
+            <div class="pf-c-login__main-body">
+                <ak-form-static
+                    class="pf-c-form__group"
+                    userAvatar="${this.challenge.pendingUserAvatar}"
+                    user=${this.challenge.pendingUser}
+                >
+                    <div slot="link">
+                        <a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
+                            >${msg("Not you?")}</a
+                        >
+                    </div>
+                </ak-form-static>
+                A verification token has been sent to your configured email address
+                ${this.challenge.email}
+                <form
+                    class="pf-c-form"
+                    @submit=${(e: Event) => {
+                        this.submitForm(e);
+                    }}
+                >
+                    <ak-form-element
+                        label="${msg("Code")}"
+                        required
+                        class="pf-c-form__group"
+                        .errors=${(this.challenge?.responseErrors || {})["code"]}
+                    >
+                        <input
+                            type="text"
+                            name="code"
+                            inputmode="numeric"
+                            pattern="[0-9]*"
+                            placeholder="${msg("Please enter the code you received via Email")}"
+                            autofocus=""
+                            autocomplete="one-time-code"
+                            class="pf-c-form-control"
+                            required
+                        />
+                    </ak-form-element>
+                    ${this.renderNonFieldErrors()}
+                    <div class="pf-c-form__group pf-m-action">
+                        <button type="submit" class="pf-c-button pf-m-primary pf-m-block">
+                            ${msg("Continue")}
+                        </button>
+                    </div>
+                </form>
+            </div>`;
+    }
+
+    render(): TemplateResult {
+        console.debug(
+            "AuthenticatorEmailStage:",
+            this.challenge ? this.challenge.emailRequired : undefined,
+        );
+
+        if (!this.challenge) {
+            console.debug("AuthenticatorEmailStage.render() called without challenge");
+
+            return html`<ak-empty-state loading> </ak-empty-state>`;
+        }
+        if (this.challenge.emailRequired) {
+            console.debug("AuthenticatorEmailStage.render() called with challenge", this.challenge);
+
+            return this.renderEmailInput();
+        }
+        console.debug(
+            "AuthenticatorEmailStage.render() called without emailRequired challenge",
+            this.challenge,
+        );
+
+        return this.renderEmailOTPInput();
+    }
+}
+
+declare global {
+    interface HTMLElementTagNameMap {
+        "ak-stage-authenticator-email": AuthenticatorEmailStage;
+    }
+}
diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts
index 3bfad7def077..ac6372333e95 100644
--- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts
+++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts
@@ -185,6 +185,12 @@ export class AuthenticatorValidateStage
                         <p>${msg("SMS")}</p>
                         <small>${msg("Tokens sent via SMS.")}</small>
                     </div>`;
+            case DeviceClassesEnum.Email:
+                return html`<i class="fas fa-mobile-alt"></i>
+                    <div class="right">
+                        <p>${msg("Email")}</p>
+                        <small>${msg("Tokens sent via Email.")}</small>
+                    </div>`;
             default:
                 break;
         }
@@ -240,6 +246,7 @@ export class AuthenticatorValidateStage
         switch (this.selectedDeviceChallenge?.deviceClass) {
             case DeviceClassesEnum.Static:
             case DeviceClassesEnum.Totp:
+            case DeviceClassesEnum.Email:
             case DeviceClassesEnum.Sms:
                 return html` <ak-stage-authenticator-validate-code
                     .host=${this}
diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageCode.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageCode.ts
index 01fcd4ef6819..d3241a52caba 100644
--- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageCode.ts
+++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageCode.ts
@@ -33,6 +33,8 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
 
     deviceMessage(): string {
         switch (this.deviceChallenge?.deviceClass) {
+            case DeviceClassesEnum.Email:
+                return msg("A code has been sent to you via Email.");
             case DeviceClassesEnum.Sms:
                 return msg("A code has been sent to you via SMS.");
             case DeviceClassesEnum.Totp:
@@ -48,6 +50,8 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
 
     deviceIcon(): string {
         switch (this.deviceChallenge?.deviceClass) {
+            case DeviceClassesEnum.Email:
+                return "fa-key";
             case DeviceClassesEnum.Sms:
                 return "fa-key";
             case DeviceClassesEnum.Totp:
diff --git a/web/src/user/user-settings/mfa/MFADeviceForm.ts b/web/src/user/user-settings/mfa/MFADeviceForm.ts
index 7ac6f9e4d7ab..1b814e3c6c9b 100644
--- a/web/src/user/user-settings/mfa/MFADeviceForm.ts
+++ b/web/src/user/user-settings/mfa/MFADeviceForm.ts
@@ -34,6 +34,12 @@ export class MFADeviceForm extends ModelForm<Device, string> {
                     duoDeviceRequest: device,
                 });
                 break;
+            case "authentik_stages_authenticator_email.EmailDevice":
+                await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsEmailUpdate({
+                    id: parseInt(this.instance?.pk, 10),
+                    emailDeviceRequest: device,
+                });
+                break;
             case "authentik_stages_authenticator_sms.SMSDevice":
                 await new AuthenticatorsApi(DEFAULT_CONFIG).authenticatorsSmsUpdate({
                     id: parseInt(this.instance?.pk, 10),
diff --git a/web/src/user/user-settings/mfa/MFADevicesPage.ts b/web/src/user/user-settings/mfa/MFADevicesPage.ts
index 29a82c48e373..2f637480a1a5 100644
--- a/web/src/user/user-settings/mfa/MFADevicesPage.ts
+++ b/web/src/user/user-settings/mfa/MFADevicesPage.ts
@@ -94,6 +94,8 @@ export class MFADevicesPage extends Table<Device> {
         switch (device.type) {
             case "authentik_stages_authenticator_duo.DuoDevice":
                 return api.authenticatorsDuoDestroy(id);
+            case "authentik_stages_authenticator_email.EmailDevice":
+                return api.authenticatorsEmailDestroy(id);
             case "authentik_stages_authenticator_sms.SMSDevice":
                 return api.authenticatorsSmsDestroy(id);
             case "authentik_stages_authenticator_totp.TOTPDevice":


Code review comments:
diff_hunk: @@ -0,0 +1,193 @@
+from os import R_OK, access
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.mail.backends.base import BaseEmailBackend
+from django.core.mail.backends.smtp import EmailBackend
+from django.db import models
+from django.template import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
+from authentik.lib.config import CONFIG
+from authentik.lib.models import SerializerModel
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator.models import SideChannelDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class EmailTemplates(models.TextChoices):
+    """Templates used for rendering the Email"""
+
+    EMAIL_OTP = (
+        "email/email_otp.html",
+        _("Email OTP"),
+    )  # nosec
+
+
+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() or not template_dir.is_dir():
+            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 AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
+    """Use Email-based authentication instead of authenticator-based."""
+
+    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)
+    password = models.TextField(default="", blank=True)
+    use_tls = models.BooleanField(default=False)
+    use_ssl = models.BooleanField(default=False)
+    timeout = models.IntegerField(default=10)
+    from_address = models.EmailField(default="system@authentik.local")
+
+    activate_user_on_success = models.BooleanField(
+        default=False, help_text=_("Activate users upon completion of stage.")
+    )
+
+    token_expiry = models.IntegerField(
+        default=30, help_text=_("Time in minutes the token sent is valid.")
+    )
path: authentik/stages/authenticator_email/models.py
text: @user1:
We usually allow text inputs for those. An example of a declaration: https://github.com/goauthentik/authentik/blob/main/authentik/tenants/models.py#L66. Usage: https://github.com/goauthentik/authentik/blob/39d8b413577be7c0136bb9074f4f86d7861dfffc/authentik/events/models.py#L64

@author:
In the email stage, we use this:
https://github.com/goauthentik/authentik/blob/39d8b413577be7c0136bb9074f4f86d7861dfffc/authentik/stages/email/models.py#L77-L79

Is there a use case where the token is valid in days, years, or seconds? 

@user2:
There usually isn't a use case where the token is valid for that long, however for consistency we should use the same format everywhere (and refactor the email stage to use the same format)

diff_hunk: @@ -0,0 +1,193 @@
+from os import R_OK, access
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.mail.backends.base import BaseEmailBackend
+from django.core.mail.backends.smtp import EmailBackend
+from django.db import models
+from django.template import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
+from authentik.lib.config import CONFIG
+from authentik.lib.models import SerializerModel
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator.models import SideChannelDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class EmailTemplates(models.TextChoices):
+    """Templates used for rendering the Email"""
+
+    EMAIL_OTP = (
+        "email/email_otp.html",
+        _("Email OTP"),
+    )  # nosec
+
+
+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() or not template_dir.is_dir():
+            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 AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
+    """Use Email-based authentication instead of authenticator-based."""
+
+    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)
+    password = models.TextField(default="", blank=True)
+    use_tls = models.BooleanField(default=False)
+    use_ssl = models.BooleanField(default=False)
+    timeout = models.IntegerField(default=10)
+    from_address = models.EmailField(default="system@authentik.local")
+
+    activate_user_on_success = models.BooleanField(
+        default=False, help_text=_("Activate users upon completion of stage.")
+    )
+
+    token_expiry = models.IntegerField(
+        default=30, help_text=_("Time in minutes the token sent is valid.")
+    )
+    subject = models.TextField(default="authentik")
+    template = models.TextField(default=EmailTemplates.EMAIL_OTP)
+
+    @property
+    def serializer(self) -> type[BaseSerializer]:
+        from authentik.stages.authenticator_email.api import AuthenticatorEmailStageSerializer
+
+        return AuthenticatorEmailStageSerializer
+
+    @property
+    def view(self) -> type[View]:
+        from authentik.stages.authenticator_email.stage import AuthenticatorEmailStageView
+
+        return AuthenticatorEmailStageView
+
+    @property
+    def component(self) -> str:
+        return "ak-stage-authenticator-email-form"
+
+    @property
+    def backend_class(self) -> type[BaseEmailBackend]:
+        """Get the email backend class to use"""
+        return EmailBackend
+
+    @property
+    def backend(self) -> BaseEmailBackend:
+        """Get fully configured Email Backend instance"""
+        if self.use_global_settings:
+            CONFIG.refresh("email.password")
+            return self.backend_class(
+                host=CONFIG.get("email.host"),
+                port=CONFIG.get_int("email.port"),
+                username=CONFIG.get("email.username"),
+                password=CONFIG.get("email.password"),
+                use_tls=CONFIG.get_bool("email.use_tls", False),
+                use_ssl=CONFIG.get_bool("email.use_ssl", False),
+                timeout=CONFIG.get_int("email.timeout"),
+            )
+        return self.backend_class(
+            host=self.host,
+            port=self.port,
+            username=self.username,
+            password=self.password,
+            use_tls=self.use_tls,
+            use_ssl=self.use_ssl,
+            timeout=self.timeout,
+        )
+
+    def send(self, device: "EmailDevice"):
+        # Compose the message using templates
+        message = device._compose_email()
+        # Lazy import here to avoid circular import
+        from authentik.stages.authenticator_email.tasks import send_mails
path: authentik/stages/authenticator_email/models.py
text: @user1:
We usually put those at the top of the function

diff_hunk: @@ -0,0 +1,193 @@
+from os import R_OK, access
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.mail.backends.base import BaseEmailBackend
+from django.core.mail.backends.smtp import EmailBackend
+from django.db import models
+from django.template import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
+from authentik.lib.config import CONFIG
+from authentik.lib.models import SerializerModel
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator.models import SideChannelDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class EmailTemplates(models.TextChoices):
+    """Templates used for rendering the Email"""
+
+    EMAIL_OTP = (
+        "email/email_otp.html",
+        _("Email OTP"),
+    )  # nosec
+
+
+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() or not template_dir.is_dir():
+            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 AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
+    """Use Email-based authentication instead of authenticator-based."""
+
+    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)
+    password = models.TextField(default="", blank=True)
+    use_tls = models.BooleanField(default=False)
+    use_ssl = models.BooleanField(default=False)
+    timeout = models.IntegerField(default=10)
+    from_address = models.EmailField(default="system@authentik.local")
+
+    activate_user_on_success = models.BooleanField(
+        default=False, help_text=_("Activate users upon completion of stage.")
+    )
+
+    token_expiry = models.IntegerField(
+        default=30, help_text=_("Time in minutes the token sent is valid.")
+    )
+    subject = models.TextField(default="authentik")
+    template = models.TextField(default=EmailTemplates.EMAIL_OTP)
+
+    @property
+    def serializer(self) -> type[BaseSerializer]:
+        from authentik.stages.authenticator_email.api import AuthenticatorEmailStageSerializer
+
+        return AuthenticatorEmailStageSerializer
+
+    @property
+    def view(self) -> type[View]:
+        from authentik.stages.authenticator_email.stage import AuthenticatorEmailStageView
+
+        return AuthenticatorEmailStageView
+
+    @property
+    def component(self) -> str:
+        return "ak-stage-authenticator-email-form"
+
+    @property
+    def backend_class(self) -> type[BaseEmailBackend]:
+        """Get the email backend class to use"""
+        return EmailBackend
+
+    @property
+    def backend(self) -> BaseEmailBackend:
+        """Get fully configured Email Backend instance"""
+        if self.use_global_settings:
+            CONFIG.refresh("email.password")
+            return self.backend_class(
+                host=CONFIG.get("email.host"),
+                port=CONFIG.get_int("email.port"),
+                username=CONFIG.get("email.username"),
+                password=CONFIG.get("email.password"),
+                use_tls=CONFIG.get_bool("email.use_tls", False),
+                use_ssl=CONFIG.get_bool("email.use_ssl", False),
+                timeout=CONFIG.get_int("email.timeout"),
+            )
+        return self.backend_class(
+            host=self.host,
+            port=self.port,
+            username=self.username,
+            password=self.password,
+            use_tls=self.use_tls,
+            use_ssl=self.use_ssl,
+            timeout=self.timeout,
+        )
+
+    def send(self, device: "EmailDevice"):
+        # Compose the message using templates
+        message = device._compose_email()
+        # Lazy import here to avoid circular import
+        from authentik.stages.authenticator_email.tasks import send_mails
+
+        return send_mails(device.stage, message)
+
+    def __str__(self):
+        return f"Email Stage {self.name}"
+
+    class Meta:
+        verbose_name = _("Email Autherenticator Stage")
path: authentik/stages/authenticator_email/models.py
text: @user1:
```suggestion
        verbose_name = _("Email Authenticator Stage")
```

diff_hunk: @@ -0,0 +1,214 @@
+"""Email Setup stage"""
+
+from django.db.models import Q
+from django.http import HttpRequest, HttpResponse
+from django.http.request import QueryDict
+from django.template.exceptions import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from rest_framework.exceptions import ValidationError
+from rest_framework.fields import BooleanField, CharField, IntegerField
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.challenge import (
+    Challenge,
+    ChallengeResponse,
+    WithUserInfoChallenge,
+)
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.stage import ChallengeStageView
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator_email.models import (
+    AuthenticatorEmailStage,
+    EmailDevice,
+)
+from authentik.stages.authenticator_email.tasks import send_mails
+from authentik.stages.email.utils import TemplateEmailMessage
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+LOGGER = get_logger()
+
+
+SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device"
+PLAN_CONTEXT_EMAIL = "email"
+PLAN_CONTEXT_EMAIL_SENT = "email_sent"
+PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
+
+
+class AuthenticatorEmailChallenge(WithUserInfoChallenge):
+    """Authenticator Email Setup challenge"""
+
+    # Set to true if no previous prompt stage set the email
+    # this stage will also check prompt_data.email
+    email = CharField(default=None, allow_blank=True, allow_null=True)
+    email_required = BooleanField(default=True)
+    component = CharField(default="ak-stage-authenticator-email")
+    response_status = CharField(default="pending")
path: authentik/stages/authenticator_email/stage.py
text: @user1:
```suggestion
```
Never used

diff_hunk: @@ -0,0 +1,285 @@
+import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
+import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
+import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { first } from "@goauthentik/common/utils";
+import "@goauthentik/elements/forms/FormGroup";
+import "@goauthentik/elements/forms/HorizontalFormElement";
+import "@goauthentik/elements/forms/Radio";
+import "@goauthentik/elements/forms/SearchSelect";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import {
+    AuthenticatorEmailStage,
+    Flow,
+    FlowsApi,
+    FlowsInstancesListDesignationEnum,
+    FlowsInstancesListRequest, // NotificationWebhookMapping,
+    // PropertymappingsApi,
+    // PropertymappingsNotificationListRequest,
path: web/src/admin/stages/authenticator_email/AuthenticatorEmailStageForm.ts
text: @user1:
Some leftovers to cleanup

diff_hunk: @@ -185,6 +185,12 @@ export class AuthenticatorValidateStage
                         <p>${msg("SMS")}</p>
                         <small>${msg("Tokens sent via SMS.")}</small>
                     </div>`;
+            case DeviceClassesEnum.Email:
+                return html`<i class="fas fa-mobile-alt"></i>
path: web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts
text: @user1:
Icon to be refined

diff_hunk: @@ -0,0 +1,165 @@
+import "@goauthentik/elements/EmptyState";
+import "@goauthentik/elements/forms/FormElement";
+import "@goauthentik/flow/FormStatic";
+import { BaseStage } from "@goauthentik/flow/stages/base";
+
+import { msg } from "@lit/localize";
+import { CSSResult, TemplateResult, html } from "lit";
+import { customElement } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
+import PFButton from "@patternfly/patternfly/components/Button/button.css";
+import PFForm from "@patternfly/patternfly/components/Form/form.css";
+import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
+import PFLogin from "@patternfly/patternfly/components/Login/login.css";
+import PFTitle from "@patternfly/patternfly/components/Title/title.css";
+import PFBase from "@patternfly/patternfly/patternfly-base.css";
+
+import {
+    AuthenticatorEmailChallenge,
+    AuthenticatorEmailChallengeResponseRequest,
+} from "@goauthentik/api";
+
+@customElement("ak-stage-authenticator-email")
+export class AuthenticatorEmailStage extends BaseStage<
+    AuthenticatorEmailChallenge,
+    AuthenticatorEmailChallengeResponseRequest
+> {
+    static get styles(): CSSResult[] {
+        return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
+    }
+
+    renderEmailInput(): TemplateResult {
+        return html`<header class="pf-c-login__main-header">
+                <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
+            </header>
+            <div class="pf-c-login__main-body">
+                <form
+                    class="pf-c-form"
+                    @submit=${(e: Event) => {
+                        this.submitForm(e);
+                    }}
+                >
+                    <ak-form-static
+                        class="pf-c-form__group"
+                        userAvatar="${this.challenge.pendingUserAvatar}"
+                        user=${this.challenge.pendingUser}
+                    >
+                        <div slot="link">
+                            <a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
+                                >${msg("Not you?")}</a
+                            >
+                        </div>
+                    </ak-form-static>
+                    <ak-form-element
+                        label="${msg("Configure your email")}"
+                        required
+                        class="pf-c-form__group"
+                        .errors=${(this.challenge?.responseErrors || {})["email"]}
+                    >
+                        <input
+                            type="email"
+                            name="email"
+                            placeholder="${msg("Please enter your email address.")}"
+                            autofocus=""
+                            autocomplete="email"
+                            class="pf-c-form-control"
+                            required
+                        />
+                    </ak-form-element>
+                    ${this.renderNonFieldErrors()}
+                    <div class="pf-c-form__group pf-m-action">
+                        <button type="submit" class="pf-c-button pf-m-primary pf-m-block">
+                            ${msg("Continue")}
+                        </button>
+                    </div>
+                </form>
+            </div>
+            <footer class="pf-c-login__main-footer">
+                <ul class="pf-c-login__main-footer-links"></ul>
+            </footer>`;
+    }
+
+    renderEmailOTPInput(): TemplateResult {
+        return html`<header class="pf-c-login__main-header">
+                <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
+            </header>
+            <div class="pf-c-login__main-body">
+                <ak-form-static
+                    class="pf-c-form__group"
+                    userAvatar="${this.challenge.pendingUserAvatar}"
+                    user=${this.challenge.pendingUser}
+                >
+                    <div slot="link">
+                        <a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
+                            >${msg("Not you?")}</a
+                        >
+                    </div>
+                </ak-form-static>
+                A verification token has been sent to your configured email address
+                ${this.challenge.email}
path: web/src/flow/stages/authenticator_email/AuthenticatorEmailStage.ts
text: @user1:
```suggestion
                ${ifDefined(this.challenge.email)}
```

This may be null from the API, but should never happen in this case. Let's handle it anyway

diff_hunk: @@ -48,6 +50,8 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
 
     deviceIcon(): string {
         switch (this.deviceChallenge?.deviceClass) {
+            case DeviceClassesEnum.Email:
+                return "fa-key";
path: web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageCode.ts
text: @user1:
Icon to be refined.

Btw, the icons of SMS and TOTP below are not consistent with the other ones defined

diff_hunk: @@ -0,0 +1,193 @@
+from os import R_OK, access
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.mail.backends.base import BaseEmailBackend
+from django.core.mail.backends.smtp import EmailBackend
+from django.db import models
+from django.template import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
+from authentik.lib.config import CONFIG
+from authentik.lib.models import SerializerModel
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator.models import SideChannelDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class EmailTemplates(models.TextChoices):
+    """Templates used for rendering the Email"""
+
+    EMAIL_OTP = (
+        "email/email_otp.html",
+        _("Email OTP"),
+    )  # nosec
+
+
+def get_template_choices():
path: authentik/stages/authenticator_email/models.py
text: @user1:
This looks very duplicated from authentik.stages.email.models.get_template_choices. Should probably be refactored to somewhere like authentik.lib.utils.templates

diff_hunk: @@ -0,0 +1,193 @@
+from os import R_OK, access
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.mail.backends.base import BaseEmailBackend
+from django.core.mail.backends.smtp import EmailBackend
+from django.db import models
+from django.template import TemplateSyntaxError
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+
+from authentik.events.models import Event, EventAction
+from authentik.flows.exceptions import StageInvalidException
+from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
+from authentik.lib.config import CONFIG
+from authentik.lib.models import SerializerModel
+from authentik.lib.utils.errors import exception_to_string
+from authentik.stages.authenticator.models import SideChannelDevice
+from authentik.stages.email.utils import TemplateEmailMessage
+
+LOGGER = get_logger()
+
+
+class EmailTemplates(models.TextChoices):
+    """Templates used for rendering the Email"""
+
+    EMAIL_OTP = (
+        "email/email_otp.html",
+        _("Email OTP"),
+    )  # nosec
+
+
+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() or not template_dir.is_dir():
+            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 AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
+    """Use Email-based authentication instead of authenticator-based."""
+
+    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)
+    password = models.TextField(default="", blank=True)
+    use_tls = models.BooleanField(default=False)
+    use_ssl = models.BooleanField(default=False)
+    timeout = models.IntegerField(default=10)
+    from_address = models.EmailField(default="system@authentik.local")
+
+    activate_user_on_success = models.BooleanField(
+        default=False, help_text=_("Activate users upon completion of stage.")
+    )
+
+    token_expiry = models.IntegerField(
+        default=30, help_text=_("Time in minutes the token sent is valid.")
+    )
+    subject = models.TextField(default="authentik")
path: authentik/stages/authenticator_email/models.py
text: @user1:
```suggestion
    subject = models.TextField(default="authentik OTP request")
```

@user3:
I probably wouldn't include OTP in the title since thats not obvious for an enduser, maybe something like `authentik Sign-in code` cc @user2