diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d9c0f4170..cb4126399 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -126,9 +126,6 @@ services: dev: aliases: - mail - ports: - - "5005:5005" - - "5006:5006" volumes: home: diff --git a/docker/mail.Dockerfile b/docker/mail.Dockerfile index e0d82af41..ae5345a3e 100644 --- a/docker/mail.Dockerfile +++ b/docker/mail.Dockerfile @@ -1,13 +1,8 @@ -FROM golang:rc-alpine -RUN apk add --no-cache git -RUN apk add --no-cache gcc -RUN apk add --no-cache musl-dev -RUN git clone "https://github.com/jamillosantos/mailslurper.git" /opt/mailslurper -WORKDIR /opt/mailslurper/cmd/mailslurper -RUN go get github.com/mjibson/esc -RUN cd /opt/mailslurper/cmd/mailslurper -COPY ./mailslurper.conf config.json -RUN go get -RUN go generate -RUN go build -ENTRYPOINT ["/opt/mailslurper/cmd/mailslurper/mailslurper"] +FROM debian:stable-slim + +RUN apt-get update && apt-get install -y python3 python3-aiosmtpd python3-termcolor + +WORKDIR /opt/mail +COPY mail.py mail.py + +ENTRYPOINT ["python3", "mail.py"] diff --git a/docker/mail.py b/docker/mail.py new file mode 100644 index 000000000..1d4b28dec --- /dev/null +++ b/docker/mail.py @@ -0,0 +1,49 @@ +import asyncio +import email +import email.policy +import sys + +from aiosmtpd.controller import Controller +from termcolor import cprint + + +class PycroftDebugging: + COLOR_HEADER = "blue" + COLOR_CONTENT_BORDER = "magenta" + + async def handle_DATA(self, server, session, envelope): + message = email.message_from_bytes(envelope.content, policy=email.policy.default) + + # Print headers + for key, value in message.items(): + cprint(f"{key}: {value}", self.COLOR_HEADER) + + # Print message parts, i.e. text/plain, text/html + for part in message.walk(): + try: + content = part.get_content() + except KeyError: + continue + + print() + content_type = part.get_content_type() + cprint(content_type, self.COLOR_CONTENT_BORDER) + cprint("⌄" * len(content_type), self.COLOR_CONTENT_BORDER) + print(content) + cprint("⌃" * len(content_type), self.COLOR_CONTENT_BORDER) + + print() + sys.stdout.flush() + + return "250 Message accepted for delivery" + + +controller = Controller(PycroftDebugging(), hostname="0.0.0.0", port=2500) +controller.start() + +loop = asyncio.get_event_loop() +try: + loop.run_forever() +finally: + loop.close() + controller.stop() diff --git a/docker/mailslurper.conf b/docker/mailslurper.conf deleted file mode 100644 index 09f03120f..000000000 --- a/docker/mailslurper.conf +++ /dev/null @@ -1,27 +0,0 @@ -{ - "wwwAddress": "0.0.0.0", - "wwwPort": 5005, - "wwwPublicURL": "", - "serviceAddress": "0.0.0.0", - "servicePort": 5006, - "servicePublicURL": "", - "smtpAddress": "0.0.0.0", - "smtpPort": 2500, - "dbEngine": "SQLite", - "dbHost": "", - "dbPort": 0, - "dbDatabase": "./mailslurper.db", - "dbUserName": "", - "dbPassword": "", - "maxWorkers": 1000, - "autoStartBrowser": false, - "keyFile": "", - "certFile": "", - "adminKeyFile": "", - "adminCertFile": "", - "authenticationScheme": "", - "authSecret": "", - "authSalt": "", - "authTimeoutInMinutes": 120, - "credentials": {} -} diff --git a/justfile b/justfile index 832b3c996..4e9c20f18 100644 --- a/justfile +++ b/justfile @@ -134,3 +134,7 @@ _stop_all: _up +containers: {{ drc }} up --wait {{ containers }} + +show_emails: + {{ drc }} --progress=none up -d dev-celery-worker dev-mail + {{ drc }} logs --no-log-prefix -f dev-mail diff --git a/pycroft/lib/mail.py b/pycroft/lib/mail.py index 03c54cc42..e23aab544 100644 --- a/pycroft/lib/mail.py +++ b/pycroft/lib/mail.py @@ -248,18 +248,3 @@ def send_template_mails( from pycroft.task import send_mails_async send_mails_async.delay(mails) - - -def send_plain_mails(email_addresses: list[str], subject: str, body_plain: str) -> None: - mails = [] - - for addr in email_addresses: - mail = Mail(to_name='', - to_address=addr, - subject=subject, - body_plain=body_plain) - mails.append(mail) - - from pycroft.task import send_mails_async - - send_mails_async.delay(mails) diff --git a/pycroft/templates/mail/base.html.j2 b/pycroft/templates/mail/base.html.j2 new file mode 100644 index 000000000..e54dc9009 --- /dev/null +++ b/pycroft/templates/mail/base.html.j2 @@ -0,0 +1,11 @@ +{%- set links = [] -%} +{%- import "macros/link.html" as link with context -%} +{%- if mode == 'html' -%} +
+{% endif %}
+{%- block body -%}{%- endblock -%}
+
+{{ link.render_link_list() }}
+{%- if mode == 'html' -%}
+
+{%- endif -%} diff --git a/pycroft/templates/mail/macros/link.html b/pycroft/templates/mail/macros/link.html index 2b8571917..42c23d8cd 100644 --- a/pycroft/templates/mail/macros/link.html +++ b/pycroft/templates/mail/macros/link.html @@ -1,4 +1,4 @@ -{%- macro render_link(text, url, mode) -%} +{%- macro render_link(text, url) -%} {%- if mode == 'html' -%} {{ text }} {%- else -%} @@ -6,7 +6,7 @@ {%- endif -%} {%- endmacro -%} -{%- macro render_link_list(mode) -%} +{% macro render_link_list() -%} {%- if mode != 'html' -%} {%- for n in range(links|length) -%} [{{ n }}] {{ links[n] }} diff --git a/pycroft/templates/mail/member_negative_balance.html b/pycroft/templates/mail/member_negative_balance.html index aeb8ff403..c5d342468 100644 --- a/pycroft/templates/mail/member_negative_balance.html +++ b/pycroft/templates/mail/member_negative_balance.html @@ -1,4 +1,5 @@ -
+{%- extends "base.html.j2" -%}
+{%- block body -%}
 * English version below *
 
 Hallo {{ user.name }},
@@ -97,4 +98,4 @@
 [4] Office hours:         https://agdsn.de/sipa/pages/support/contacts
 [5] Finance constitution:
 https://agdsn.de/sipa/documents/legal/beitragsordnung.pdf
-
+{%- endblock -%} diff --git a/pycroft/templates/mail/member_request_denied.html b/pycroft/templates/mail/member_request_denied.html index 57983007f..0a59a6c34 100644 --- a/pycroft/templates/mail/member_request_denied.html +++ b/pycroft/templates/mail/member_request_denied.html @@ -1,4 +1,5 @@ -
+{%- extends "base.html.j2" -%}
+{%- block body -%}
 English version below!
 
 ---
@@ -30,6 +31,4 @@
 
 Best Regards
 Your AG DSN
-
- - +{%- endblock -%} diff --git a/pycroft/templates/mail/member_request_merged.html b/pycroft/templates/mail/member_request_merged.html index 233eb5906..7fbdfa70c 100644 --- a/pycroft/templates/mail/member_request_merged.html +++ b/pycroft/templates/mail/member_request_merged.html @@ -1,4 +1,5 @@ -
+{%- extends "base.html.j2" -%}
+{%- block body -%}
 English version below!
 
 ---
@@ -41,6 +42,4 @@
 
 Best Regards
 Your AG DSN
-
- - +{%- endblock -%} diff --git a/pycroft/templates/mail/member_request_pending.html b/pycroft/templates/mail/member_request_pending.html index ac4c3df0c..65ef557bb 100644 --- a/pycroft/templates/mail/member_request_pending.html +++ b/pycroft/templates/mail/member_request_pending.html @@ -1,4 +1,5 @@ -
+{%- extends "base.html.j2" -%}
+{%- block body -%}
 English version below!
 
 ---
@@ -70,6 +71,4 @@
 
 Best Regards
 Your AG DSN
-
- - +{%- endblock -%} diff --git a/pycroft/templates/mail/task_failed.html b/pycroft/templates/mail/task_failed.html index 73e8886d6..62a9613bf 100644 --- a/pycroft/templates/mail/task_failed.html +++ b/pycroft/templates/mail/task_failed.html @@ -1,4 +1,5 @@ -
+{%- extends "base.html.j2" -%}
+{%- block body -%}
 Hallo Supportteam,
 
 die geplante Pycroft {{ task.type }} Aufgabe #{{ task.id }} von {{ task.creator.name }}
@@ -15,6 +16,4 @@
 
 Viele Grüße
 Pycroft
-
- - +{%- endblock -%} diff --git a/pycroft/templates/mail/user_confirm_email.html b/pycroft/templates/mail/user_confirm_email.html index e368a9c62..2e5d0b6a6 100644 --- a/pycroft/templates/mail/user_confirm_email.html +++ b/pycroft/templates/mail/user_confirm_email.html @@ -1,6 +1,6 @@ -{%- set links = [] -%} +{%- extends "base.html.j2" -%} {%- import "macros/link.html" as link with context -%} -
+{%- block body -%}
 English version below!
 
 ---
@@ -9,26 +9,23 @@
 
 bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst:
 
-{{ link.render_link(email_confirm_url, email_confirm_url, mode) }}
+{{ email_confirm_url }}
 
 Falls du dich nicht bei uns registriert hast, kannst du diese E-Mail ignorieren.
 
 Viele Grüße
 Deine AG DSN
 
-{{ link.render_link_list(mode) }}
 --
 
 Hello {{ user.name }},
 
 Please confirm your email address by clicking on the following link:
 
-{{ link.render_link(email_confirm_url, email_confirm_url, mode) }}
+{{ email_confirm_url }}
 
 If you have not registered with us, you can ignore this email.
 
 Best Regards
 Your AG DSN
-
-{{ link.render_link_list(mode) }}
-
+{%- endblock -%} diff --git a/pycroft/templates/mail/user_created.html b/pycroft/templates/mail/user_created.html index 10471bc5b..7d3251ef2 100644 --- a/pycroft/templates/mail/user_created.html +++ b/pycroft/templates/mail/user_created.html @@ -1,7 +1,6 @@ -{%- set links = [] -%} +{%- extends "base.html.j2" -%} {%- import "macros/link.html" as link with context -%} - -
+{%- block body -%}
 English version below!
 
 ---
@@ -21,32 +20,32 @@
 Wenn du Interesse hast und mal einen Blick auf Technik werfen willst, die man
 sonst eher nicht zu Gesicht bekommt, würden wir uns freuen dich bei einer unserer
 Teamsitzungen begrüßen zu dürfen.
-Wann, wo und wie die Sitzungen stattfinden, kannst du unserer {{ link.render_link("Teamübersicht", "https://agdsn.de/sipa/pages/about_us/teams", mode) }}
+Wann, wo und wie die Sitzungen stattfinden, kannst du unserer {{ link.render_link("Teamübersicht", "https://agdsn.de/sipa/pages/about_us/teams") }}
 entnehmen.
 
 Deine Mitgliedschaft beginnt je nach gewählter Option entweder sofort oder
 mit deinem Einzug in dein Wohnheimzimmer.
-Den aktuellen Status deiner Mitgliedschaft kannst du in der {{ link.render_link("Usersuite", "https://agdsn.de/sipa/usersuite/", mode) }}
+Den aktuellen Status deiner Mitgliedschaft kannst du in der {{ link.render_link("Usersuite", "https://agdsn.de/sipa/usersuite/") }}
 einsehen.
 
 Deine Nutzer-ID: {{ user_id }}
 Dein Nutzername: {{ user.login }}
 
-Bitte denke daran, den {{ link.render_link("Mitgliedsbeitrag", "https://agdsn.de/sipa/pages/membership/membership_contribution", mode) }} immer pünktlich
+Bitte denke daran, den {{ link.render_link("Mitgliedsbeitrag", "https://agdsn.de/sipa/pages/membership/membership_contribution") }} immer pünktlich
 (vor Ende des Monats) mit dem korrkten Verwendungszweck zu überweisen.
 Es ist auch möglich für mehrere Monate aufeinmal zu bezahlen.
 
 Mit deiner Mitgliedschaft erhälst du ein E-Mail Konto mit der Adresse {{ user.email_internal }}.
 Standardmäßig werden alle E-Mails an deine angegebene Adresse weitergeleietet.
 Diese Weiterleitung kannst du in der Usersuite deaktivieren, wenn du das Konto
-eigenständig nutzen möchtest. Weitere Informationen findest du auf unserer {{ link.render_link("Internetseite", "https://agdsn.de/sipa/pages/service/email", mode) }}.
+eigenständig nutzen möchtest. Weitere Informationen findest du auf unserer {{ link.render_link("Internetseite", "https://agdsn.de/sipa/pages/service/email") }}.
 
-Weitere Schritte (u.a. zur Verwendung des Netzwerkes) findest du {{ link.render_link("hier", "https://agdsn.de/sipa/pages/membership/registration_account_created", mode) }}.
+Weitere Schritte (u.a. zur Verwendung des Netzwerkes) findest du {{ link.render_link("hier", "https://agdsn.de/sipa/pages/membership/registration_account_created") }}.
 
 Viele Grüße
 Deine AG DSN
 
-{{ link.render_link_list(mode) }}
+{{ link.render_link_list() }}
 --
 
 Hello {{ user.name }},
@@ -61,28 +60,26 @@
 network maintenance, software development and many more. Besides, you can add
 some extra extracurricular activity to your CV and have the opportunity to see
 and work with usually hidden technology.
-We would be happy to welcome you with us. Be our guest at one of our {{ link.render_link("team meetings", "https://agdsn.de/sipa/pages/about_us/teams", mode) }}.
+We would be happy to welcome you with us. Be our guest at one of our {{ link.render_link("team meetings", "https://agdsn.de/sipa/pages/about_us/teams") }}.
 
 Depending on the chosen option, your membership starts either immediately or
 with you moving into your dorm room.
-You can check the current status of your membership in the {{ link.render_link("Usersuite", "https://agdsn.de/sipa/usersuite/", mode) }}.
+You can check the current status of your membership in the {{ link.render_link("Usersuite", "https://agdsn.de/sipa/usersuite/") }}.
 
 Your User-ID:   {{ user_id }}
 Your Username:  {{ user.login }}
 
-Please remember to transfer the {{ link.render_link("membership contribution", "https://agdsn.de/sipa/pages/membership/membership_contribution", mode) }}
+Please remember to transfer the {{ link.render_link("membership contribution", "https://agdsn.de/sipa/pages/membership/membership_contribution") }}
 always in time (before the end of the month) with the correct payment description.
 It is also possible to pay for multiple months at once.
 
 With your membership you will receive an email account with the address {{ user.email_internal }}.
 By default, all emails will be forwarded to the address you specified.
 You can disable this forwarding in the usersuite if you want to use the standalone.
-For more information, please visit our {{ link.render_link("website", "https://agdsn.de/sipa/pages/service/email", mode) }}.
+For more information, please visit our {{ link.render_link("website", "https://agdsn.de/sipa/pages/service/email") }}.
 
-Further steps (including how to use the network) can be found {{ link.render_link("here", "https://agdsn.de/sipa/pages/membership/registration_account_created", mode) }}
+Further steps (including how to use the network) can be found {{ link.render_link("here", "https://agdsn.de/sipa/pages/membership/registration_account_created") }}
 
 Best Regards
 Your AG DSN
-
-{{ link.render_link_list(mode) }}
-
+{%- endblock -%} diff --git a/pycroft/templates/mail/user_moved_in.html b/pycroft/templates/mail/user_moved_in.html index 259d8b6a9..c4961db81 100644 --- a/pycroft/templates/mail/user_moved_in.html +++ b/pycroft/templates/mail/user_moved_in.html @@ -1,4 +1,6 @@ -
+{% extends "base.html.j2" %}
+{%- import "macros/link.html" as link with context -%}
+{% block body %}
 English version below!
 
 ---
@@ -12,17 +14,18 @@
 {{ user.address.zip_code }} {{ user.address.city }}
 {{ user.address.country }}
 
-Du kannst deinen Netzwerkanschluss nach vorherigem Login auf unserer Internetseite
+Du kannst deinen Netzwerkanschluss nach vorherigem Login auf unserer {{ link.render_link("Internetseite", "https://agdsn.de/sipa/usersuite/") }}
 freischalten.
 Falls du deinen Netzwerkanschluss bereits freigeschaltet hast, sollte dieser nun
 mit der korrekten MAC Adresse in dem oben angegebenen Zimmer funktionieren.
 
 Um bei anstehenden Wartungsarbeiten an deinem Netzwerkanschluss immer rechtzeitig
-informiert zu sein, empfehlen wir dir unsere Statusseite zu abonnieren.
+informiert zu sein, empfehlen wir dir unsere {{ link.render_link("Statusseite", "https://status.agdsn.net/subscribers/subscribe") }} zu abonnieren.
 
 Viele Grüße
 Deine AG DSN
 
+{{ link.render_link_list() }}
 --
 
 Hello {{ user.name }},
@@ -34,13 +37,13 @@
 {{ user.address.zip_code }} {{ user.address.city }}
 {{ user.address.country }}
 
-You can enable your network connection after logging in on our webpage.
+You can enable your network connection after logging in on our {{ link.render_link("webpage", "https://agdsn.de/sipa/usersuite/") }}.
 If you have already enabled your network connection, it should now
 work with the correct MAC address in the room specified above.
 
 To always receive the latest news about upcoming maintenance, we recommend to
-subscribe to our status page.
+subscribe to our {{ link.render_link("status page", "https://status.agdsn.net/subscribers/subscribe") }}.
 
 Best Regards
 Your AG DSN
-
+{% endblock %} diff --git a/pycroft/templates/mail/user_reset_password.html b/pycroft/templates/mail/user_reset_password.html index 2e22d1ccf..080d0b29d 100644 --- a/pycroft/templates/mail/user_reset_password.html +++ b/pycroft/templates/mail/user_reset_password.html @@ -1,6 +1,6 @@ -{%- set links = [] -%} +{% extends "base.html.j2" %} {%- import "macros/link.html" as link with context -%} -
+{% block body %}
 English version below!
 
 ---
@@ -15,12 +15,11 @@
 Dein Nutzername lautet: {{ user.login }}
 
 Passwort zurücksetzen:
-{{ link.render_link(password_reset_url, password_reset_url, mode) }}
+{{ password_reset_url }}
 
 Viele Grüße
 Deine AG DSN
 
-{{ link.render_link_list(mode) }}
 --
 
 Hello {{ user.name }},
@@ -32,10 +31,8 @@
 Your user-id is:  {{ user_id }}
 
 Reset password:
-{{ link.render_link(password_reset_url, password_reset_url, mode) }}
+{{ password_reset_url }}
 
 Best Regards
 Your AG DSN
-
-{{ link.render_link_list(mode) }}
-
+{% endblock %} diff --git a/tests/assertions.py b/tests/assertions.py index cc8f6d147..87b9b09ed 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -3,6 +3,7 @@ # the Apache License, Version 2.0. See the LICENSE file for details import contextlib import typing as t +from collections import abc @contextlib.contextmanager @@ -15,3 +16,13 @@ def assert_unchanged( yield assert [g() for g in value_getter] == old assert {k: g() for k, g in named_value_getter.items()} == named_old + + +# TODO use PEP 695 (Type Parameter Syntax) once on py3.12 +T = t.TypeVar("T") + + +def assert_one(seq: abc.Sequence[T]) -> T: + """assert whether a sequence contains only one element and return it""" + assert (l := len(seq)) == 1, f"Expected one element in sequence, found {l} (sequence: {seq!r})" + return seq[0] diff --git a/tests/lib/user/assertions.py b/tests/lib/user/assertions.py index 82f31035b..b6e2ce403 100644 --- a/tests/lib/user/assertions.py +++ b/tests/lib/user/assertions.py @@ -1,4 +1,8 @@ +import re +import typing as t + from pycroft.helpers.i18n import localized +from pycroft.lib.mail import Mail def assert_account_name(account, expected_name): @@ -15,3 +19,12 @@ def assert_logmessage_startswith(logentry, expected_start: str): assert localized_message.startswith( expected_start ), f"Message {localized_message!r} does not start with {expected_start!r}" + + +def assert_mail_reasonable(mail: t.Any, subject_re: str | re.Pattern | None) -> Mail: + assert " found in mail's plain body" + assert "
" in mail.body_html, "No 
 found in mail's HTML body"
+    if subject_re:
+        assert re.match(
+            subject_re, mail.subject
+        ), f"Mail's subject didn't contain the pattern {subject_re!r}: ({mail.subject=!r})"
diff --git a/tests/lib/user/conftest.py b/tests/lib/user/conftest.py
new file mode 100644
index 000000000..2f3d49911
--- /dev/null
+++ b/tests/lib/user/conftest.py
@@ -0,0 +1,17 @@
+import pytest
+
+from pycroft.lib.mail import Mail
+
+
+@pytest.fixture
+def mail_capture(monkeypatch) -> list[Mail]:
+    mails_captured = []
+
+    class TaskStub:
+        @staticmethod
+        def delay(mails):
+            assert all(isinstance(m, Mail) for m in mails), "didn't get an instance of Mail()"
+            mails_captured.extend(mails)
+
+    monkeypatch.setattr("pycroft.lib.user.send_mails_async", TaskStub)
+    yield mails_captured
diff --git a/tests/lib/user/test_create_user.py b/tests/lib/user/test_create_user.py
index c3f52d4c5..c849a9085 100644
--- a/tests/lib/user/test_create_user.py
+++ b/tests/lib/user/test_create_user.py
@@ -6,7 +6,14 @@
 from pycroft.lib.user import create_user
 from pycroft.model.logging import LogEntry
 from tests import factories
-from .assertions import assert_account_name, assert_membership_groups, assert_logmessage_startswith
+from tests.assertions import assert_one
+
+from .assertions import (
+    assert_account_name,
+    assert_logmessage_startswith,
+    assert_mail_reasonable,
+    assert_membership_groups,
+)
 
 
 @dataclasses.dataclass
@@ -37,25 +44,20 @@ def user_data(self) -> UserData:
             birthdate=date.fromisoformat("1990-01-01"),
         )
 
-    @pytest.fixture(scope="class")
-    def user_mail_capture(self):
-        # TODO actually test whether mails are sent out correctly instead of mocking
-        # mocking is only done because we don't test for the mails anyway
+    @pytest.fixture(scope="class", autouse=True)
+    def new_user(self, class_session, user_data, room, processor, member_group):
         from unittest.mock import patch
-        with patch("pycroft.lib.user.user_send_mails") as p:
-            yield p
 
-    @pytest.fixture(scope="class", autouse=True)
-    def new_user(self, class_session, user_data, room, processor, member_group, user_mail_capture):
-        new_user, _ = create_user(
-            user_data.name,
-            user_data.login,
-            user_data.email,
-            user_data.birthdate,
-            processor=processor,
-            groups=(member_group,),
-            address=room.address,
-        )
+        with patch("pycroft.lib.user.user_send_mails"):
+            new_user, _ = create_user(
+                user_data.name,
+                user_data.login,
+                user_data.email,
+                user_data.birthdate,
+                processor=processor,
+                groups=(member_group,),
+                address=room.address,
+            )
         return new_user
 
     def test_user_base_data(self, new_user, user_data, room):
@@ -92,5 +94,15 @@ def test_finance_account(self, new_user):
         assert new_user.account is not None
         assert new_user.account.balance == 0
 
-    def test_one_mail_sent(self, user_mail_capture):
-        user_mail_capture.assert_called()
+    def test_mail_content(self, processor, member_group, room, mail_capture):
+        create_user(
+            "Jane Doe",
+            "janed",
+            "jane.doe@example.org",
+            date.fromisoformat("2000-03-14"),
+            processor=processor,
+            groups=(member_group,),
+            address=room.address,
+        )
+
+        assert_mail_reasonable(assert_one(mail_capture), subject_re="Willkommen")
diff --git a/tests/lib/user/test_move.py b/tests/lib/user/test_move.py
index 105b6f6ee..ef3abc3c7 100644
--- a/tests/lib/user/test_move.py
+++ b/tests/lib/user/test_move.py
@@ -9,9 +9,11 @@
 from pycroft.model.task_serialization import UserMoveParams
 from pycroft.model.user import User
 from tests import factories
-from tests.assertions import assert_unchanged
+from tests.assertions import assert_one, assert_unchanged
 from tests.lib.user.task_helpers import create_task_and_execute
 
+from .assertions import assert_mail_reasonable
+
 
 class TestUserMove:
     @pytest.fixture(scope="class")
@@ -61,15 +63,17 @@ def test_move_scheduling(
             room_number=new_room_other_building.number,
         )
 
-    def test_moves_into_same_room(self, session, user, processor):
+    def test_moves_into_same_room(self, session, user, processor, mail_capture):
         old_room = user.room
         with pytest.raises(AssertionError):
             lib_user.move(
                 user, old_room.building.id, old_room.level, old_room.number, processor
             )
 
+        assert not mail_capture
+
     def test_moves_into_other_building(
-        self, session, user, processor, new_room_other_building
+        self, session, user, processor, new_room_other_building, mail_capture
     ):
         lib_user.move(
             user,
@@ -82,6 +86,8 @@ def test_moves_into_other_building(
         assert user.hosts[0].room == new_room_other_building
         # TODO test for changing ip
 
+        assert_mail_reasonable(assert_one(mail_capture), subject_re="Wohnortänderung")
+
 
 class TestMoveImpl:
     @pytest.fixture(scope="class")
@@ -110,11 +116,13 @@ def full_params(self, new_room) -> dict[str]:
             "room_number": new_room.number,
         }
 
-    def test_successful_move_execution(self, session, user, new_room, full_params):
+    def test_successful_move_execution(self, session, user, new_room, full_params, mail_capture):
         task = create_task_and_execute(TaskType.USER_MOVE, user, full_params)
         assert task.status == TaskStatus.EXECUTED
         assert user.room == new_room
 
+        assert_mail_reasonable(assert_one(mail_capture), subject_re="Wohnortänderung")
+
     @pytest.mark.parametrize(
         "param_keys, error_needle",
         (
@@ -131,6 +139,7 @@ def test_all_params_required(
         full_params,
         param_keys: t.Iterable[str],
         error_needle: str,
+        mail_capture,
     ):
         params = {k: v for k, v in full_params.items() if k in param_keys}
         with assert_unchanged(lambda: user.room):
@@ -139,3 +148,5 @@ def test_all_params_required(
         assert len(task.errors) == 1
         [error] = task.errors
         assert error_needle in error.lower()
+
+        assert not mail_capture
diff --git a/tests/lib/user/test_move_in.py b/tests/lib/user/test_move_in.py
index 6ab44e7c5..38ed80d49 100644
--- a/tests/lib/user/test_move_in.py
+++ b/tests/lib/user/test_move_in.py
@@ -8,8 +8,10 @@
 from pycroft.model.task_serialization import UserMoveInParams
 from pycroft.model.user import User
 from tests import factories
+from tests.assertions import assert_one
 from . import ExampleUserData
 from .task_helpers import create_task_and_execute
+from .assertions import assert_mail_reasonable
 
 
 @pytest.fixture(scope="module")
@@ -38,7 +40,7 @@ class TestUserMoveIn:
     def user_data(self):
         return ExampleUserData
 
-    def test_move_in(self, session, user, room, processor, config, mac):
+    def test_move_in(self, session, user, room, processor, config, mac, mail_capture):
         lib_user.move_in(
             user,
             building_id=room.building.id,
@@ -51,11 +53,9 @@ def test_move_in(self, session, user, room, processor, config, mac):
         assert user.room == room
         assert user.address == user.room.address
 
-        assert len(hosts := user.hosts) == 1
-        assert len(interfaces := hosts[0].interfaces) == 1
-        user_interface = interfaces[0]
-        assert len(user_interface.ips) == 1
+        user_interface = assert_one(assert_one(user.hosts).interfaces)
         assert user_interface.mac == mac
+        assert_one(user_interface.ips)
 
         # checks the initial group memberships
         active_user_groups = user.active_property_groups()
@@ -63,8 +63,9 @@ def test_move_in(self, session, user, room, processor, config, mac):
             assert group in active_user_groups
 
         assert not user.has_property("reduced_membership_fee")
+        assert_mail_reasonable(assert_one(mail_capture), subject_re="Wohnortänderung")
 
-    def test_move_in_scheduling(self, session, utcnow, user, room, processor, config):
+    def test_move_in_scheduling(self, session, utcnow, user, room, processor, config, mail_capture):
         processing_user = processor
         test_mac = '00:de:ad:be:ef:00'
         lib_user.move_in(
@@ -83,10 +84,11 @@ def test_move_in_scheduling(self, session, utcnow, user, room, processor, config
             room_number="1",
             mac=test_mac,
         )
+        assert not mail_capture
 
 
 class TestMoveInImpl:
-    def test_successful_move_in_execution_without_mac(self, session, user, room):
+    def test_successful_move_in_execution_without_mac(self, session, user, room, mail_capture):
         task = create_task_and_execute(
             TaskType.USER_MOVE_IN,
             user,
@@ -98,9 +100,10 @@ def test_successful_move_in_execution_without_mac(self, session, user, room):
         )
         assert isinstance(task, UserTask)
         assert_successful_move_in_execution(task, room)
+        assert_mail_reasonable(assert_one(mail_capture), subject_re="Wohnortänderung")
         assert not user.hosts
 
-    def test_successful_move_in_execution_minimal(self, session, user, room, mac):
+    def test_successful_move_in_execution_minimal(self, session, user, room, mac, mail_capture):
         task = create_task_and_execute(
             TaskType.USER_MOVE_IN,
             user,
@@ -113,9 +116,9 @@ def test_successful_move_in_execution_minimal(self, session, user, room, mac):
         )
         assert isinstance(task, UserTask)
         assert_successful_move_in_execution(task, room)
-        assert len(hosts := user.hosts) == 1
-        assert len(interfaces := hosts[0].interfaces) == 1
-        assert interfaces[0].mac == mac
+        interface = assert_one(assert_one(user.hosts).interfaces)
+        assert interface.mac == mac
+        assert_mail_reasonable(assert_one(mail_capture), subject_re="Wohnortänderung")
 
 
 def assert_successful_move_in_execution(task: UserTask, room: Room):
@@ -125,7 +128,5 @@ def assert_successful_move_in_execution(task: UserTask, room: Room):
 
 def assert_failing_move_execution(task: UserTask, error_needle: str):
     assert task.status == TaskStatus.FAILED
-    assert len(task.errors) == 1
-    [error] = task.errors
-    assert error_needle in error.lower()
+    assert error_needle in assert_one(task.errors).lower()
     assert task.user.room is None
diff --git a/tools/semgrep.yml b/tools/semgrep.yml
index 65beeeae2..557f26c4b 100644
--- a/tools/semgrep.yml
+++ b/tools/semgrep.yml
@@ -214,3 +214,17 @@ rules:
     severity: ERROR
     paths:
       include: ["web"]
+
+  - id: no-plain-a-tags-in-mails
+    pattern: $TEXT
+    message: |
+      Don't use `` tags in a mail template.
+      Use the mode-aware `render_link` macro instead.
+      Please do not forget to add {{ link.render_link_list() }} at end of the email.
+    fix: >
+      {{ link.render_link("$TEXT", "$HREF") }}
+    severity: ERROR
+    languages: [ html ]
+    paths:
+      include:
+        - "pycroft/templates/mail/*.html"