stages/email: prevent authentik emails from being marked as spam (also add text template support) (#7949)

* use <> style email address with name

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add support for text templates

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix icon display in event log

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add text email templates

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs, update email screenshot

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* prevent prettier from breaking example template

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Optimised images with calibre/image-actions

* Apply suggestions from code review

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

* reword docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Jens L 2023-12-21 14:32:05 +01:00 committed by GitHub
parent 218d61648b
commit ec8f2d4bf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 95 additions and 24 deletions

View file

@ -461,7 +461,7 @@ class NotificationTransport(SerializerModel):
} }
mail = TemplateEmailMessage( mail = TemplateEmailMessage(
subject=subject_prefix + context["title"], subject=subject_prefix + context["title"],
to=[notification.user.email], to=[f"{notification.user.name} <{notification.user.email}>"],
language=notification.user.locale(), language=notification.user.locale(),
template_name="email/event_notification.html", template_name="email/event_notification.html",
template_context=context, template_context=context,

View file

@ -110,7 +110,7 @@ class EmailStageView(ChallengeStageView):
try: try:
message = TemplateEmailMessage( message = TemplateEmailMessage(
subject=_(current_stage.subject), subject=_(current_stage.subject),
to=[email], to=[f"{pending_user.name} <{email}>"],
language=pending_user.locale(self.request), language=pending_user.locale(self.request),
template_name=current_stage.template, template_name=current_stage.template,
template_context={ template_context={

View file

@ -0,0 +1,8 @@
{% load i18n %}{% translate "Welcome!" %}
{% translate "We're excited to have you get started. First, you need to confirm your account. Just open the link below." %}
{{ url }}
--
Powered by goauthentik.io.

View file

@ -0,0 +1,18 @@
{% load authentik_stages_email %}{% load i18n %}{% translate "Dear authentik user," %}
{% translate "The following notification was created:" %}
{{ body|indent }}
{% if key_value %}
{% translate "Additional attributes:" %}
{% for key, value in key_value.items %}
{{ key }}: {{ value|indent }}{% endfor %}
{% endif %}
{% if source %}{% blocktranslate with name=source.from %}
This email was sent from the notification transport {{ name }}.
{% endblocktranslate %}{% endif %}
--
Powered by goauthentik.io.

View file

@ -0,0 +1,12 @@
{% load i18n %}{% load humanize %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}
{% blocktrans %}
You recently requested to change your password for your authentik account. Use the link below to set a new password.
{% endblocktrans %}
{{ url }}
{% blocktrans with expires=expires|naturaltime %}
If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}.
{% endblocktrans %}
--
Powered by goauthentik.io.

View file

@ -0,0 +1,7 @@
{% load i18n %}authentik Test-Email
{% blocktrans %}
This is a test email to inform you, that you've successfully configured authentik emails.
{% endblocktrans %}
--
Powered by goauthentik.io.

View file

@ -29,3 +29,9 @@ def inline_static_binary(path: str) -> str:
b64content = b64encode(_file.read().encode()) b64content = b64encode(_file.read().encode())
return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}" return f"data:image/{result.suffix};base64,{b64content.decode('utf-8')}"
return path return path
@register.filter(name="indent")
def indent_string(val, num_spaces=4):
"""Intent text by a given amount of spaces"""
return val.replace("\n", "\n" + " " * num_spaces)

View file

@ -58,9 +58,11 @@ class TestEmailStageSending(FlowTestCase):
events = Event.objects.filter(action=EventAction.EMAIL_SENT) events = Event.objects.filter(action=EventAction.EMAIL_SENT)
self.assertEqual(len(events), 1) self.assertEqual(len(events), 1)
event = events.first() event = events.first()
self.assertEqual(event.context["message"], f"Email to {self.user.email} sent") self.assertEqual(
event.context["message"], f"Email to {self.user.name} <{self.user.email}> sent"
)
self.assertEqual(event.context["subject"], "authentik") self.assertEqual(event.context["subject"], "authentik")
self.assertEqual(event.context["to_email"], [self.user.email]) self.assertEqual(event.context["to_email"], [f"{self.user.name} <{self.user.email}>"])
self.assertEqual(event.context["from_email"], "system@authentik.local") self.assertEqual(event.context["from_email"], "system@authentik.local")
def test_pending_fake_user(self): def test_pending_fake_user(self):

View file

@ -94,7 +94,7 @@ class TestEmailStage(FlowTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik") self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [self.user.email]) self.assertEqual(mail.outbox[0].to, [f"{self.user.name} <{self.user.email}>"])
@patch( @patch(
"authentik.stages.email.models.EmailStage.backend_class", "authentik.stages.email.models.EmailStage.backend_class",
@ -114,7 +114,7 @@ class TestEmailStage(FlowTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik") self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, ["foo@bar.baz"]) self.assertEqual(mail.outbox[0].to, [f"{self.user.name} <foo@bar.baz>"])
@patch( @patch(
"authentik.stages.email.models.EmailStage.backend_class", "authentik.stages.email.models.EmailStage.backend_class",

View file

@ -4,6 +4,7 @@ from functools import lru_cache
from pathlib import Path from pathlib import Path
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template.exceptions import TemplateDoesNotExist
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import translation from django.utils import translation
@ -24,9 +25,15 @@ class TemplateEmailMessage(EmailMultiAlternatives):
"""Wrapper around EmailMultiAlternatives with integrated template rendering""" """Wrapper around EmailMultiAlternatives with integrated template rendering"""
def __init__(self, template_name=None, template_context=None, language="", **kwargs): def __init__(self, template_name=None, template_context=None, language="", **kwargs):
super().__init__(**kwargs)
with translation.override(language): with translation.override(language):
html_content = render_to_string(template_name, template_context) html_content = render_to_string(template_name, template_context)
super().__init__(**kwargs) try:
self.content_subtype = "html" text_content = render_to_string(
template_name.replace("html", "txt"), template_context
)
self.body = text_content
except TemplateDoesNotExist:
pass
self.mixed_subtype = "related" self.mixed_subtype = "related"
self.attach_alternative(html_content, "text/html") self.attach_alternative(html_content, "text/html")

View file

@ -285,10 +285,12 @@ export class EventInfo extends AKElement {
} }
renderEmailSent() { renderEmailSent() {
let body = this.event.context.body as string;
body = body.replace("cid:logo.png", "/static/dist/assets/icons/icon_left_brand.png");
return html`<div class="pf-c-card__title">${msg("Email info:")}</div> return html`<div class="pf-c-card__title">${msg("Email info:")}</div>
<div class="pf-c-card__body">${this.getEmailInfo(this.event.context)}</div> <div class="pf-c-card__body">${this.getEmailInfo(this.event.context)}</div>
<ak-expand> <ak-expand>
<iframe srcdoc=${this.event.context.body}></iframe> <iframe srcdoc=${body}></iframe>
</ak-expand>`; </ak-expand>`;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -25,6 +25,10 @@ return True
You can also use custom email templates, to use your own design or layout. You can also use custom email templates, to use your own design or layout.
:::info
Starting with authentik 2024.1, it is possible to create `.txt` files with the same name as the `.html` template. If a matching `.txt` file exists, the email sent will be a multipart email with both the text and HTML template.
:::
import Tabs from "@theme/Tabs"; import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem"; import TabItem from "@theme/TabItem";
@ -81,13 +85,17 @@ Templates are rendered using Django's templating engine. The following variables
- `user`: The pending user object. - `user`: The pending user object.
- `expires`: The timestamp when the token expires. - `expires`: The timestamp when the token expires.
<!-- prettier-ignore-start -->
```html ```html
{# This is how you can write comments which aren't rendered. #} {# Extend this {# This is how you can write comments which aren't rendered. #}
template from the base email template, which includes base layout and CSS. #} {% {# Extend this template from the base email template, which includes base layout and CSS. #}
extends "email/base.html" %} {# Load the internationalization module to {% extends "email/base.html" %}
translate strings, and humanize to show date-time #} {% load i18n %} {% load {# Load the internationalization module to translate strings, and humanize to show date-time #}
humanize %} {# The email/base.html template uses a single "content" block #} {% {% load i18n %}
block content %} {% load humanize %}
{# The email/base.html template uses a single "content" block #}
{% block content %}
<tr> <tr>
<td class="alert alert-success"> <td class="alert alert-success">
{% blocktrans with username=user.username %} Hi {{ username }}, {% {% blocktrans with username=user.username %} Hi {{ username }}, {%
@ -99,9 +107,9 @@ block content %}
<table width="100%" cellpadding="0" cellspacing="0"> <table width="100%" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="content-block"> <td class="content-block">
{% blocktrans %} You recently requested to change your {% blocktrans %}
password for you authentik account. Use the button below to You recently requested to change your password for you authentik account. Use the button below to set a new password.
set a new password. {% endblocktrans %} {% endblocktrans %}
</td> </td>
</tr> </tr>
<tr> <tr>
@ -130,8 +138,7 @@ block content %}
href="{{ url }}" href="{{ url }}"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
>{% trans 'Reset >{% trans 'Reset Password' %}</a
Password' %}</a
> >
</td> </td>
</tr> </tr>
@ -145,9 +152,9 @@ block content %}
</tr> </tr>
<tr> <tr>
<td class="content-block"> <td class="content-block">
{% blocktrans with expires=expires|naturaltime %} If you did {% blocktrans with expires=expires|naturaltime %}
not request a password change, please ignore this Email. The If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}.
link above is valid for {{ expires }}. {% endblocktrans %} {% endblocktrans %}
</td> </td>
</tr> </tr>
</table> </table>
@ -155,3 +162,5 @@ block content %}
</tr> </tr>
{% endblock %} {% endblock %}
``` ```
<!-- prettier-ignore-end -->